Skip to content

[v2] Make ToolTaskHandler.getTask/getTaskResult optional and actually invoke them#1764

Open
felixweinberger wants to merge 2 commits intomainfrom
fweinberger/tooltask-handlers
Open

[v2] Make ToolTaskHandler.getTask/getTaskResult optional and actually invoke them#1764
felixweinberger wants to merge 2 commits intomainfrom
fweinberger/tooltask-handlers

Conversation

@felixweinberger
Copy link
Contributor

@felixweinberger felixweinberger commented Mar 25, 2026

Makes getTask and getTaskResult optional on ToolTaskHandler and wires them into the tasks/get and tasks/result request paths. Supersedes #1332.

Motivation and Context

These handlers were defined on the interface but never invoked — three code paths bypassed them and hit TaskStore directly:

  1. TaskManager.handleGetTask_requireTaskStore.getTask
  2. TaskManager.handleGetTaskPayload_requireTaskStore.getTask/getTaskResult
  3. McpServer.handleAutomaticTaskPollingctx.task.store

The handlers exist to support proxying external job systems (AWS Step Functions, CI/CD pipelines, etc.) where the external system is the source of truth for task state. But every test and example implementation was pure boilerplate delegation to ctx.task.store, which made the handlers look vestigial.

This PR makes them optional:

  • Omitted (the common case) → TaskStore handles everything. Zero boilerplate.
  • Provided → invoked for tasks/get, tasks/result, and automatic polling. Useful for external-system proxies.

Isolation

All dispatch logic lives in ExperimentalMcpServerTasks (the experimental module that already owns registerToolTask):

  • _taskToTool map, _recordTask(), _installOverrides(), _dispatch() — ~50 lines, all contained there
  • Core gains one internal method: TaskManager.setTaskOverrides() + two lookup checks. No changes to the public TaskManagerOptions type.
  • McpServer gains ~5 lines: construct experimental module eagerly, install overrides, record tasks on creation.

How Has This Been Tested?

  • All existing task lifecycle tests pass (boilerplate handlers removed — net -180 LOC in tests)
  • New test: optional handlers are invoked when provided for tasks/get and tasks/result
  • New test: TaskStore is consulted directly when handlers are omitted

Breaking Changes

Yes (experimental API):

  • TaskRequestHandler signature changed from (args, ctx) to (ctx) — tool arguments aren't available at tasks/get/tasks/result time
  • getTask and getTaskResult are now optional on ToolTaskHandler

See docs/migration.md.

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

Known limitation: the taskId → tool mapping is in-memory and doesn't survive restarts or span multiple server instances. In those scenarios, requests fall through to TaskStore. Documented on the ToolTaskHandler interface.

Also adds an isToolTaskHandler type guard replacing inline 'createTask' in handler checks.

Closes #1332

@changeset-bot
Copy link

changeset-bot bot commented Mar 25, 2026

🦋 Changeset detected

Latest commit: 607e0db

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@modelcontextprotocol/core Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/node Major
@modelcontextprotocol/express Major
@modelcontextprotocol/hono Major

Not sure what this means? Click here to learn what changesets are.

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

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 25, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1764

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1764

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1764

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1764

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1764

commit: 607e0db

…actually invoke them

These handlers were defined on the interface but never invoked — three
code paths bypassed them and called TaskStore directly:

- TaskManager.handleGetTask
- TaskManager.handleGetTaskPayload
- McpServer.handleAutomaticTaskPolling

The handlers exist to support proxying external job systems (AWS Step
Functions, CI/CD pipelines) where the external system is the source of
truth for task state. But every test/example implementation was pure
boilerplate delegation to the store.

This change makes the handlers optional: when omitted, TaskStore handles
requests (zero boilerplate, previous de-facto behavior). When provided,
they're invoked for tasks/get, tasks/result, and automatic polling.

Dispatch logic is isolated in ExperimentalMcpServerTasks. Core gains a
single setTaskOverrides() method on TaskManager — no changes to public
TaskManagerOptions type. McpServer itself gains ~5 lines.

Also drops the Args parameter from TaskRequestHandler since tool input
arguments aren't available at tasks/get/tasks/result time.

BREAKING CHANGE: TaskRequestHandler signature changed from (args, ctx)
to (ctx). ToolTaskHandler.getTask and getTaskResult are now optional.

Closes #1332

Co-authored-by: Luca Chang <lucalc@amazon.com>
@felixweinberger felixweinberger force-pushed the fweinberger/tooltask-handlers branch from b106062 to 0f7ba7c Compare March 25, 2026 22:14
@felixweinberger felixweinberger changed the title [v2] Remove getTask/getTaskResult from ToolTaskHandler [v2] Make ToolTaskHandler.getTask/getTaskResult optional and actually invoke them Mar 25, 2026
@felixweinberger felixweinberger marked this pull request as ready for review March 25, 2026 22:16
@felixweinberger felixweinberger requested a review from a team as a code owner March 25, 2026 22:16
TypeDoc can't resolve {@linkcode TaskStore} from the server package
since core isn't in the processed package set.
@felixweinberger
Copy link
Contributor Author

@claude review

Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

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

This PR introduces an override mechanism into core TaskManager, has breaking changes to the experimental task API, and touches 13 files across core/server/tests — worth a human review for the design decisions involved.

Extended reasoning...

Overview

This PR makes getTask and getTaskResult optional on ToolTaskHandler and actually wires them into the tasks/get and tasks/result request paths, fixing a bug where these handlers were defined but never invoked. The changes span 13 files: core TaskManager gains a new TaskLookupOverrides type and setTaskOverrides() method; ExperimentalMcpServerTasks gains ~50 lines of dispatch logic (_taskToTool map, _recordTask, _installOverrides, _dispatch); McpServer is modified to eagerly construct the experimental module and install overrides at construction time; tests remove ~180 lines of boilerplate handlers; and migration docs are updated.

Security risks

No direct security risks. The override mechanism is internal (@internal annotation) and only invoked by McpServer. The _dispatch method validates that the tool exists and has the expected handler shape before calling it. No auth, crypto, or permissions code is affected.

Level of scrutiny

This warrants careful human review for several reasons:

  1. Core infrastructure change: TaskManager in @modelcontextprotocol/core gains a new internal method that alters the lookup flow for tasks/get and tasks/result — this affects all servers using tasks.
  2. Breaking API changes: TaskRequestHandler signature changes from (args, ctx) to (ctx), and getTask/getTaskResult become optional. Even though the API is experimental, downstream users may be affected.
  3. Design decisions: The override pattern (McpServer installing overrides into core TaskManager at construction time) is an architectural choice that should be validated by a maintainer.
  4. Major version bumps: The changeset triggers Major bumps for node, express, and hono packages.

Other factors

The PR has good test coverage with two new tests (handlers invoked when provided, TaskStore consulted when omitted) and all existing tests updated. The in-memory _taskToTool map limitation is documented. The code is well-structured with clear separation of concerns. The net LOC change is negative (removing boilerplate), which is positive. However, the scope and the core changes justify human sign-off.

Comment on lines +60 to +77
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);
}
Copy link

Choose a reason for hiding this comment

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

🟡 _taskToTool Map grows without bound: entries are added via _recordTask() but never deleted when tasks reach terminal status or when the server closes. For long-running servers using custom getTask/getTaskResult handlers, this is an unbounded memory leak. Consider deleting entries after returning a terminal result in _dispatch, and clearing the map on close (similar to how TaskManager._taskProgressTokens has _cleanupTaskProgressHandler() and onClose() cleanup).

Extended reasoning...

What the bug is

The _taskToTool Map (line 60) in ExperimentalMcpServerTasks records taskId -> toolName mappings whenever a task is created for a tool that has custom getTask or getTaskResult handlers. Entries are added in _recordTask() but there are zero calls to _taskToTool.delete() or _taskToTool.clear() anywhere in the class or codebase.

Code path

The flow is:

  1. A client calls tools/call with task augmentation. McpServer calls this._experimental.tasks._recordTask(taskId, toolName) (mcp.ts lines 200 and 322).
  2. _recordTask() checks if the tool has custom getTask/getTaskResult handlers and, if so, stores the mapping: this._taskToTool.set(taskId, toolName) (mcpServer.ts line 77).
  3. When tasks/get or tasks/result arrives, _dispatch() reads from the map to route to the correct handler.
  4. No code ever removes the entry -- not after a terminal result is returned, not when the task is cancelled, and not when the server shuts down.

Why existing code does not prevent it

The TaskManager class in core has cleanup patterns for its own maps: _cleanupTaskProgressHandler() deletes entries from _taskProgressTokens when a task reaches terminal status, and onClose() clears the entire map. The ExperimentalMcpServerTasks class follows none of these patterns -- it has no onClose() hook and no per-task cleanup.

Step-by-step proof

  1. Server starts, _taskToTool is empty (size 0).
  2. Client creates task T1 for tool proxy-task (which has custom getTask/getTaskResult). _recordTask("T1", "proxy-task") runs. Map size = 1.
  3. T1 completes. _dispatch returns the result. Map still has entry "T1" -> "proxy-task" -- size = 1.
  4. Client creates task T2 for the same tool. Map size = 2.
  5. T2 completes. Map size = 2.
  6. After N tasks, map size = N. The entries for T1, T2, etc. are never removed despite those tasks being long finished.

Impact

Each entry is just two short strings (~100 bytes), so the leak is slow. For the common case (no custom handlers), _recordTask skips the .set() call entirely, so there is no leak. The leak only manifests for the uncommon proxy use case (custom getTask/getTaskResult handlers) on a long-running server that processes many tasks. The PR description already acknowledges the in-memory-only nature of this map, but the concern there was about cross-instance behavior -- within a single process the map grows monotonically.

Suggested fix

Delete the entry in _dispatch after a terminal result is returned (when the method is getTaskResult, since that is the final lookup). Also add an onClose() method that calls this._taskToTool.clear() and wire it into the server shutdown path. This mirrors the _taskProgressTokens cleanup pattern in TaskManager.

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.

1 participant