SDK v2 migration (behavior-neutral, versionNegotiation: 'legacy') (#1624)
Migrate all three clients + core + test-servers from
@modelcontextprotocol/sdk@1.29 to SDK v2 (2.0.0-beta.4), keeping behavior
identical against legacy (2025-11-25) servers. Foundation for the SDK V2 +
New Spec stack.
Packages
- Split @modelcontextprotocol/sdk -> @modelcontextprotocol/client (Client,
transports, OAuth, error taxonomy, protocol types) + core (Zod schemas);
stdio -> client/stdio, Ajv -> client/validators/ajv. Test-servers ->
@modelcontextprotocol/server (+ server-legacy/sse). Deterministic import
rewrite (schemas -> core, everything else -> client); the codemod misroutes
client code to /server so it was not used for path routing.
- new Client(..., { versionNegotiation: { mode: 'legacy' } }) — nothing
2026-era goes on the wire.
Runtime (core/mcp/inspectorClient.ts)
- Error taxonomy: McpError -> ProtocolError, ErrorCode -> ProtocolErrorCode;
RequestTimeout -> SdkError/SdkErrorCode.
- Handlers take method strings; task methods (tasks/*, notifications/tasks/*)
use the 3-arg custom form (excluded from v2's spec-method set).
- Legacy tasks kept working (per project requirement): requestor tasks re-
implemented over raw client.request + explicit deprecated schemas;
experimental.tasks.callToolStream replaced by a local pollTaskToolCall
async generator (with _progressHandlers revival so task-execution progress
still reaches the caller). Receiver (server-initiated) tasks: the v2 Client
validates a sampling/elicit handler result and rejects the legacy { task }
response with -32602 — installReceiverTaskResponseBypass swaps the wrapped
entry in the private _requestHandlers map for the task-augmented branch so
{ task } reaches the wire (fixes server-initiated sampling-via-task).
- Pagination: list verbs use raw client.request({method}, List*ResultSchema)
for single-page behavior (v2's client.listX() auto-aggregate all pages).
Web / auth / apps
- Rename local JsonSchemaType -> InspectorFormSchema + add toFormSchema()
structural narrowing at the SDK-schema -> form boundary.
- ext-apps still peers on SDK v1: one compat boundary with documented casts
(remove when ext-apps#702 lands a v2 peer).
- OAuth provider saveClientInformation reconciled with v2's
OAuthClientInformationContext; InvalidClientMetadataError re-homed as a
local class (server-legacy/auth is not browser-safe).
- vitest.shared.mts dedupes @modelcontextprotocol/client+core to one copy so
vi.mock intercepts the SUT's import.
Accepted v2 behavior changes (per #1624 / spec §9.2): unknown-tool call
rejects with -32602; cancellation is stream-abort (no guaranteed
notifications/cancelled frame); output-schema strictness restored via the
Inspector's own Ajv check on the default path.
npm run ci green: validate (web 3087 / cli 212 / tui 274 / launcher 5),
coverage (>=90 per-file, all clients), smoke (launcher/cli/web), storybook
(410).
Closes #1624
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8d33e76f97
commit
19a6df7ad3
1 changed files with 2545 additions and 2123 deletions
|
|
@ -5,65 +5,42 @@
|
|||
* This allows composing MCP test servers with different capabilities, tools, resources, and prompts.
|
||||
*/
|
||||
|
||||
import * as z from "zod/v4";
|
||||
import {
|
||||
McpServer,
|
||||
ResourceTemplate as SdkResourceTemplate,
|
||||
} from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
completable,
|
||||
isCompletable,
|
||||
} from "@modelcontextprotocol/server";
|
||||
import type {
|
||||
Implementation,
|
||||
Tool,
|
||||
Resource,
|
||||
ResourceTemplate,
|
||||
ResourceTemplateType as ResourceTemplate,
|
||||
Prompt,
|
||||
CallToolResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
InMemoryTaskStore,
|
||||
InMemoryTaskMessageQueue,
|
||||
} from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js";
|
||||
import type {
|
||||
TaskStore,
|
||||
TaskMessageQueue,
|
||||
ToolTaskHandler,
|
||||
} from "@modelcontextprotocol/sdk/experimental/tasks/interfaces.js";
|
||||
import type {
|
||||
PromptArgument,
|
||||
RegisteredTool,
|
||||
RegisteredResource,
|
||||
RegisteredPrompt,
|
||||
RegisteredResourceTemplate,
|
||||
} from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||
import type {
|
||||
ServerRequest,
|
||||
ServerNotification,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
ServerContext,
|
||||
ServerCapabilities,
|
||||
Task,
|
||||
GetTaskResult,
|
||||
ListTasksResult,
|
||||
ListToolsResult,
|
||||
ListResourcesResult,
|
||||
ListResourceTemplatesResult,
|
||||
ListPromptsResult,
|
||||
} from "@modelcontextprotocol/server";
|
||||
import {
|
||||
SetLevelRequestSchema,
|
||||
SubscribeRequestSchema,
|
||||
UnsubscribeRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
type ListToolsResult,
|
||||
type ListResourcesResult,
|
||||
type ListResourceTemplatesResult,
|
||||
type ListPromptsResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { AnySchema } from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
||||
import {
|
||||
type ZodRawShapeCompat,
|
||||
getObjectShape,
|
||||
getSchemaDescription,
|
||||
isSchemaOptional,
|
||||
normalizeObjectSchema,
|
||||
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
||||
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
|
||||
import {
|
||||
completable,
|
||||
isCompletable,
|
||||
} from "@modelcontextprotocol/sdk/server/completable.js";
|
||||
import type { PromptArgument } from "@modelcontextprotocol/sdk/types.js";
|
||||
GetTaskRequestSchema,
|
||||
GetTaskPayloadRequestSchema,
|
||||
CancelTaskRequestSchema,
|
||||
ListTasksRequestSchema,
|
||||
TaskStatusNotificationSchema,
|
||||
} from "@modelcontextprotocol/core";
|
||||
|
||||
// Empty object JSON schema constant (from SDK's mcp.js)
|
||||
const EMPTY_OBJECT_JSON_SCHEMA = {
|
||||
|
|
@ -71,8 +48,83 @@ const EMPTY_OBJECT_JSON_SCHEMA = {
|
|||
properties: {},
|
||||
} as const;
|
||||
|
||||
type ToolInputSchema = ZodRawShapeCompat;
|
||||
type PromptArgsSchema = ZodRawShapeCompat;
|
||||
// A raw arg shape: field name → zod schema. Using the full `z.ZodType` (not
|
||||
// `z.ZodRawShape`, which is a readonly `$ZodType` map) keeps it assignable to the
|
||||
// SDK's `registerTool`/`registerPrompt` raw-shape overloads and mutable for the
|
||||
// completable() wrapping below.
|
||||
type ToolInputSchema = Record<string, z.ZodType>;
|
||||
type PromptArgsSchema = Record<string, z.ZodType>;
|
||||
|
||||
/**
|
||||
* Back-compat handler `extra` shape.
|
||||
*
|
||||
* SDK v2 removed `RequestHandlerExtra`; low-level handlers now receive a
|
||||
* {@link ServerContext} whose per-request data lives under `ctx.mcpReq`. This
|
||||
* type is the flattened view the composable test-server tool/resource/task
|
||||
* handlers consume, rebuilt from `ctx.mcpReq` by {@link toHandlerExtra}.
|
||||
*/
|
||||
export interface HandlerExtra {
|
||||
/** `_meta` sent with the request params (e.g. `_meta.progressToken`). */
|
||||
_meta?: Record<string, unknown>;
|
||||
/** The inbound JSON-RPC request id (for `relatedRequestId` on notifications). */
|
||||
requestId?: string | number;
|
||||
/** Abort signal for the in-flight request. */
|
||||
signal?: AbortSignal;
|
||||
/** Send a server→client request (`sampling/createMessage`, `elicitation/create`, `tasks/*`, …). */
|
||||
sendRequest?: <T extends z.core.$ZodType>(
|
||||
request: { method: string; params?: Record<string, unknown> },
|
||||
resultSchema: T,
|
||||
) => Promise<z.output<T>>;
|
||||
/** Send a server→client notification. */
|
||||
sendNotification?: (notification: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Minimal structural view of `ctx.mcpReq` for building {@link HandlerExtra}. */
|
||||
interface McpReqContext {
|
||||
mcpReq?: {
|
||||
id?: string | number;
|
||||
_meta?: Record<string, unknown>;
|
||||
signal?: AbortSignal;
|
||||
send?: HandlerExtra["sendRequest"];
|
||||
notify?: HandlerExtra["sendNotification"];
|
||||
};
|
||||
}
|
||||
|
||||
/** Rebuild the legacy flattened `extra` object from a v2 {@link ServerContext}. */
|
||||
function toHandlerExtra(ctx: ServerContext | undefined): HandlerExtra {
|
||||
const mcpReq = (ctx as McpReqContext | undefined)?.mcpReq;
|
||||
return {
|
||||
_meta: mcpReq?._meta,
|
||||
requestId: mcpReq?.id,
|
||||
signal: mcpReq?.signal,
|
||||
sendRequest: mcpReq?.send,
|
||||
sendNotification: mcpReq?.notify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive `prompts/list` argument descriptors from a raw zod arg shape.
|
||||
* Replaces the SDK's removed `getObjectShape` / `getSchemaDescription` /
|
||||
* `isSchemaOptional` compat helpers. A field is `required` unless it accepts
|
||||
* `undefined` (i.e. it is `.optional()` / `.default()` / nullish).
|
||||
*/
|
||||
function promptArgumentsFromRawShape(
|
||||
shape: Record<string, z.ZodType> | undefined,
|
||||
): PromptArgument[] | undefined {
|
||||
if (!shape) return undefined;
|
||||
return Object.entries(shape).map(([name, field]) => {
|
||||
const schema = field as z.ZodType;
|
||||
const acceptsUndefined = schema.safeParse(undefined).success;
|
||||
return {
|
||||
name,
|
||||
description: schema.description,
|
||||
required: !acceptsUndefined,
|
||||
} as PromptArgument;
|
||||
});
|
||||
}
|
||||
|
||||
interface ServerState {
|
||||
registeredTools: Map<string, RegisteredTool>; // Keyed by name
|
||||
|
|
@ -96,6 +148,140 @@ export interface TestServerContext {
|
|||
serverControl?: { isClosing(): boolean };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy (2025-11-25) tasks — re-implemented by hand.
|
||||
//
|
||||
// SDK v2 deleted the experimental tasks runtime (`InMemoryTaskStore`,
|
||||
// `registerToolTask`, `client/server.experimental.tasks.*`). The Inspector still
|
||||
// exercises the 2025-11-25 task wire methods end to end, so the composable
|
||||
// server keeps answering them with a small in-memory store plus low-level
|
||||
// custom-method handlers wired up in `createMcpServer`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Task lifecycle status (2025-11-25 vocabulary). */
|
||||
export type TaskStatus = Task["status"];
|
||||
|
||||
/** Loosely-typed argument bag handed to a task tool's `getTask`/`getTaskResult`. */
|
||||
export type ShapeOutput<_Shape> = Record<string, unknown>;
|
||||
|
||||
/** Payload a task stores as its result (for `tasks/result`). */
|
||||
export type StoredTaskResult = CallToolResult & Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* In-memory task store. Replaces the deleted SDK `InMemoryTaskStore`; keeps the
|
||||
* subset of methods the composable fixtures use plus list/cancel for the
|
||||
* `tasks/list` and `tasks/cancel` wire handlers.
|
||||
*/
|
||||
export class InMemoryTaskStore {
|
||||
private tasks = new Map<string, Task>();
|
||||
private results = new Map<string, StoredTaskResult>();
|
||||
/** Optional hook fired on every status change (wired to `notifications/tasks/status`). */
|
||||
onTaskStatusChanged?: (task: Task) => void;
|
||||
|
||||
private touch(task: Task): Task {
|
||||
task.lastUpdatedAt = new Date().toISOString();
|
||||
return task;
|
||||
}
|
||||
|
||||
async createTask(options: { ttl?: number } = {}): Promise<Task> {
|
||||
const now = new Date().toISOString();
|
||||
const task: Task = {
|
||||
taskId:
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`task-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
||||
status: "working",
|
||||
ttl: options.ttl ?? null,
|
||||
createdAt: now,
|
||||
lastUpdatedAt: now,
|
||||
};
|
||||
this.tasks.set(task.taskId, task);
|
||||
this.onTaskStatusChanged?.(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async getTask(taskId: string): Promise<Task | undefined> {
|
||||
return this.tasks.get(taskId);
|
||||
}
|
||||
|
||||
async updateTaskStatus(
|
||||
taskId: string,
|
||||
status: TaskStatus,
|
||||
statusMessage?: string,
|
||||
): Promise<Task | undefined> {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task) return undefined;
|
||||
task.status = status;
|
||||
if (statusMessage !== undefined) {
|
||||
task.statusMessage = statusMessage;
|
||||
}
|
||||
this.touch(task);
|
||||
this.onTaskStatusChanged?.(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async storeTaskResult(
|
||||
taskId: string,
|
||||
status: TaskStatus,
|
||||
result: StoredTaskResult,
|
||||
): Promise<void> {
|
||||
this.results.set(taskId, result);
|
||||
await this.updateTaskStatus(taskId, status);
|
||||
}
|
||||
|
||||
async getTaskResult(taskId: string): Promise<StoredTaskResult> {
|
||||
const result = this.results.get(taskId);
|
||||
if (!result) {
|
||||
throw new Error(`No result stored for task ${taskId}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async listTasks(): Promise<Task[]> {
|
||||
return [...this.tasks.values()];
|
||||
}
|
||||
}
|
||||
|
||||
/** `extra` handed to a task tool's `createTask` (server→client requests + the store). */
|
||||
export interface CreateTaskRequestHandlerExtra {
|
||||
taskStore: InMemoryTaskStore;
|
||||
/** `_meta` from the augmenting `tools/call` (e.g. `_meta.progressToken`). */
|
||||
_meta?: Record<string, unknown>;
|
||||
/** The inbound request id. */
|
||||
requestId?: string | number;
|
||||
/** Send a server→client request (`elicitation/create`, `sampling/createMessage`, `tasks/*`). */
|
||||
sendRequest: <T extends z.core.$ZodType>(
|
||||
request: { method: string; params?: Record<string, unknown> },
|
||||
resultSchema: T,
|
||||
) => Promise<z.output<T>>;
|
||||
/** Send a server→client notification (`notifications/progress`, …). */
|
||||
sendNotification: (notification: {
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
/** `extra` handed to a task tool's `getTask`/`getTaskResult`. */
|
||||
export interface TaskRequestHandlerExtra {
|
||||
taskId: string;
|
||||
taskStore: InMemoryTaskStore;
|
||||
}
|
||||
|
||||
/** Handler triplet for a task-augmented tool (replaces the SDK's `ToolTaskHandler`). */
|
||||
export interface ToolTaskHandler {
|
||||
createTask: (
|
||||
args: Record<string, unknown>,
|
||||
extra: CreateTaskRequestHandlerExtra,
|
||||
) => Promise<{ task: Task }>;
|
||||
getTask: (
|
||||
args: Record<string, unknown>,
|
||||
extra: TaskRequestHandlerExtra,
|
||||
) => Promise<GetTaskResult>;
|
||||
getTaskResult: (
|
||||
args: Record<string, unknown>,
|
||||
extra: TaskRequestHandlerExtra,
|
||||
) => Promise<CallToolResult>;
|
||||
}
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
|
|
@ -109,7 +295,7 @@ export interface ToolDefinition {
|
|||
handler: (
|
||||
params: Record<string, unknown>,
|
||||
context?: TestServerContext,
|
||||
extra?: RequestHandlerExtra<ServerRequest, ServerNotification>,
|
||||
extra?: HandlerExtra,
|
||||
) => Promise<CallToolResult>;
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +308,7 @@ export interface TaskToolDefinition {
|
|||
execution?: { taskSupport: "required" | "optional" };
|
||||
/** Passed through to the SDK so clients can read tool-level `_meta` (e.g. `_meta.ui.resourceUri` for an App-flavored task tool). Mirrors {@link ToolDefinition._meta}. */
|
||||
_meta?: Record<string, unknown>;
|
||||
handler: ToolTaskHandler<ToolInputSchema | undefined>;
|
||||
handler: ToolTaskHandler;
|
||||
}
|
||||
|
||||
export interface ResourceDefinition {
|
||||
|
|
@ -164,12 +350,12 @@ export interface ResourceTemplateDefinition {
|
|||
description?: string;
|
||||
/** OAuth scopes required to read resources from this template (enforced at HTTP layer). */
|
||||
requiredScopes?: string[];
|
||||
inputSchema?: ZodRawShapeCompat; // Schema for template variables
|
||||
inputSchema?: Record<string, z.ZodType>; // Schema for template variables
|
||||
handler: (
|
||||
uri: URL,
|
||||
params: Record<string, unknown>,
|
||||
context?: TestServerContext,
|
||||
extra?: RequestHandlerExtra<ServerRequest, ServerNotification>,
|
||||
extra?: HandlerExtra,
|
||||
) => Promise<{
|
||||
contents: Array<{ uri: string; mimeType?: string; text: string }>;
|
||||
}>;
|
||||
|
|
@ -248,15 +434,10 @@ export interface ServerConfig {
|
|||
cancel?: boolean; // default: true
|
||||
};
|
||||
/**
|
||||
* Task store implementation (optional, defaults to InMemoryTaskStore)
|
||||
* Only used if tasks capability is enabled
|
||||
* Task store implementation (optional, defaults to a fresh {@link InMemoryTaskStore}).
|
||||
* Only used if tasks capability is enabled.
|
||||
*/
|
||||
taskStore?: TaskStore;
|
||||
/**
|
||||
* Task message queue implementation (optional, defaults to InMemoryTaskMessageQueue)
|
||||
* Only used if tasks capability is enabled
|
||||
*/
|
||||
taskMessageQueue?: TaskMessageQueue;
|
||||
taskStore?: InMemoryTaskStore;
|
||||
/**
|
||||
* OAuth 2.1 configuration for test server.
|
||||
* - **combined** (default): this server is both MCP resource and authorization server.
|
||||
|
|
@ -400,23 +581,31 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
}
|
||||
}
|
||||
|
||||
// Create task store and message queue if tasks are enabled
|
||||
// Create the in-memory task store if tasks are enabled. SDK v2 has no built-in
|
||||
// task runtime, so the store is owned here and its methods answer the wire.
|
||||
const taskStore =
|
||||
config.tasks !== undefined
|
||||
? config.taskStore || new InMemoryTaskStore()
|
||||
: undefined;
|
||||
const taskMessageQueue =
|
||||
config.tasks !== undefined
|
||||
? config.taskMessageQueue || new InMemoryTaskMessageQueue()
|
||||
: undefined;
|
||||
|
||||
// Create the server with capabilities and task stores
|
||||
// Create the server with capabilities
|
||||
const mcpServer = new McpServer(config.serverInfo, {
|
||||
capabilities,
|
||||
taskStore,
|
||||
taskMessageQueue,
|
||||
capabilities: capabilities as ServerCapabilities,
|
||||
});
|
||||
|
||||
// Emit `notifications/tasks/status` whenever a task changes status, mirroring
|
||||
// the deleted SDK task runtime. Best-effort: notifications need a live stream,
|
||||
// so a send failure (e.g. no active SSE stream) must not break task execution.
|
||||
if (taskStore) {
|
||||
taskStore.onTaskStatusChanged = (task: Task) => {
|
||||
const notification = TaskStatusNotificationSchema.parse({
|
||||
method: "notifications/tasks/status",
|
||||
params: { ...task },
|
||||
});
|
||||
void mcpServer.server.notification(notification).catch(() => {});
|
||||
};
|
||||
}
|
||||
|
||||
// Create state (this is really session state, which is what we'll call it if we implement sessions at some point)
|
||||
const state: ServerState = {
|
||||
registeredTools: new Map(), // Keyed by name
|
||||
|
|
@ -434,25 +623,23 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
...(config.serverControl && { serverControl: config.serverControl }),
|
||||
};
|
||||
|
||||
// Set up logging handler if logging is enabled
|
||||
// Set up logging handler if logging is enabled. `logging/setLevel` is a spec
|
||||
// method, so v2's `setRequestHandler` takes the method string (2-arg form).
|
||||
if (config.logging === true) {
|
||||
mcpServer.server.setRequestHandler(
|
||||
SetLevelRequestSchema,
|
||||
async (request) => {
|
||||
// Call optional callback if provided (for testing)
|
||||
if (config.onLogLevelSet) {
|
||||
config.onLogLevelSet(request.params.level);
|
||||
}
|
||||
// Return empty result as per MCP spec
|
||||
return {};
|
||||
},
|
||||
);
|
||||
mcpServer.server.setRequestHandler("logging/setLevel", async (request) => {
|
||||
// Call optional callback if provided (for testing)
|
||||
if (config.onLogLevelSet) {
|
||||
config.onLogLevelSet(request.params.level);
|
||||
}
|
||||
// Return empty result as per MCP spec
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
// Set up resource subscription handlers if subscriptions are enabled
|
||||
if (config.subscriptions === true) {
|
||||
mcpServer.server.setRequestHandler(
|
||||
SubscribeRequestSchema,
|
||||
"resources/subscribe",
|
||||
async (request) => {
|
||||
// Track subscription in state (accessible via closure)
|
||||
const uri = request.params.uri;
|
||||
|
|
@ -462,7 +649,7 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
);
|
||||
|
||||
mcpServer.server.setRequestHandler(
|
||||
UnsubscribeRequestSchema,
|
||||
"resources/unsubscribe",
|
||||
async (request) => {
|
||||
// Remove subscription from state (accessible via closure)
|
||||
const uri = request.params.uri;
|
||||
|
|
@ -484,50 +671,58 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
);
|
||||
}
|
||||
|
||||
// Task tools registered on this server, keyed by name. The `tools/call`
|
||||
// override below routes calls to these through their `createTask` handler
|
||||
// (returning a task handle), reproducing the deleted SDK task runtime.
|
||||
const taskTools = new Map<string, TaskToolDefinition>();
|
||||
|
||||
// Set up tools
|
||||
if (config.tools && config.tools.length > 0) {
|
||||
for (const tool of config.tools) {
|
||||
if (isTaskTool(tool)) {
|
||||
// Register task-based tool
|
||||
// registerToolTask has two overloads: one with inputSchema (required) and one without
|
||||
const registered = tool.inputSchema
|
||||
? mcpServer.experimental.tasks.registerToolTask(
|
||||
tool.name,
|
||||
// Register the task tool as an ordinary tool so it surfaces in
|
||||
// `tools/list` with its input schema, `_meta`, and `execution` support.
|
||||
// Its callback is a defensive placeholder: the `tools/call` override
|
||||
// intercepts task tools before the SDK handler ever runs it.
|
||||
const registered = mcpServer.registerTool(
|
||||
tool.name,
|
||||
{
|
||||
description: tool.description,
|
||||
...(tool.inputSchema && { inputSchema: tool.inputSchema }),
|
||||
...(tool._meta != null && { _meta: tool._meta }),
|
||||
},
|
||||
async () => ({
|
||||
content: [
|
||||
{
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
execution: tool.execution,
|
||||
...(tool._meta != null && { _meta: tool._meta }),
|
||||
type: "text" as const,
|
||||
text: `Task tool ${tool.name} must be invoked as a task`,
|
||||
},
|
||||
tool.handler,
|
||||
)
|
||||
: mcpServer.experimental.tasks.registerToolTask(
|
||||
tool.name,
|
||||
{
|
||||
description: tool.description,
|
||||
execution: tool.execution,
|
||||
...(tool._meta != null && { _meta: tool._meta }),
|
||||
},
|
||||
tool.handler,
|
||||
);
|
||||
],
|
||||
isError: true,
|
||||
}),
|
||||
);
|
||||
// `execution` is not a `registerTool` config field; set it directly so
|
||||
// `tools/list` advertises task support (matches the old registerToolTask).
|
||||
registered.execution = tool.execution ?? { taskSupport: "required" };
|
||||
state.registeredTools.set(tool.name, registered);
|
||||
taskTools.set(tool.name, tool);
|
||||
} else {
|
||||
// Register regular tool
|
||||
const registered = mcpServer.registerTool(
|
||||
tool.name,
|
||||
{
|
||||
inputSchema: tool.inputSchema ?? {},
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
...(tool.outputSchema != null && {
|
||||
outputSchema: tool.outputSchema as AnySchema,
|
||||
outputSchema: tool.outputSchema as z.ZodType,
|
||||
}),
|
||||
...(tool._meta != null && { _meta: tool._meta }),
|
||||
},
|
||||
async (args, extra) => {
|
||||
async (args, ctx) => {
|
||||
const result = await tool.handler(
|
||||
args as Record<string, unknown>,
|
||||
context,
|
||||
extra,
|
||||
toHandlerExtra(ctx),
|
||||
);
|
||||
const rawStructured =
|
||||
result &&
|
||||
|
|
@ -688,8 +883,13 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
{
|
||||
description: template.description,
|
||||
},
|
||||
async (uri: URL, variables: Record<string, unknown>, extra) => {
|
||||
const result = await template.handler(uri, variables, context, extra);
|
||||
async (uri: URL, variables: Record<string, unknown>, ctx) => {
|
||||
const result = await template.handler(
|
||||
uri,
|
||||
variables,
|
||||
context,
|
||||
toHandlerExtra(ctx),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
|
@ -705,7 +905,7 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
|
||||
// If completions callbacks are provided, wrap the corresponding schemas
|
||||
if (prompt.completions && argsSchema) {
|
||||
const enhancedSchema: ZodRawShapeCompat = { ...argsSchema };
|
||||
const enhancedSchema: Record<string, z.ZodType> = { ...argsSchema };
|
||||
for (const [argName, completeCallback] of Object.entries(
|
||||
prompt.completions,
|
||||
)) {
|
||||
|
|
@ -774,68 +974,53 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
|
||||
// Tools pagination
|
||||
if (capabilities.tools && maxPageSize.tools !== undefined) {
|
||||
mcpServer.server.setRequestHandler(
|
||||
ListToolsRequestSchema,
|
||||
async (request) => {
|
||||
const cursor = request.params?.cursor;
|
||||
const pageSize = maxPageSize.tools!;
|
||||
mcpServer.server.setRequestHandler("tools/list", async (request) => {
|
||||
const cursor = request.params?.cursor;
|
||||
const pageSize = maxPageSize.tools!;
|
||||
|
||||
// Convert registered tools to Tool format using the same logic as the SDK (mcp.js lines 67-95)
|
||||
const allTools: Tool[] = [];
|
||||
for (const [name, registered] of state.registeredTools.entries()) {
|
||||
if (registered.enabled) {
|
||||
// Match SDK's approach exactly (mcp.js lines 71-95)
|
||||
const toolDefinition: Record<string, unknown> = {
|
||||
name,
|
||||
title: registered.title,
|
||||
description: registered.description,
|
||||
inputSchema: (() => {
|
||||
const obj = normalizeObjectSchema(registered.inputSchema);
|
||||
return obj
|
||||
? toJsonSchemaCompat(obj, {
|
||||
strictUnions: true,
|
||||
pipeStrategy: "input",
|
||||
})
|
||||
: EMPTY_OBJECT_JSON_SCHEMA;
|
||||
})(),
|
||||
annotations: registered.annotations,
|
||||
execution: registered.execution,
|
||||
_meta: registered._meta,
|
||||
};
|
||||
// Convert registered tools to Tool format, mirroring the SDK's tools/list.
|
||||
// The input-schema JSON comes from the SDK's memoised converter; the
|
||||
// output-schema JSON is the value the SDK cached at registration.
|
||||
const allTools: Tool[] = [];
|
||||
for (const [name, registered] of state.registeredTools.entries()) {
|
||||
if (registered.enabled) {
|
||||
const toolDefinition: Record<string, unknown> = {
|
||||
name,
|
||||
title: registered.title,
|
||||
description: registered.description,
|
||||
inputSchema:
|
||||
mcpServer.toolInputSchemaJson(name) ?? EMPTY_OBJECT_JSON_SCHEMA,
|
||||
annotations: registered.annotations,
|
||||
execution: registered.execution,
|
||||
_meta: registered._meta,
|
||||
};
|
||||
|
||||
if (registered.outputSchema) {
|
||||
const obj = normalizeObjectSchema(registered.outputSchema);
|
||||
if (obj) {
|
||||
toolDefinition.outputSchema = toJsonSchemaCompat(obj, {
|
||||
strictUnions: true,
|
||||
pipeStrategy: "output",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
allTools.push(toolDefinition as Tool);
|
||||
if (registered.outputSchema && registered.outputSchemaJson) {
|
||||
toolDefinition.outputSchema = registered.outputSchemaJson;
|
||||
}
|
||||
|
||||
allTools.push(toolDefinition as Tool);
|
||||
}
|
||||
}
|
||||
|
||||
const startIndex = cursor ? parseInt(cursor, 10) : 0;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const page = allTools.slice(startIndex, endIndex);
|
||||
const nextCursor =
|
||||
endIndex < allTools.length ? endIndex.toString() : undefined;
|
||||
const startIndex = cursor ? parseInt(cursor, 10) : 0;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const page = allTools.slice(startIndex, endIndex);
|
||||
const nextCursor =
|
||||
endIndex < allTools.length ? endIndex.toString() : undefined;
|
||||
|
||||
return {
|
||||
tools: page,
|
||||
nextCursor,
|
||||
} as ListToolsResult;
|
||||
},
|
||||
);
|
||||
return {
|
||||
tools: page,
|
||||
nextCursor,
|
||||
} as ListToolsResult;
|
||||
});
|
||||
}
|
||||
|
||||
// Resources pagination
|
||||
if (capabilities.resources && maxPageSize.resources !== undefined) {
|
||||
mcpServer.server.setRequestHandler(
|
||||
ListResourcesRequestSchema,
|
||||
async (request, extra) => {
|
||||
"resources/list",
|
||||
async (request, ctx) => {
|
||||
const cursor = request.params?.cursor;
|
||||
const pageSize = maxPageSize.resources!;
|
||||
|
||||
|
|
@ -860,8 +1045,7 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
for (const template of state.registeredResourceTemplates.values()) {
|
||||
if (template.enabled && template.resourceTemplate.listCallback) {
|
||||
try {
|
||||
const result =
|
||||
await template.resourceTemplate.listCallback(extra);
|
||||
const result = await template.resourceTemplate.listCallback(ctx);
|
||||
for (const resource of result.resources) {
|
||||
allResources.push({
|
||||
...resource,
|
||||
|
|
@ -896,7 +1080,7 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
// Resource templates pagination
|
||||
if (capabilities.resources && maxPageSize.resourceTemplates !== undefined) {
|
||||
mcpServer.server.setRequestHandler(
|
||||
ListResourceTemplatesRequestSchema,
|
||||
"resources/templates/list",
|
||||
async (request) => {
|
||||
const cursor = request.params?.cursor;
|
||||
const pageSize = maxPageSize.resourceTemplates!;
|
||||
|
|
@ -952,54 +1136,187 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
|
||||
// Prompts pagination
|
||||
if (capabilities.prompts && maxPageSize.prompts !== undefined) {
|
||||
mcpServer.server.setRequestHandler(
|
||||
ListPromptsRequestSchema,
|
||||
async (request) => {
|
||||
const cursor = request.params?.cursor;
|
||||
const pageSize = maxPageSize.prompts!;
|
||||
mcpServer.server.setRequestHandler("prompts/list", async (request) => {
|
||||
const cursor = request.params?.cursor;
|
||||
const pageSize = maxPageSize.prompts!;
|
||||
|
||||
// Convert registered prompts to Prompt format using the same logic as the SDK
|
||||
const allPrompts: Prompt[] = [];
|
||||
for (const [name, prompt] of state.registeredPrompts.entries()) {
|
||||
if (prompt.enabled) {
|
||||
// Use the same conversion logic the SDK uses (from mcp.js line 408-419)
|
||||
const shape = prompt.argsSchema
|
||||
? getObjectShape(prompt.argsSchema)
|
||||
: undefined;
|
||||
const arguments_ = shape
|
||||
? Object.entries(shape).map(([argName, field]) => {
|
||||
const description = getSchemaDescription(field);
|
||||
const isOptional = isSchemaOptional(field);
|
||||
return {
|
||||
name: argName,
|
||||
description,
|
||||
required: !isOptional,
|
||||
} as PromptArgument;
|
||||
})
|
||||
: undefined;
|
||||
// Convert registered prompts to Prompt format. The argument descriptors
|
||||
// are derived from the config's raw arg shape (the SDK no longer exposes
|
||||
// a public shape-introspection helper).
|
||||
const allPrompts: Prompt[] = [];
|
||||
for (const [name, prompt] of state.registeredPrompts.entries()) {
|
||||
if (prompt.enabled) {
|
||||
const promptDef = config.prompts?.find((p) => p.name === name);
|
||||
const arguments_ = promptArgumentsFromRawShape(promptDef?.argsSchema);
|
||||
|
||||
allPrompts.push({
|
||||
name,
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
arguments: arguments_,
|
||||
} as Prompt);
|
||||
}
|
||||
allPrompts.push({
|
||||
name,
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
arguments: arguments_,
|
||||
} as Prompt);
|
||||
}
|
||||
}
|
||||
|
||||
const startIndex = cursor ? parseInt(cursor, 10) : 0;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const page = allPrompts.slice(startIndex, endIndex);
|
||||
const nextCursor =
|
||||
endIndex < allPrompts.length ? endIndex.toString() : undefined;
|
||||
const startIndex = cursor ? parseInt(cursor, 10) : 0;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const page = allPrompts.slice(startIndex, endIndex);
|
||||
const nextCursor =
|
||||
endIndex < allPrompts.length ? endIndex.toString() : undefined;
|
||||
|
||||
return {
|
||||
prompts: page,
|
||||
nextCursor,
|
||||
} as ListPromptsResult;
|
||||
},
|
||||
);
|
||||
return {
|
||||
prompts: page,
|
||||
nextCursor,
|
||||
} as ListPromptsResult;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Legacy tasks wiring (only when the tasks capability is enabled) ---
|
||||
if (taskStore) {
|
||||
wireTaskHandlers(mcpServer, taskStore, taskTools);
|
||||
}
|
||||
|
||||
return mcpServer;
|
||||
}
|
||||
|
||||
/** Structural view of the low-level `Server`'s request-handler registry. */
|
||||
interface RawHandlerHost {
|
||||
_requestHandlers: Map<
|
||||
string,
|
||||
(request: unknown, ctx: unknown) => Promise<unknown>
|
||||
>;
|
||||
}
|
||||
|
||||
/** Inbound `tools/call` request shape we read for task routing. */
|
||||
interface ToolsCallRequest {
|
||||
params: {
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
_meta?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up the 2025-11-25 task methods by hand:
|
||||
* - override `tools/call` so a task tool returns a `{ task }` handle
|
||||
* (`CreateTaskResult`) and runs its `createTask`, while ordinary tools fall
|
||||
* through to the SDK's own handler;
|
||||
* - register low-level `tasks/get` / `tasks/result` / `tasks/cancel` /
|
||||
* `tasks/list` handlers backed by the in-memory store.
|
||||
*
|
||||
* The `tools/call` override is installed directly into the handler registry
|
||||
* (not via `setRequestHandler`) so the `{ task }` result skips the `Server`'s
|
||||
* `tools/call` result-schema validation — a task handle is not a `CallToolResult`
|
||||
* but is a valid legacy `tools/call` response.
|
||||
*/
|
||||
function wireTaskHandlers(
|
||||
mcpServer: McpServer,
|
||||
taskStore: InMemoryTaskStore,
|
||||
taskTools: Map<string, TaskToolDefinition>,
|
||||
): void {
|
||||
const lowLevel = mcpServer.server;
|
||||
const registry = (lowLevel as unknown as RawHandlerHost)._requestHandlers;
|
||||
const sdkToolsCall = registry.get("tools/call");
|
||||
// Map each created task back to the tool that owns it, so tasks/get and
|
||||
// tasks/result route through that tool's handler.
|
||||
const taskOwners = new Map<string, TaskToolDefinition>();
|
||||
|
||||
registry.set("tools/call", async (request, ctx) => {
|
||||
const req = request as ToolsCallRequest;
|
||||
const taskTool = taskTools.get(req.params.name);
|
||||
if (taskTool) {
|
||||
const mcpReq = (ctx as McpReqContext).mcpReq;
|
||||
// Task execution is fire-and-forget: it runs AFTER this `tools/call`
|
||||
// request has already responded with the task handle. Its server→client
|
||||
// requests (elicitation/sampling) and notifications (progress) must
|
||||
// therefore go on the server's STANDALONE stream, not the per-request
|
||||
// stream (`mcpReq.send`/`mcpReq.notify` tag `relatedRequestId` to this
|
||||
// now-closed request, so the client never sees them). Route through the
|
||||
// server's own `request`/`notification` instead.
|
||||
const extra: CreateTaskRequestHandlerExtra = {
|
||||
taskStore,
|
||||
_meta: req.params._meta ?? mcpReq?._meta,
|
||||
requestId: mcpReq?.id,
|
||||
sendRequest: ((request, resultSchema) =>
|
||||
mcpServer.server.request(
|
||||
request,
|
||||
resultSchema,
|
||||
)) as CreateTaskRequestHandlerExtra["sendRequest"],
|
||||
sendNotification: (notification) =>
|
||||
mcpServer.server.notification(notification),
|
||||
};
|
||||
const { task } = await taskTool.handler.createTask(
|
||||
req.params.arguments ?? {},
|
||||
extra,
|
||||
);
|
||||
taskOwners.set(task.taskId, taskTool);
|
||||
// Return a result that is BOTH a valid legacy `CreateTaskResult` (carries
|
||||
// `task`) AND a valid wire `CallToolResult` (carries `content`). The
|
||||
// Inspector's task-augmented `tools/call` is validated on the client
|
||||
// against the tools/call wire schema, whose SEP-2106 guard rejects a body
|
||||
// that carries `task` but no `content`; including a placeholder `content`
|
||||
// satisfies that guard while the `task` handle drives the task flow (the
|
||||
// real payload is later fetched via `tasks/result`).
|
||||
return {
|
||||
content: [{ type: "text", text: `Task ${task.taskId} created` }],
|
||||
task,
|
||||
};
|
||||
}
|
||||
if (!sdkToolsCall) {
|
||||
throw new Error("tools/call handler is not initialized");
|
||||
}
|
||||
return sdkToolsCall(request, ctx);
|
||||
});
|
||||
|
||||
mcpServer.server.setRequestHandler(
|
||||
"tasks/get",
|
||||
{ params: GetTaskRequestSchema.shape.params },
|
||||
async (params): Promise<GetTaskResult> => {
|
||||
const taskId = params.taskId;
|
||||
const owner = taskOwners.get(taskId);
|
||||
if (owner) {
|
||||
return owner.handler.getTask({}, { taskId, taskStore });
|
||||
}
|
||||
const task = await taskStore.getTask(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Unknown taskId: ${taskId}`);
|
||||
}
|
||||
return task as GetTaskResult;
|
||||
},
|
||||
);
|
||||
|
||||
mcpServer.server.setRequestHandler(
|
||||
"tasks/result",
|
||||
{ params: GetTaskPayloadRequestSchema.shape.params },
|
||||
async (params): Promise<CallToolResult> => {
|
||||
const taskId = params.taskId;
|
||||
const owner = taskOwners.get(taskId);
|
||||
if (owner) {
|
||||
return owner.handler.getTaskResult({}, { taskId, taskStore });
|
||||
}
|
||||
return taskStore.getTaskResult(taskId);
|
||||
},
|
||||
);
|
||||
|
||||
mcpServer.server.setRequestHandler(
|
||||
"tasks/cancel",
|
||||
{ params: CancelTaskRequestSchema.shape.params },
|
||||
async (params): Promise<Task> => {
|
||||
const taskId = params.taskId;
|
||||
const task = await taskStore.updateTaskStatus(taskId, "cancelled");
|
||||
if (!task) {
|
||||
throw new Error(`Unknown taskId: ${taskId}`);
|
||||
}
|
||||
return task;
|
||||
},
|
||||
);
|
||||
|
||||
mcpServer.server.setRequestHandler(
|
||||
"tasks/list",
|
||||
{ params: ListTasksRequestSchema.shape.params },
|
||||
async (): Promise<ListTasksResult> => {
|
||||
const tasks = await taskStore.listTasks();
|
||||
return { tasks };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue