Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/remove-tooltask-get-handlers.md
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.
31 changes: 31 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,8 +500,39 @@

```typescript
import { JSONRPCErrorResponse, ResourceTemplateReference, isJSONRPCErrorResponse } from '@modelcontextprotocol/server';
```

### `ToolTaskHandler.getTask` and `getTaskResult` are now optional (experimental)

`getTask` and `getTaskResult` are now optional on `ToolTaskHandler`. When omitted, `tasks/get` and `tasks/result` are served directly from the configured `TaskStore`. Their signature has also changed — they no longer receive the tool's input arguments (which aren't available at `tasks/get`/`tasks/result` time).

If your handlers just delegated to the store, delete them:

**Before:**

```typescript
server.experimental.tasks.registerToolTask('long-task', config, {
createTask: async (args, ctx) => { /* ... */ },
getTask: async (args, ctx) => ctx.task.store.getTask(ctx.task.id),
getTaskResult: async (args, ctx) => ctx.task.store.getTaskResult(ctx.task.id)
});
```

**After:**

```typescript
server.experimental.tasks.registerToolTask('long-task', config, {
createTask: async (args, ctx) => { /* ... */ }
});
```

Keep them if you're proxying an external job system (AWS Step Functions, CI/CD pipelines, etc.) — the new signature takes only `ctx`:

Check warning on line 530 in docs/migration.md

View check run for this annotation

Claude / Claude Code Review

migration-SKILL.md missing ToolTaskHandler breaking change entry

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.
Comment on lines 503 to +530
Copy link
Copy Markdown

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:

  1. Developer has an existing implementation with getTask: async (args, ctx) => ctx.task.store.getTask(ctx.task.id) and getTaskResult: async (args, ctx) => ctx.task.store.getTaskResult(ctx.task.id).
  2. Developer runs an LLM-assisted migration using migration-SKILL.md as context.
  3. The LLM finds no entry for getTask, getTaskResult, ToolTaskHandler, or TaskRequestHandler in migration-SKILL.md.
  4. The LLM does not remove the boilerplate handlers or update the signature.
  5. TypeScript compiler rejects the old (args, ctx) signature — compile error.

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.

```typescript
getTask: async (ctx) => describeStepFunctionExecution(ctx.task.id),
getTaskResult: async (ctx) => getStepFunctionOutput(ctx.task.id)
```

### Request handler context types

The `RequestHandlerExtra` type has been replaced with a structured context type hierarchy using nested groups:
Expand Down
14 changes: 0 additions & 14 deletions examples/server/src/simpleStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,13 +480,6 @@ const getServer = () => {
return {
task
};
},
async getTask(_args, ctx) {
return await ctx.task.store.getTask(ctx.task.id);
},
async getTaskResult(_args, ctx) {
const result = await ctx.task.store.getTaskResult(ctx.task.id);
return result as CallToolResult;
}
}
);
Expand Down Expand Up @@ -588,13 +581,6 @@ const getServer = () => {
})();

return { task };
},
async getTask(_args, ctx) {
return await ctx.task.store.getTask(ctx.task.id);
},
async getTaskResult(_args, ctx) {
const result = await ctx.task.store.getTaskResult(ctx.task.id);
return result as CallToolResult;
}
}
);
Expand Down
45 changes: 34 additions & 11 deletions packages/core/src/shared/taskManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ export type TaskContext = {
requestedTtl?: number;
};

/**
* Overrides for `tasks/get` and `tasks/result` lookups. Consulted before
* the configured {@linkcode TaskStore}; return `undefined` to fall through.
* @internal
*/
export type TaskLookupOverrides = {
getTask?: (taskId: string, ctx: BaseContext) => Promise<Task | undefined>;
getTaskResult?: (taskId: string, ctx: BaseContext) => Promise<Result | undefined>;
};

export type TaskManagerOptions = {
/**
* Task storage implementation. Required for handling incoming task requests (server-side).
Expand Down Expand Up @@ -199,20 +209,30 @@ export class TaskManager {
private _requestResolvers: Map<RequestId, (response: JSONRPCResultResponse | Error) => void> = new Map();
private _options: TaskManagerOptions;
private _host?: TaskManagerHost;
private _overrides?: TaskLookupOverrides;

constructor(options: TaskManagerOptions) {
this._options = options;
this._taskStore = options.taskStore;
this._taskMessageQueue = options.taskMessageQueue;
}

/**
* Installs per-task lookup overrides consulted before the {@linkcode TaskStore}.
* Used by McpServer to dispatch to per-tool `getTask`/`getTaskResult` handlers.
* @internal
*/
setTaskOverrides(overrides: TaskLookupOverrides): void {
this._overrides = overrides;
}

bind(host: TaskManagerHost): void {
this._host = host;

if (this._taskStore) {
host.registerHandler('tasks/get', async (request, ctx) => {
const params = request.params as { taskId: string };
const task = await this.handleGetTask(params.taskId, ctx.sessionId);
const task = await this.handleGetTask(params.taskId, ctx);
// Per spec: tasks/get responses SHALL NOT include related-task metadata
// as the taskId parameter is the source of truth
return {
Expand All @@ -222,7 +242,7 @@ export class TaskManager {

host.registerHandler('tasks/result', async (request, ctx) => {
const params = request.params as { taskId: string };
return await this.handleGetTaskPayload(params.taskId, ctx.sessionId, ctx.mcpReq.signal, async message => {
return await this.handleGetTaskPayload(params.taskId, ctx, async message => {
// Send the message on the response stream by passing the relatedRequestId
// This tells the transport to write the message to the tasks/result response stream
await host.sendOnResponseStream(message, ctx.mcpReq.id);
Expand Down Expand Up @@ -362,8 +382,11 @@ export class TaskManager {

// -- Handler bodies (delegated from Protocol's registered handlers) --

private async handleGetTask(taskId: string, sessionId?: string): Promise<Task> {
const task = await this._requireTaskStore.getTask(taskId, sessionId);
private async handleGetTask(taskId: string, ctx: BaseContext): Promise<Task> {
const override = await this._overrides?.getTask?.(taskId, ctx);
if (override !== undefined) return override;

const task = await this._requireTaskStore.getTask(taskId, ctx.sessionId);
if (!task) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Failed to retrieve task: Task not found');
}
Expand All @@ -372,10 +395,12 @@ export class TaskManager {

private async handleGetTaskPayload(
taskId: string,
sessionId: string | undefined,
signal: AbortSignal,
ctx: BaseContext,
sendOnResponseStream: (message: JSONRPCNotification | JSONRPCRequest) => Promise<void>
): Promise<Result> {
const sessionId = ctx.sessionId;
const signal = ctx.mcpReq.signal;

const handleTaskResult = async (): Promise<Result> => {
if (this._taskMessageQueue) {
let queuedMessage: QueuedMessage | undefined;
Expand Down Expand Up @@ -404,17 +429,15 @@ export class TaskManager {
}
}

const task = await this._requireTaskStore.getTask(taskId, sessionId);
if (!task) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found: ${taskId}`);
}
const task = await this.handleGetTask(taskId, ctx);

if (!isTerminal(task.status)) {
await this._waitForTaskUpdate(task.pollInterval, signal);
return await handleTaskResult();
}

const result = await this._requireTaskStore.getTaskResult(taskId, sessionId);
const override = await this._overrides?.getTaskResult?.(taskId, ctx);
const result = override ?? (await this._requireTaskStore.getTaskResult(taskId, sessionId));
await this._clearTaskQueue(taskId);

return {
Expand Down
44 changes: 32 additions & 12 deletions packages/server/src/experimental/tasks/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {
TaskServerContext
} from '@modelcontextprotocol/core';

import type { BaseToolCallback } from '../../server/mcp.js';
import type { AnyToolHandler, BaseToolCallback } from '../../server/mcp.js';

// ============================================================================
// Task Handler Types (for registerToolTask)
Expand All @@ -30,19 +30,27 @@ export type CreateTaskRequestHandler<

/**
* Handler for task operations (`get`, `getResult`).
*
* Receives only the context (no tool arguments — they are not available at
* `tasks/get` or `tasks/result` time). Access the task ID via `ctx.task.id`.
*
* @experimental
*/
export type TaskRequestHandler<SendResultT extends Result, Args extends StandardSchemaWithJSON | undefined = undefined> = BaseToolCallback<
SendResultT,
TaskServerContext,
Args
>;
export type TaskRequestHandler<SendResultT extends Result> = (ctx: TaskServerContext) => SendResultT | Promise<SendResultT>;

/**
* Interface for task-based tool handlers.
*
* Task-based tools split a long-running operation into three phases:
* `createTask`, `getTask`, and `getTaskResult`.
* Task-based tools create a task on `tools/call` and by default let the SDK's
* `TaskStore` handle subsequent `tasks/get` and `tasks/result` requests.
*
* Provide `getTask` and `getTaskResult` to override the default lookups — useful
* when proxying an external job system (e.g., AWS Step Functions, CI/CD pipelines)
* where the external system is the source of truth for task state.
*
* **Note:** the taskId → tool mapping used to dispatch `getTask`/`getTaskResult`
* is held in-memory and does not survive server restarts or span multiple
* instances. In those scenarios, requests fall through to the `TaskStore`.
*
* @see {@linkcode @modelcontextprotocol/server!experimental/tasks/mcpServer.ExperimentalMcpServerTasks#registerToolTask | registerToolTask} for registration.
* @experimental
Expand All @@ -56,11 +64,23 @@ export interface ToolTaskHandler<Args extends StandardSchemaWithJSON | undefined
*/
createTask: CreateTaskRequestHandler<CreateTaskResult, Args>;
/**
* Handler for `tasks/get` requests.
* Optional handler for `tasks/get` requests. When omitted, the configured
* `TaskStore` is consulted directly.
*/
getTask: TaskRequestHandler<GetTaskResult, Args>;
getTask?: TaskRequestHandler<GetTaskResult>;
/**
* Handler for `tasks/result` requests.
* Optional handler for `tasks/result` requests. When omitted, the configured
* `TaskStore` is consulted directly.
*/
getTaskResult: TaskRequestHandler<CallToolResult, Args>;
getTaskResult?: TaskRequestHandler<CallToolResult>;
}

/**
* Type guard for {@linkcode ToolTaskHandler}.
* @experimental
*/
export function isToolTaskHandler(
handler: AnyToolHandler<StandardSchemaWithJSON | undefined>
): handler is ToolTaskHandler<StandardSchemaWithJSON | undefined> {
return 'createTask' in handler;
}
89 changes: 78 additions & 11 deletions packages/server/src/experimental/tasks/mcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -26,6 +38,7 @@
_meta: Record<string, unknown> | undefined,
handler: AnyToolHandler<StandardSchemaWithJSON | undefined>
): RegisteredTool;
_registeredTools: { [name: string]: RegisteredTool };
}

/**
Expand All @@ -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);
}
}

/** @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

View check run for this annotation

Claude / Claude Code Review

Successful getTaskResult + network-lost-response causes infinite retry loop for external-proxy tools

After a successful `getTaskResult` call for an external-proxy tool, the `_taskToTool` entry is deleted at mcpServer.ts:111; if the HTTP response is lost before the client receives it, any retry of `tasks/result` finds no entry, falls through to the local `TaskStore` (which permanently shows `'working'` for external-proxy tools since `storeTaskResult` is never called locally), and enters an infinite polling loop via `_waitForTaskUpdate` until the HTTP connection times out. To fix, cache the resul
Comment on lines +108 to +113
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 After a successful getTaskResult call for an external-proxy tool, the _taskToTool entry is deleted at mcpServer.ts:111; if the HTTP response is lost before the client receives it, any retry of tasks/result finds no entry, falls through to the local TaskStore (which permanently shows 'working' for external-proxy tools since storeTaskResult is never called locally), and enters an infinite polling loop via _waitForTaskUpdate until the HTTP connection times out. To fix, cache the result in a short-lived map (keyed by taskId) so retries within a grace period can return the same result without re-invoking the handler, or alternatively re-invoke the handler idempotently on retry.

Extended reasoning...

What the bug is and how it manifests

This PR intentionally moves _taskToTool.delete(taskId) to after handler(taskCtx) resolves (mcpServer.ts line 111), correctly protecting against the transient-throw retry case. However, there is a second retry scenario that is not addressed: the handler succeeds, the entry is deleted, the result is assembled and returned to the HTTP layer — but the TCP connection drops (or a proxy times out) before the client receives the response. The client retries tasks/result. At that point _taskToTool has no entry for taskId, so _dispatch returns undefined for both getTask and getTaskResult. Both fall through to _requireTaskStore.

The specific code path that triggers it

  1. handleGetTaskPayload calls handleGetTask(taskId, ctx) (taskManager.ts:432).
  2. handleGetTask calls _overrides.getTask(taskId, ctx)_dispatch('getTask') → no _taskToTool entry → returns undefined → falls through to _requireTaskStore.getTask(taskId, sessionId).
  3. The local TaskStore returns a Task with status = 'working'. For external-proxy tools, storeTaskResult is never called locally — the external system (e.g. AWS Step Functions) is the authoritative source of truth. The SDK never updates the local store for these tasks.
  4. isTerminal('working') is false_waitForTaskUpdate(task.pollInterval, signal) is called.
  5. After waiting, handleTaskResult() is called recursively. Step 2-4 repeats. The local store still says 'working' — it will never say anything else.
  6. The loop continues at pollInterval-ms intervals until ctx.mcpReq.signal fires (HTTP connection closes or the client gives up).

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 TaskStore for external-proxy tools permanently holds the initial 'working' status because storeTaskResult is only called by the tool's createTask background work or via the store's update methods — neither of which an external-proxy tool ever calls.

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 tasks/result call causes the server to spin at pollInterval ms for the full HTTP timeout duration (potentially minutes). Each retry from the client creates a new polling goroutine. Under real network conditions (load balancers, proxies with short idle timeouts, mobile clients), this is not a theoretical edge case.

How to fix it

Option A (result cache with TTL): After _dispatch('getTaskResult') succeeds, store the result in a short-lived Map<taskId, {result, expiry}>. On retry, if _taskToTool has no entry but the result cache does, return the cached result immediately. Clear the cache entry after the TTL or after onClose().

Option B (idempotent re-invoke): Restore the _taskToTool entry on network errors. This is difficult because the entry deletion and the HTTP response write happen in different layers.

Option C (sentinel in TaskStore): After getTaskResult succeeds, write a sentinel 'completed' status to the local TaskStore before deleting the _taskToTool entry. Retries would then find the task terminal, fetch the cached local result, and return immediately. This requires external-proxy tools to also write a minimal result to the local store.

Step-by-step proof

  1. Tool proxy-sfn is registered with custom getTask + getTaskResult handlers (proxying AWS Step Functions). _taskToTool has entry 'T1' → 'proxy-sfn'. Local TaskStore has task T1 with status='working'.
  2. Client calls tasks/result for T1. handleGetTask_dispatch('getTask') → finds entry → calls SFN DescribeExecution → returns {status:'completed'}.
  3. isTerminal('completed') === truehandleGetTaskPayload calls _overrides.getTaskResult('T1', ctx).
  4. _dispatch('getTaskResult'): calls SFN GetExecutionHistory → returns result → _taskToTool.delete('T1') → returns result.
  5. Result is wrapped and returned to HTTP layer. TCP connection drops before client receives it.
  6. Client retries tasks/result for T1.
  7. handleGetTask_dispatch('getTask')no entry → falls through to _requireTaskStore.getTask('T1') → returns {status:'working'}.
  8. isTerminal('working') === false_waitForTaskUpdate(pollInterval, signal)handleTaskResult() recurse.
  9. Goto step 7. Loop runs at pollInterval ms until the HTTP connection times out.

}

/**
* 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
Expand All @@ -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
Expand Down
Loading
Loading