diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 9dffe4418..1176eeedf 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -424,11 +424,42 @@ const tool = await client.callTool({ name: 'my-tool', arguments: {} }); Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc., when they were only used in `request()`/`send()`/`callTool()` calls. -## 12. Client Behavioral Changes +## 12. Registered Primitives API Changes + +`RegisteredTool`, `RegisteredPrompt`, `RegisteredResource`, `RegisteredResourceTemplate` are now proper classes. The `update()` method signature changed: + +| v1 (update field) | v2 (update field) | Applies to | +| ----------------- | ----------------- | ---------------------------------------------------------------------------- | +| `paramsSchema` | `inputSchema` | RegisteredTool | +| `callback` | `handler` | RegisteredTool | +| `callback` | `callback` | RegisteredPrompt, RegisteredResource, RegisteredResourceTemplate (unchanged) | + +```typescript +// v1 +tool.update({ paramsSchema: { name: z.string() }, callback: handler }); + +// v2 +tool.update({ inputSchema: { name: z.string() }, handler: handler }); +``` + +**Note:** In v1, `paramsSchema` inconsistently differed from `inputSchema` used in `registerTool()`. Fixed in v2. + +**New:** `RegisteredTool` now supports `icons` field (parity with protocol `Tool` type). + +New getter methods on `McpServer`: + +| Getter | Returns | +|--------|---------| +| `mcpServer.tools` | `ReadonlyMap` | +| `mcpServer.prompts` | `ReadonlyMap` | +| `mcpServer.resources` | `ReadonlyMap` | +| `mcpServer.resourceTemplates` | `ReadonlyMap` | + +## 13. Client Behavioral Changes `Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead. -## 13. Runtime-Specific JSON Schema Validators (Enhancement) +## 14. Runtime-Specific JSON Schema Validators (Enhancement) The SDK now auto-selects the appropriate JSON Schema validator based on runtime: - Node.js → `AjvJsonSchemaValidator` (no change from v1) @@ -448,7 +479,7 @@ new McpServer({ name: 'server', version: '1.0.0' }, {}); Access validators via `_shims` export: `import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims';` -## 14. Migration Steps (apply in this order) +## 15. Migration Steps (apply in this order) 1. Update `package.json`: `npm uninstall @modelcontextprotocol/sdk`, install the appropriate v2 packages 2. Replace all imports from `@modelcontextprotocol/sdk/...` using the import mapping tables (sections 3-4), including `StreamableHTTPServerTransport` → `NodeStreamableHTTPServerTransport` diff --git a/docs/migration.md b/docs/migration.md index 59b2b50ed..9f0599074 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -404,6 +404,56 @@ const result = await client.callTool({ name: 'my-tool', arguments: {} }); The return type is now inferred from the method name via `ResultTypeMap`. For example, `client.request({ method: 'tools/call', ... })` returns `Promise`. +### Registered primitives are now classes + +`RegisteredTool`, `RegisteredPrompt`, `RegisteredResource`, and `RegisteredResourceTemplate` are now proper classes instead of plain object types. They are exported from `@modelcontextprotocol/server`. + +The `update()` method now uses `inputSchema` and `handler` instead of `paramsSchema` and `callback`: + +**Before (v1):** + +```typescript +const tool = server.registerTool('my-tool', { inputSchema: { name: z.string() } }, handler); + +tool.update({ + paramsSchema: { name: z.string(), value: z.number() }, + callback: newHandler +}); +``` + +**After (v2):** + +```typescript +const tool = server.registerTool('my-tool', { inputSchema: { name: z.string() } }, handler); + +tool.update({ + inputSchema: { name: z.string(), value: z.number() }, + handler: newHandler +}); +``` + +**Note:** In v1, `RegisteredTool.update()` used `paramsSchema` which inconsistently differed from the `inputSchema` field used in `registerTool()`. This has been fixed in v2. + +**New:** `RegisteredTool` now supports the `icons` field for parity with the protocol `Tool` type: + +```typescript +tool.update({ icons: [{ type: 'base64', mediaType: 'image/png', data: '...' }] }); +``` + +New getter methods are available on `McpServer` to access all registered items: + +```typescript +// Get all registered tools +for (const [name, tool] of mcpServer.tools) { + console.log(name, tool.description, tool.enabled); +} + +// Similarly for prompts, resources, resourceTemplates +mcpServer.prompts; +mcpServer.resources; +mcpServer.resourceTemplates; +``` + ### Client list methods return empty results for missing capabilities `Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, and `listTools()` now return empty results when the server didn't advertise the corresponding capability, instead of sending the request. This respects the MCP spec's capability negotiation. diff --git a/docs/server.md b/docs/server.md index 3c246ac12..2e69f47f5 100644 --- a/docs/server.md +++ b/docs/server.md @@ -199,7 +199,7 @@ server.registerResource( ); ``` -Dynamic resources use {@linkcode @modelcontextprotocol/server!server/mcp.ResourceTemplate | ResourceTemplate} and can support completions on path parameters: +Dynamic resources use {@linkcode @modelcontextprotocol/server!server/primitives/resourceTemplate.ResourceTemplate | ResourceTemplate} and can support completions on path parameters: ```ts source="../examples/server/src/serverGuide.examples.ts#registerResource_template" server.registerResource( diff --git a/packages/server/src/experimental/tasks/interfaces.ts b/packages/server/src/experimental/tasks/interfaces.ts index d12ee11d8..f315edcff 100644 --- a/packages/server/src/experimental/tasks/interfaces.ts +++ b/packages/server/src/experimental/tasks/interfaces.ts @@ -13,7 +13,7 @@ import type { TaskServerContext } from '@modelcontextprotocol/core'; -import type { BaseToolCallback } from '../../server/mcp.js'; +import type { BaseToolCallback } from '../../server/primitives/index.js'; // ============================================================================ // Task Handler Types (for registerToolTask) diff --git a/packages/server/src/experimental/tasks/mcpServer.ts b/packages/server/src/experimental/tasks/mcpServer.ts index c1558e445..abd51195d 100644 --- a/packages/server/src/experimental/tasks/mcpServer.ts +++ b/packages/server/src/experimental/tasks/mcpServer.ts @@ -7,7 +7,8 @@ import type { AnySchema, TaskToolExecution, ToolAnnotations, ToolExecution } from '@modelcontextprotocol/core'; -import type { AnyToolHandler, McpServer, RegisteredTool } from '../../server/mcp.js'; +import type { McpServer } from '../../server/mcp.js'; +import type { AnyToolHandler, RegisteredTool } from '../../server/primitives/index.js'; import type { ToolTaskHandler } from './interfaces.js'; /** @@ -72,7 +73,7 @@ export class ExperimentalMcpServerTasks { * @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 - * @returns {@linkcode server/mcp.RegisteredTool | RegisteredTool} for managing the tool's lifecycle + * @returns {@linkcode server/primitives/tool.RegisteredTool | RegisteredTool} for managing the tool's lifecycle * * @experimental */ diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1a8dbf143..c23ee7619 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,6 +1,7 @@ export * from './server/completable.js'; export * from './server/mcp.js'; export * from './server/middleware/hostHeaderValidation.js'; +export * from './server/primitives/index.js'; export * from './server/server.js'; export * from './server/stdio.js'; export * from './server/streamableHttp.js'; diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 05136f5b6..4becd3486 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -7,46 +7,42 @@ import type { CompleteRequestResourceTemplate, CompleteResult, CreateTaskResult, - CreateTaskServerContext, GetPromptResult, Implementation, ListPromptsResult, - ListResourcesResult, ListToolsResult, LoggingMessageNotification, - Prompt, - PromptArgument, PromptReference, - ReadResourceResult, Resource, ResourceTemplateReference, - Result, SchemaOutput, ServerContext, - Tool, ToolAnnotations, ToolExecution, - Transport, - Variables + Transport } from '@modelcontextprotocol/core'; import { assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate, - getSchemaDescription, getSchemaShape, - isOptionalSchema, parseSchemaAsync, ProtocolError, ProtocolErrorCode, - schemaToJson, - unwrapOptionalSchema, - UriTemplate, - validateAndWarnToolName + unwrapOptionalSchema } from '@modelcontextprotocol/core'; -import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; import { getCompleter, isCompletable } from './completable.js'; +import type { + AnyToolHandler, + PromptCallback, + ReadResourceCallback, + ReadResourceTemplateCallback, + ResourceMetadata, + ResourceTemplate, + ToolCallback +} from './primitives/index.js'; +import { RegisteredPrompt, RegisteredResource, RegisteredResourceTemplate, RegisteredTool } from './primitives/index.js'; import type { ServerOptions } from './server.js'; import { Server } from './server.js'; @@ -97,6 +93,38 @@ export class McpServer { return this._experimental; } + /** + * Gets all registered tools. + * @returns A read-only map of tool names to RegisteredTool instances + */ + get tools(): ReadonlyMap { + return new Map(Object.entries(this._registeredTools)); + } + + /** + * Gets all registered prompts. + * @returns A read-only map of prompt names to RegisteredPrompt instances + */ + get prompts(): ReadonlyMap { + return new Map(Object.entries(this._registeredPrompts)); + } + + /** + * Gets all registered resources. + * @returns A read-only map of resource URIs to RegisteredResource instances + */ + get resources(): ReadonlyMap { + return new Map(Object.entries(this._registeredResources)); + } + + /** + * Gets all registered resource templates. + * @returns A read-only map of template names to RegisteredResourceTemplate instances + */ + get resourceTemplates(): ReadonlyMap { + return new Map(Object.entries(this._registeredResourceTemplates)); + } + /** * Attaches to the given transport, starts it, and starts listening for messages. * @@ -139,29 +167,9 @@ export class McpServer { this.server.setRequestHandler( 'tools/list', (): ListToolsResult => ({ - tools: Object.entries(this._registeredTools) - .filter(([, tool]) => tool.enabled) - .map(([name, tool]): Tool => { - const toolDefinition: Tool = { - name, - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema - ? (schemaToJson(tool.inputSchema, { io: 'input' }) as Tool['inputSchema']) - : EMPTY_OBJECT_JSON_SCHEMA, - annotations: tool.annotations, - execution: tool.execution, - _meta: tool._meta - }; - - if (tool.outputSchema) { - toolDefinition.outputSchema = schemaToJson(tool.outputSchema, { - io: 'output' - }) as Tool['outputSchema']; - } - - return toolDefinition; - }) + tools: Object.values(this._registeredTools) + .filter(tool => tool.enabled) + .map(tool => tool.toProtocolTool()) }) ); @@ -449,13 +457,9 @@ export class McpServer { }); this.server.setRequestHandler('resources/list', async (_request, ctx) => { - const resources = Object.entries(this._registeredResources) - .filter(([_, resource]) => resource.enabled) - .map(([uri, resource]) => ({ - uri, - name: resource.name, - ...resource.metadata - })); + const resources = Object.values(this._registeredResources) + .filter(resource => resource.enabled) + .map(resource => resource.toProtocolResource()); const templateResources: Resource[] = []; for (const template of Object.values(this._registeredResourceTemplates)) { @@ -477,11 +481,9 @@ export class McpServer { }); this.server.setRequestHandler('resources/templates/list', async () => { - const resourceTemplates = Object.entries(this._registeredResourceTemplates).map(([name, template]) => ({ - name, - uriTemplate: template.resourceTemplate.uriTemplate.toString(), - ...template.metadata - })); + const resourceTemplates = Object.values(this._registeredResourceTemplates).map(template => + template.toProtocolResourceTemplate() + ); return { resourceTemplates }; }); @@ -531,16 +533,9 @@ export class McpServer { this.server.setRequestHandler( 'prompts/list', (): ListPromptsResult => ({ - prompts: Object.entries(this._registeredPrompts) - .filter(([, prompt]) => prompt.enabled) - .map(([name, prompt]): Prompt => { - return { - name, - title: prompt.title, - description: prompt.description, - arguments: prompt.argsSchema ? promptArgumentsFromSchema(prompt.argsSchema) : undefined - }; - }) + prompts: Object.values(this._registeredPrompts) + .filter(prompt => prompt.enabled) + .map(prompt => prompt.toProtocolPrompt()) }) ); @@ -635,30 +630,27 @@ export class McpServer { metadata: ResourceMetadata | undefined, readCallback: ReadResourceCallback ): RegisteredResource { - const registeredResource: RegisteredResource = { - name, - title, - metadata, - readCallback, - enabled: true, - disable: () => registeredResource.update({ enabled: false }), - enable: () => registeredResource.update({ enabled: true }), - remove: () => registeredResource.update({ uri: null }), - update: updates => { - if (updates.uri !== undefined && updates.uri !== uri) { - delete this._registeredResources[uri]; - if (updates.uri) this._registeredResources[updates.uri] = registeredResource; - } - if (updates.name !== undefined) registeredResource.name = updates.name; - if (updates.title !== undefined) registeredResource.title = updates.title; - if (updates.metadata !== undefined) registeredResource.metadata = updates.metadata; - if (updates.callback !== undefined) registeredResource.readCallback = updates.callback; - if (updates.enabled !== undefined) registeredResource.enabled = updates.enabled; + const resource = new RegisteredResource( + { + name, + title, + uri, + ...metadata, + readCallback + }, + () => this.sendResourceListChanged(), + (oldUri, newUri, r) => { + delete this._registeredResources[oldUri]; + this._registeredResources[newUri] = r; + this.sendResourceListChanged(); + }, + resourceUri => { + delete this._registeredResources[resourceUri]; this.sendResourceListChanged(); } - }; - this._registeredResources[uri] = registeredResource; - return registeredResource; + ); + this._registeredResources[uri] = resource; + return resource; } private _createRegisteredResourceTemplate( @@ -668,29 +660,26 @@ export class McpServer { metadata: ResourceMetadata | undefined, readCallback: ReadResourceTemplateCallback ): RegisteredResourceTemplate { - const registeredResourceTemplate: RegisteredResourceTemplate = { - resourceTemplate: template, - title, - metadata, - readCallback, - enabled: true, - disable: () => registeredResourceTemplate.update({ enabled: false }), - enable: () => registeredResourceTemplate.update({ enabled: true }), - remove: () => registeredResourceTemplate.update({ name: null }), - update: updates => { - if (updates.name !== undefined && updates.name !== name) { - delete this._registeredResourceTemplates[name]; - if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate; - } - if (updates.title !== undefined) registeredResourceTemplate.title = updates.title; - if (updates.template !== undefined) registeredResourceTemplate.resourceTemplate = updates.template; - if (updates.metadata !== undefined) registeredResourceTemplate.metadata = updates.metadata; - if (updates.callback !== undefined) registeredResourceTemplate.readCallback = updates.callback; - if (updates.enabled !== undefined) registeredResourceTemplate.enabled = updates.enabled; + const resourceTemplate = new RegisteredResourceTemplate( + { + name, + title, + resourceTemplate: template, + ...metadata, + readCallback + }, + () => this.sendResourceListChanged(), + (oldName, newName, rt) => { + delete this._registeredResourceTemplates[oldName]; + this._registeredResourceTemplates[newName] = rt; + this.sendResourceListChanged(); + }, + templateName => { + delete this._registeredResourceTemplates[templateName]; this.sendResourceListChanged(); } - }; - this._registeredResourceTemplates[name] = registeredResourceTemplate; + ); + this._registeredResourceTemplates[name] = resourceTemplate; // If the resource template has any completion callbacks, enable completions capability const variableNames = template.uriTemplate.variableNames; @@ -699,7 +688,7 @@ export class McpServer { this.setCompletionRequestHandler(); } - return registeredResourceTemplate; + return resourceTemplate; } private _createRegisteredPrompt( @@ -709,47 +698,26 @@ export class McpServer { argsSchema: AnySchema | undefined, callback: PromptCallback ): RegisteredPrompt { - // Track current schema and callback for handler regeneration - let currentArgsSchema = argsSchema; - let currentCallback = callback; - - const registeredPrompt: RegisteredPrompt = { - title, - description, - argsSchema, - handler: createPromptHandler(name, argsSchema, callback), - enabled: true, - disable: () => registeredPrompt.update({ enabled: false }), - enable: () => registeredPrompt.update({ enabled: true }), - remove: () => registeredPrompt.update({ name: null }), - update: updates => { - if (updates.name !== undefined && updates.name !== name) { - delete this._registeredPrompts[name]; - if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt; - } - if (updates.title !== undefined) registeredPrompt.title = updates.title; - if (updates.description !== undefined) registeredPrompt.description = updates.description; - - // Track if we need to regenerate the handler - let needsHandlerRegen = false; - if (updates.argsSchema !== undefined) { - registeredPrompt.argsSchema = updates.argsSchema; - currentArgsSchema = updates.argsSchema; - needsHandlerRegen = true; - } - if (updates.callback !== undefined) { - currentCallback = updates.callback as PromptCallback; - needsHandlerRegen = true; - } - if (needsHandlerRegen) { - registeredPrompt.handler = createPromptHandler(name, currentArgsSchema, currentCallback); - } - - if (updates.enabled !== undefined) registeredPrompt.enabled = updates.enabled; + const prompt = new RegisteredPrompt( + { + name, + title, + description, + argsSchema, + callback + }, + () => this.sendPromptListChanged(), + (oldName, newName, p) => { + delete this._registeredPrompts[oldName]; + this._registeredPrompts[newName] = p; + this.sendPromptListChanged(); + }, + promptName => { + delete this._registeredPrompts[promptName]; this.sendPromptListChanged(); } - }; - this._registeredPrompts[name] = registeredPrompt; + ); + this._registeredPrompts[name] = prompt; // If any argument uses a Completable schema, enable completions capability if (argsSchema) { @@ -765,7 +733,7 @@ export class McpServer { } } - return registeredPrompt; + return prompt; } private _createRegisteredTool( @@ -779,65 +747,35 @@ export class McpServer { _meta: Record | undefined, handler: AnyToolHandler ): RegisteredTool { - // Validate tool name according to SEP specification - validateAndWarnToolName(name); - - // Track current handler for executor regeneration - let currentHandler = handler; - - const registeredTool: RegisteredTool = { - title, - description, - inputSchema, - outputSchema, - annotations, - execution, - _meta, - handler: handler, - executor: createToolExecutor(inputSchema, handler), - enabled: true, - disable: () => registeredTool.update({ enabled: false }), - enable: () => registeredTool.update({ enabled: true }), - remove: () => registeredTool.update({ name: null }), - update: updates => { - if (updates.name !== undefined && updates.name !== name) { - if (typeof updates.name === 'string') { - validateAndWarnToolName(updates.name); - } - delete this._registeredTools[name]; - if (updates.name) this._registeredTools[updates.name] = registeredTool; - } - if (updates.title !== undefined) registeredTool.title = updates.title; - if (updates.description !== undefined) registeredTool.description = updates.description; - - // Track if we need to regenerate the executor - let needsExecutorRegen = false; - if (updates.paramsSchema !== undefined) { - registeredTool.inputSchema = updates.paramsSchema; - needsExecutorRegen = true; - } - if (updates.callback !== undefined) { - registeredTool.handler = updates.callback; - currentHandler = updates.callback as AnyToolHandler; - needsExecutorRegen = true; - } - if (needsExecutorRegen) { - registeredTool.executor = createToolExecutor(registeredTool.inputSchema, currentHandler); - } - - if (updates.outputSchema !== undefined) registeredTool.outputSchema = updates.outputSchema; - if (updates.annotations !== undefined) registeredTool.annotations = updates.annotations; - if (updates._meta !== undefined) registeredTool._meta = updates._meta; - if (updates.enabled !== undefined) registeredTool.enabled = updates.enabled; + const tool = new RegisteredTool( + { + name, + title, + description, + inputSchema, + outputSchema, + annotations, + execution, + _meta, + handler + }, + () => this.sendToolListChanged(), + (oldName, newName, t) => { + delete this._registeredTools[oldName]; + this._registeredTools[newName] = t; + this.sendToolListChanged(); + }, + toolName => { + delete this._registeredTools[toolName]; this.sendToolListChanged(); } - }; - this._registeredTools[name] = registeredTool; + ); + this._registeredTools[name] = tool; this.setToolRequestHandlers(); this.sendToolListChanged(); - return registeredTool; + return tool; } /** @@ -1006,288 +944,6 @@ export class McpServer { } } -/** - * A callback to complete one variable within a resource template's URI template. - */ -export type CompleteResourceTemplateCallback = ( - value: string, - context?: { - arguments?: Record; - } -) => string[] | Promise; - -/** - * A resource template combines a URI pattern with optional functionality to enumerate - * all resources matching that pattern. - */ -export class ResourceTemplate { - private _uriTemplate: UriTemplate; - - constructor( - uriTemplate: string | UriTemplate, - private _callbacks: { - /** - * A callback to list all resources matching this template. This is required to be specified, even if `undefined`, to avoid accidentally forgetting resource listing. - */ - list: ListResourcesCallback | undefined; - - /** - * An optional callback to autocomplete variables within the URI template. Useful for clients and users to discover possible values. - */ - complete?: { - [variable: string]: CompleteResourceTemplateCallback; - }; - } - ) { - this._uriTemplate = typeof uriTemplate === 'string' ? new UriTemplate(uriTemplate) : uriTemplate; - } - - /** - * Gets the URI template pattern. - */ - get uriTemplate(): UriTemplate { - return this._uriTemplate; - } - - /** - * Gets the list callback, if one was provided. - */ - get listCallback(): ListResourcesCallback | undefined { - return this._callbacks.list; - } - - /** - * Gets the callback for completing a specific URI template variable, if one was provided. - */ - completeCallback(variable: string): CompleteResourceTemplateCallback | undefined { - return this._callbacks.complete?.[variable]; - } -} - -export type BaseToolCallback = Args extends AnySchema - ? (args: SchemaOutput, ctx: Ctx) => ResultT | Promise - : (ctx: Ctx) => ResultT | Promise; - -/** - * Callback for a tool handler registered with {@linkcode McpServer.registerTool}. - */ -export type ToolCallback = BaseToolCallback; - -/** - * Supertype that can handle both regular tools (simple callback) and task-based tools (task handler object). - */ -export type AnyToolHandler = ToolCallback | ToolTaskHandler; - -/** - * Internal executor type that encapsulates handler invocation with proper types. - */ -type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; - -export type RegisteredTool = { - title?: string; - description?: string; - inputSchema?: AnySchema; - outputSchema?: AnySchema; - annotations?: ToolAnnotations; - execution?: ToolExecution; - _meta?: Record; - handler: AnyToolHandler; - /** @hidden */ - executor: ToolExecutor; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - description?: string; - paramsSchema?: AnySchema; - outputSchema?: AnySchema; - annotations?: ToolAnnotations; - _meta?: Record; - callback?: ToolCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -/** - * Creates an executor that invokes the handler with the appropriate arguments. - * When `inputSchema` is defined, the handler is called with `(args, ctx)`. - * When `inputSchema` is undefined, the handler is called with just `(ctx)`. - */ -function createToolExecutor(inputSchema: AnySchema | undefined, handler: AnyToolHandler): ToolExecutor { - const isTaskHandler = 'createTask' in handler; - - if (isTaskHandler) { - const taskHandler = handler as TaskHandlerInternal; - return async (args, ctx) => { - if (!ctx.task?.store) { - throw new Error('No task store provided.'); - } - const taskCtx: CreateTaskServerContext = { ...ctx, task: { store: ctx.task.store, requestedTtl: ctx.task?.requestedTtl } }; - if (inputSchema) { - return taskHandler.createTask(args, taskCtx); - } - // When no inputSchema, call with just ctx (the handler expects (ctx) signature) - return (taskHandler.createTask as (ctx: CreateTaskServerContext) => CreateTaskResult | Promise)(taskCtx); - }; - } - - if (inputSchema) { - const callback = handler as ToolCallbackInternal; - return async (args, ctx) => callback(args, ctx); - } - - // When no inputSchema, call with just ctx (the handler expects (ctx) signature) - const callback = handler as (ctx: ServerContext) => CallToolResult | Promise; - return async (_args, ctx) => callback(ctx); -} - -const EMPTY_OBJECT_JSON_SCHEMA = { - type: 'object' as const, - properties: {} -}; - -/** - * Additional, optional information for annotating a resource. - */ -export type ResourceMetadata = Omit; - -/** - * Callback to list all resources matching a given template. - */ -export type ListResourcesCallback = (ctx: ServerContext) => ListResourcesResult | Promise; - -/** - * Callback to read a resource at a given URI. - */ -export type ReadResourceCallback = (uri: URL, ctx: ServerContext) => ReadResourceResult | Promise; - -export type RegisteredResource = { - name: string; - title?: string; - metadata?: ResourceMetadata; - readCallback: ReadResourceCallback; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string; - title?: string; - uri?: string | null; - metadata?: ResourceMetadata; - callback?: ReadResourceCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -/** - * Callback to read a resource at a given URI, following a filled-in URI template. - */ -export type ReadResourceTemplateCallback = ( - uri: URL, - variables: Variables, - ctx: ServerContext -) => ReadResourceResult | Promise; - -export type RegisteredResourceTemplate = { - resourceTemplate: ResourceTemplate; - title?: string; - metadata?: ResourceMetadata; - readCallback: ReadResourceTemplateCallback; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - template?: ResourceTemplate; - metadata?: ResourceMetadata; - callback?: ReadResourceTemplateCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -export type PromptCallback = Args extends AnySchema - ? (args: SchemaOutput, ctx: ServerContext) => GetPromptResult | Promise - : (ctx: ServerContext) => GetPromptResult | Promise; - -/** - * Internal handler type that encapsulates parsing and callback invocation. - * This allows type-safe handling without runtime type assertions. - */ -type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; - -type ToolCallbackInternal = (args: unknown, ctx: ServerContext) => CallToolResult | Promise; - -type TaskHandlerInternal = { - createTask: (args: unknown, ctx: CreateTaskServerContext) => CreateTaskResult | Promise; -}; - -export type RegisteredPrompt = { - title?: string; - description?: string; - argsSchema?: AnySchema; - /** @hidden */ - handler: PromptHandler; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - description?: string; - argsSchema?: Args; - callback?: PromptCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -/** - * Creates a type-safe prompt handler that captures the schema and callback in a closure. - * This eliminates the need for type assertions at the call site. - */ -function createPromptHandler( - name: string, - argsSchema: AnySchema | undefined, - callback: PromptCallback -): PromptHandler { - if (argsSchema) { - const typedCallback = callback as (args: SchemaOutput, ctx: ServerContext) => GetPromptResult | Promise; - - return async (args, ctx) => { - const parseResult = await parseSchemaAsync(argsSchema, args); - if (!parseResult.success) { - const errorMessage = parseResult.error.issues.map((i: { message: string }) => i.message).join(', '); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for prompt ${name}: ${errorMessage}`); - } - return typedCallback(parseResult.data as SchemaOutput, ctx); - }; - } else { - const typedCallback = callback as (ctx: ServerContext) => GetPromptResult | Promise; - - return async (_args, ctx) => { - return typedCallback(ctx); - }; - } -} - -function promptArgumentsFromSchema(schema: AnySchema): PromptArgument[] { - const shape = getSchemaShape(schema); - if (!shape) return []; - return Object.entries(shape).map(([name, field]): PromptArgument => { - return { - name, - description: getSchemaDescription(field), - required: !isOptionalSchema(field) - }; - }); -} - function createCompletionResult(suggestions: readonly unknown[]): CompleteResult { const values = suggestions.map(String).slice(0, 100); return { diff --git a/packages/server/src/server/primitives/index.ts b/packages/server/src/server/primitives/index.ts new file mode 100644 index 000000000..9b415d48d --- /dev/null +++ b/packages/server/src/server/primitives/index.ts @@ -0,0 +1,41 @@ +/** + * Registered primitives for MCP server (tools, prompts, resources). + * These classes manage the lifecycle of registered items and provide + * methods to enable, disable, update, and remove them. + */ + +// Shared types +export type { OnRemove, OnRename, OnUpdate } from './types.js'; + +// Tool exports +export { + type AnyToolHandler, + type BaseToolCallback, + RegisteredTool, + type ToolCallback, + type ToolConfig, + type ToolProtocolFields +} from './tool.js'; + +// Prompt exports +export { type PromptArgsRawShape, type PromptCallback, type PromptConfig, type PromptProtocolFields, RegisteredPrompt } from './prompt.js'; + +// Resource exports +export { + type ReadResourceCallback, + RegisteredResource, + type ResourceConfig, + type ResourceMetadata, + type ResourceProtocolFields +} from './resource.js'; + +// Resource template exports +export { + type CompleteResourceTemplateCallback, + type ListResourcesCallback, + type ReadResourceTemplateCallback, + RegisteredResourceTemplate, + ResourceTemplate, + type ResourceTemplateConfig, + type ResourceTemplateProtocolFields +} from './resourceTemplate.js'; diff --git a/packages/server/src/server/primitives/prompt.ts b/packages/server/src/server/primitives/prompt.ts new file mode 100644 index 000000000..aed3c88f0 --- /dev/null +++ b/packages/server/src/server/primitives/prompt.ts @@ -0,0 +1,243 @@ +import type { AnySchema, GetPromptResult, Icon, Prompt, PromptArgument, SchemaOutput, ServerContext } from '@modelcontextprotocol/core'; +import { + getSchemaDescription, + getSchemaShape, + isOptionalSchema, + parseSchemaAsync, + ProtocolError, + ProtocolErrorCode +} from '@modelcontextprotocol/core'; + +import type { OnRemove, OnRename, OnUpdate } from './types.js'; + +/** + * Raw shape type for prompt arguments (Zod schema shape). + */ +export type PromptArgsRawShape = AnySchema; + +/** + * Callback for a prompt handler registered with McpServer.registerPrompt(). + */ +export type PromptCallback = Args extends AnySchema + ? (args: SchemaOutput, ctx: ServerContext) => GetPromptResult | Promise + : (ctx: ServerContext) => GetPromptResult | Promise; + +/** + * Protocol fields for Prompt, derived from the Prompt type. + * Uses argsSchema (Zod schema) instead of arguments array (converted in toProtocolPrompt). + */ +export type PromptProtocolFields = Omit & { + argsSchema?: AnySchema; +}; + +/** + * Configuration for creating a RegisteredPrompt. + * Combines protocol fields with SDK-specific callback. + */ +export type PromptConfig = PromptProtocolFields & { + callback: PromptCallback; +}; + +/** + * Converts a Zod object schema to an array of PromptArguments. + */ +function promptArgumentsFromSchema(schema: AnySchema): PromptArgument[] { + const shape = getSchemaShape(schema); + if (!shape) return []; + return Object.entries(shape).map(([name, field]): PromptArgument => { + const description = getSchemaDescription(field); + const isOptional = isOptionalSchema(field); + return { + name, + description, + required: !isOptional + }; + }); +} + +/** + * A registered prompt in the MCP server. + * Provides methods to enable, disable, update, rename, and remove the prompt. + */ +export class RegisteredPrompt { + // Protocol fields - stored together for easy spreading + #protocolFields: PromptProtocolFields; + + // SDK-specific fields - separate from protocol + #callback: PromptCallback; + #enabled: boolean = true; + + // Callbacks for McpServer communication + readonly #onUpdate: OnUpdate; + readonly #onRename: OnRename; + readonly #onRemove: OnRemove; + + constructor(config: PromptConfig, onUpdate: OnUpdate, onRename: OnRename, onRemove: OnRemove) { + // Separate protocol fields from SDK fields + const { callback, ...protocolFields } = config; + this.#protocolFields = protocolFields; + this.#callback = callback; + + this.#onUpdate = onUpdate; + this.#onRename = onRename; + this.#onRemove = onRemove; + } + + // Protocol field getters (delegate to #protocolFields) + get name(): string { + return this.#protocolFields.name; + } + get title(): string | undefined { + return this.#protocolFields.title; + } + get description(): string | undefined { + return this.#protocolFields.description; + } + get icons(): Icon[] | undefined { + return this.#protocolFields.icons; + } + get argsSchema(): AnySchema | undefined { + return this.#protocolFields.argsSchema; + } + get _meta(): Record | undefined { + return this.#protocolFields._meta; + } + + // SDK-specific getters + get callback(): PromptCallback { + return this.#callback; + } + get enabled(): boolean { + return this.#enabled; + } + + /** + * Enables the prompt. + * @returns this for chaining + */ + enable(): this { + if (!this.#enabled) { + this.#enabled = true; + this.#onUpdate(); + } + return this; + } + + /** + * Disables the prompt. + * @returns this for chaining + */ + disable(): this { + if (this.#enabled) { + this.#enabled = false; + this.#onUpdate(); + } + return this; + } + + /** + * Renames the prompt. + * @param newName - The new name for the prompt + * @returns this for chaining + */ + rename(newName: string): this { + if (newName !== this.#protocolFields.name) { + const oldName = this.#protocolFields.name; + this.#protocolFields.name = newName; + this.#onRename(oldName, newName, this); + } + return this; + } + + /** + * Removes the prompt from the registry. + */ + remove(): void { + this.#onRemove(this.#protocolFields.name); + } + + /** + * Updates the prompt's properties. + * @param updates - The properties to update + */ + update( + updates: { + name?: string | null; + argsSchema?: Args; + callback?: PromptCallback; + enabled?: boolean; + } & Omit, 'name' | 'argsSchema'> + ): void { + // Handle name change (rename or remove) + if (updates.name !== undefined) { + if (updates.name === null) { + this.remove(); + return; + } + this.rename(updates.name); + } + + // Extract special fields, update protocol fields in one go + const { name: _name, enabled, argsSchema, callback, ...protocolUpdates } = updates; + void _name; // Already handled above + Object.assign(this.#protocolFields, protocolUpdates); + + // Update argsSchema if provided + if (argsSchema !== undefined) { + this.#protocolFields.argsSchema = argsSchema; + } + + // Update SDK-specific fields + if (callback !== undefined) { + this.#callback = callback as PromptCallback; + } + + // Handle enabled (triggers its own notification) + if (enabled === undefined) { + this.#onUpdate(); + } else if (enabled) { + this.enable(); + } else { + this.disable(); + } + } + + /** + * Executes the prompt handler with the given arguments and context. + * Handles schema validation when argsSchema is defined. + */ + public async handler(args: Record | undefined, ctx: ServerContext): Promise { + if (this.#protocolFields.argsSchema) { + const typedCallback = this.#callback as ( + args: SchemaOutput, + ctx: ServerContext + ) => GetPromptResult | Promise; + const parseResult = await parseSchemaAsync(this.#protocolFields.argsSchema, args); + if (!parseResult.success) { + const errorMessage = parseResult.error.issues.map((i: { message: string }) => i.message).join(', '); + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Invalid arguments for prompt ${this.#protocolFields.name}: ${errorMessage}` + ); + } + return typedCallback(parseResult.data as SchemaOutput, ctx); + } + + const typedCallback = this.#callback as (ctx: ServerContext) => GetPromptResult | Promise; + return typedCallback(ctx); + } + + /** + * Converts to the Prompt protocol type (for list responses). + * Converts argsSchema to arguments array. + */ + toProtocolPrompt(): Prompt { + return { + ...this.#protocolFields, + // Convert argsSchema to arguments array + arguments: this.#protocolFields.argsSchema ? promptArgumentsFromSchema(this.#protocolFields.argsSchema) : undefined, + // Remove argsSchema from output (it's SDK-specific) + argsSchema: undefined + } as Prompt; + } +} diff --git a/packages/server/src/server/primitives/resource.ts b/packages/server/src/server/primitives/resource.ts new file mode 100644 index 000000000..700b9953b --- /dev/null +++ b/packages/server/src/server/primitives/resource.ts @@ -0,0 +1,200 @@ +import type { Icon, ReadResourceResult, Resource, ServerContext } from '@modelcontextprotocol/core'; + +import type { OnRemove, OnRename, OnUpdate } from './types.js'; + +/** + * Additional, optional information for annotating a resource. + */ +export type ResourceMetadata = Omit; + +/** + * Callback to read a resource at a given URI. + */ +export type ReadResourceCallback = (uri: URL, ctx: ServerContext) => ReadResourceResult | Promise; + +/** + * Protocol fields for Resource, derived from the Resource type. + */ +export type ResourceProtocolFields = Resource; + +/** + * Configuration for creating a RegisteredResource. + * Combines protocol fields with SDK-specific callback. + */ +export type ResourceConfig = ResourceProtocolFields & { + readCallback: ReadResourceCallback; +}; + +/** + * A registered resource in the MCP server. + * Provides methods to enable, disable, update, rename, and remove the resource. + */ +export class RegisteredResource { + // Protocol fields - stored together for easy spreading + #protocolFields: ResourceProtocolFields; + + // SDK-specific fields - separate from protocol + #readCallback: ReadResourceCallback; + #enabled: boolean = true; + + // Callbacks for McpServer communication + readonly #onUpdate: OnUpdate; + readonly #onRename: OnRename; + readonly #onRemove: OnRemove; + + constructor(config: ResourceConfig, onUpdate: OnUpdate, onRename: OnRename, onRemove: OnRemove) { + // Separate protocol fields from SDK fields + const { readCallback, ...protocolFields } = config; + this.#protocolFields = protocolFields; + this.#readCallback = readCallback; + + this.#onUpdate = onUpdate; + this.#onRename = onRename; + this.#onRemove = onRemove; + } + + // Protocol field getters (delegate to #protocolFields) + get name(): string { + return this.#protocolFields.name; + } + get title(): string | undefined { + return this.#protocolFields.title; + } + get uri(): string { + return this.#protocolFields.uri; + } + get description(): string | undefined { + return this.#protocolFields.description; + } + get mimeType(): string | undefined { + return this.#protocolFields.mimeType; + } + get icons(): Icon[] | undefined { + return this.#protocolFields.icons; + } + get annotations(): Resource['annotations'] | undefined { + return this.#protocolFields.annotations; + } + get _meta(): Record | undefined { + return this.#protocolFields._meta; + } + + /** + * Gets the resource metadata (all fields except uri and name). + */ + get metadata(): ResourceMetadata { + return { + title: this.#protocolFields.title, + description: this.#protocolFields.description, + mimeType: this.#protocolFields.mimeType, + icons: this.#protocolFields.icons, + annotations: this.#protocolFields.annotations, + _meta: this.#protocolFields._meta + }; + } + + // SDK-specific getters + get readCallback(): ReadResourceCallback { + return this.#readCallback; + } + get enabled(): boolean { + return this.#enabled; + } + + /** + * Enables the resource. + * @returns this for chaining + */ + public enable(): this { + if (!this.#enabled) { + this.#enabled = true; + this.#onUpdate(); + } + return this; + } + + /** + * Disables the resource. + * @returns this for chaining + */ + public disable(): this { + if (this.#enabled) { + this.#enabled = false; + this.#onUpdate(); + } + return this; + } + + /** + * Changes the resource's URI (which is also the registry key). + * @param newUri - The new URI for the resource + * @returns this for chaining + */ + public changeUri(newUri: string): this { + if (newUri !== this.#protocolFields.uri) { + const oldUri = this.#protocolFields.uri; + this.#protocolFields.uri = newUri; + this.#onRename(oldUri, newUri, this); + } + return this; + } + + /** + * Removes the resource from the registry. + */ + public remove(): void { + this.#onRemove(this.#protocolFields.uri); + } + + /** + * Updates the resource's properties. + * @param updates - The properties to update + */ + public update( + updates: Partial & { + enabled?: boolean; + uri?: string | null; + callback?: ReadResourceCallback; + } + ): void { + const { + uri: uriUpdate, + enabled: enabledUpdate, + readCallback: readCallbackUpdate, + callback: callbackUpdate, + ...protocolUpdates + } = updates; + + // Handle uri change (change key or remove) + if (uriUpdate !== undefined) { + if (uriUpdate === null) { + this.remove(); + return; + } + this.changeUri(uriUpdate); + } + + // Extract special fields, update protocol fields in one go + Object.assign(this.#protocolFields, protocolUpdates); + + // Update SDK-specific fields (support both readCallback and callback) + if (readCallbackUpdate !== undefined) this.#readCallback = readCallbackUpdate; + if (callbackUpdate !== undefined) this.#readCallback = callbackUpdate; + + // Handle enabled (triggers its own notification) + if (enabledUpdate === undefined) { + this.#onUpdate(); + } else if (enabledUpdate) { + this.enable(); + } else { + this.disable(); + } + } + + /** + * Converts to the Resource protocol type (for list responses). + */ + public toProtocolResource(): Resource { + return { ...this.#protocolFields }; + } +} diff --git a/packages/server/src/server/primitives/resourceTemplate.ts b/packages/server/src/server/primitives/resourceTemplate.ts new file mode 100644 index 000000000..dedc247dd --- /dev/null +++ b/packages/server/src/server/primitives/resourceTemplate.ts @@ -0,0 +1,291 @@ +import type { + Icon, + ListResourcesResult, + ReadResourceResult, + ResourceTemplateType, + ServerContext, + Variables +} from '@modelcontextprotocol/core'; +import { UriTemplate } from '@modelcontextprotocol/core'; + +import type { ResourceMetadata } from './resource.js'; +import type { OnRemove, OnRename, OnUpdate } from './types.js'; + +/** + * Callback to list all resources matching a given template. + */ +export type ListResourcesCallback = (ctx: ServerContext) => ListResourcesResult | Promise; + +/** + * Callback to read a resource at a given URI, following a filled-in URI template. + */ +export type ReadResourceTemplateCallback = ( + uri: URL, + variables: Variables, + ctx: ServerContext +) => ReadResourceResult | Promise; + +/** + * A callback to complete one variable within a resource template's URI template. + */ +export type CompleteResourceTemplateCallback = ( + value: string, + context?: { + arguments?: Record; + } +) => string[] | Promise; + +/** + * A resource template combines a URI pattern with optional functionality to enumerate + * all resources matching that pattern. + */ +export class ResourceTemplate { + #uriTemplate: UriTemplate; + + constructor( + uriTemplate: string | UriTemplate, + private _callbacks: { + /** + * A callback to list all resources matching this template. + * This is required to be specified, even if `undefined`, to avoid accidentally forgetting resource listing. + */ + list: ListResourcesCallback | undefined; + + /** + * An optional callback to autocomplete variables within the URI template. + * Useful for clients and users to discover possible values. + */ + complete?: { + [variable: string]: CompleteResourceTemplateCallback; + }; + } + ) { + this.#uriTemplate = typeof uriTemplate === 'string' ? new UriTemplate(uriTemplate) : uriTemplate; + } + + /** + * Gets the URI template pattern. + */ + get uriTemplate(): UriTemplate { + return this.#uriTemplate; + } + + /** + * Gets the list callback, if one was provided. + */ + get listCallback(): ListResourcesCallback | undefined { + return this._callbacks.list; + } + + /** + * Gets the callback for completing a specific URI template variable, if one was provided. + */ + completeCallback(variable: string): CompleteResourceTemplateCallback | undefined { + return this._callbacks.complete?.[variable]; + } +} + +/** + * Protocol fields for ResourceTemplate, derived from the ResourceTemplateType protocol type. + * Note: The SDK ResourceTemplate class is separate from the protocol type. + */ +export type ResourceTemplateProtocolFields = Omit & { + resourceTemplate: ResourceTemplate; +}; + +/** + * Configuration for creating a RegisteredResourceTemplate. + * Combines protocol fields with SDK-specific callback. + */ +export type ResourceTemplateConfig = ResourceTemplateProtocolFields & { + readCallback: ReadResourceTemplateCallback; +}; + +/** + * A registered resource template in the MCP server. + * Provides methods to enable, disable, update, rename, and remove the resource template. + */ +export class RegisteredResourceTemplate { + // Protocol fields - stored together for easy spreading + #protocolFields: ResourceTemplateProtocolFields; + + // SDK-specific fields - separate from protocol + #readCallback: ReadResourceTemplateCallback; + #enabled: boolean = true; + + // Callbacks for McpServer communication + readonly #onUpdate: OnUpdate; + readonly #onRename: OnRename; + readonly #onRemove: OnRemove; + + constructor(config: ResourceTemplateConfig, onUpdate: OnUpdate, onRename: OnRename, onRemove: OnRemove) { + // Separate protocol fields from SDK fields + const { readCallback, ...protocolFields } = config; + this.#protocolFields = protocolFields; + this.#readCallback = readCallback; + + this.#onUpdate = onUpdate; + this.#onRename = onRename; + this.#onRemove = onRemove; + } + + // Protocol field getters (delegate to #protocolFields) + get name(): string { + return this.#protocolFields.name; + } + get title(): string | undefined { + return this.#protocolFields.title; + } + get description(): string | undefined { + return this.#protocolFields.description; + } + get mimeType(): string | undefined { + return this.#protocolFields.mimeType; + } + get icons(): Icon[] | undefined { + return this.#protocolFields.icons; + } + get annotations(): ResourceTemplateType['annotations'] | undefined { + return this.#protocolFields.annotations; + } + get _meta(): Record | undefined { + return this.#protocolFields._meta; + } + get resourceTemplate(): ResourceTemplate { + return this.#protocolFields.resourceTemplate; + } + + /** + * Gets the resource metadata (all fields except name and resourceTemplate). + */ + get metadata(): ResourceMetadata { + return { + title: this.#protocolFields.title, + description: this.#protocolFields.description, + mimeType: this.#protocolFields.mimeType, + icons: this.#protocolFields.icons, + annotations: this.#protocolFields.annotations, + _meta: this.#protocolFields._meta + }; + } + + // SDK-specific getters + get readCallback(): ReadResourceTemplateCallback { + return this.#readCallback; + } + get enabled(): boolean { + return this.#enabled; + } + + /** + * Enables the resource template. + * @returns this for chaining + */ + public enable(): this { + if (!this.#enabled) { + this.#enabled = true; + this.#onUpdate(); + } + return this; + } + + /** + * Disables the resource template. + * @returns this for chaining + */ + public disable(): this { + if (this.#enabled) { + this.#enabled = false; + this.#onUpdate(); + } + return this; + } + + /** + * Renames the resource template. + * @param newName - The new name for the resource template + * @returns this for chaining + */ + public rename(newName: string): this { + if (newName !== this.#protocolFields.name) { + const oldName = this.#protocolFields.name; + this.#protocolFields.name = newName; + this.#onRename(oldName, newName, this); + } + return this; + } + + /** + * Removes the resource template from the registry. + */ + public remove(): void { + this.#onRemove(this.#protocolFields.name); + } + + /** + * Updates the resource template's properties. + * @param updates - The properties to update + */ + public update( + updates: Partial & { + enabled?: boolean; + name?: string | null; + template?: ResourceTemplate; + callback?: ReadResourceTemplateCallback; + } + ): void { + const { + name: nameUpdate, + enabled: enabledUpdate, + template: templateUpdate, + readCallback: readCallbackUpdate, + callback: callbackUpdate, + resourceTemplate: resourceTemplateUpdate, + ...protocolUpdates + } = updates; + + // Handle name change (rename or remove) + if (nameUpdate !== undefined) { + if (nameUpdate === null) { + this.remove(); + return; + } + this.rename(nameUpdate); + } + + // Extract special fields, update protocol fields in one go + Object.assign(this.#protocolFields, protocolUpdates); + + // Handle template specially (maps to resourceTemplate in protocol fields) + if (templateUpdate !== undefined) { + this.#protocolFields.resourceTemplate = templateUpdate; + } + if (resourceTemplateUpdate !== undefined) { + this.#protocolFields.resourceTemplate = resourceTemplateUpdate; + } + + // Update SDK-specific fields (support both readCallback and callback) + if (readCallbackUpdate !== undefined) this.#readCallback = readCallbackUpdate; + if (callbackUpdate !== undefined) this.#readCallback = callbackUpdate; + + // Handle enabled (triggers its own notification) + if (enabledUpdate === undefined) { + this.#onUpdate(); + } else if (enabledUpdate) { + this.enable(); + } else { + this.disable(); + } + } + + /** + * Converts to the ResourceTemplate protocol type (for list responses). + */ + public toProtocolResourceTemplate(): ResourceTemplateType { + const { resourceTemplate, ...rest } = this.#protocolFields; + return { + ...rest, + uriTemplate: resourceTemplate.uriTemplate.toString() + }; + } +} diff --git a/packages/server/src/server/primitives/tool.ts b/packages/server/src/server/primitives/tool.ts new file mode 100644 index 000000000..130c776ba --- /dev/null +++ b/packages/server/src/server/primitives/tool.ts @@ -0,0 +1,260 @@ +import type { + AnySchema, + CallToolResult, + CreateTaskResult, + CreateTaskServerContext, + Icon, + Result, + SchemaOutput, + ServerContext, + Tool, + ToolAnnotations, + ToolExecution +} from '@modelcontextprotocol/core'; +import { schemaToJson, validateAndWarnToolName } from '@modelcontextprotocol/core'; + +import type { ToolTaskHandler } from '../../experimental/tasks/interfaces.js'; +import type { OnRemove, OnRename, OnUpdate } from './types.js'; + +/** + * Base callback type for tool handlers. + */ +export type BaseToolCallback< + SendResultT extends Result, + Ctx extends ServerContext, + Args extends AnySchema | undefined +> = Args extends AnySchema + ? (args: SchemaOutput, ctx: Ctx) => SendResultT | Promise + : (ctx: Ctx) => SendResultT | Promise; + +/** + * Callback for a tool handler registered with McpServer.registerTool(). + * + * Parameters will include tool arguments, if applicable, as well as other request handler context. + * + * The callback should return: + * - `structuredContent` if the tool has an outputSchema defined + * - `content` if the tool does not have an outputSchema + * - Both fields are optional but typically one should be provided + */ +export type ToolCallback = BaseToolCallback; + +/** + * Supertype that can handle both regular tools (simple callback) and task-based tools (task handler object). + */ +export type AnyToolHandler = ToolCallback | ToolTaskHandler; + +/** + * Protocol fields for Tool, derived from the Tool type. + * Uses Zod schemas instead of JSON Schema (converted in toProtocolTool). + */ +export type ToolProtocolFields = Omit & { + inputSchema?: AnySchema; + outputSchema?: AnySchema; +}; + +/** + * Configuration for creating a RegisteredTool. + * Combines protocol fields with SDK-specific handler. + */ +export type ToolConfig = ToolProtocolFields & { + handler: AnyToolHandler; +}; + +const EMPTY_OBJECT_JSON_SCHEMA = { + type: 'object' as const, + properties: {} +}; + +/** + * A registered tool in the MCP server. + * Provides methods to enable, disable, update, rename, and remove the tool. + */ +export class RegisteredTool { + // Protocol fields - stored together for easy spreading + #protocolFields: ToolProtocolFields; + + // SDK-specific fields - separate from protocol + #handler: AnyToolHandler; + #enabled: boolean = true; + + // Callbacks for McpServer communication + readonly #onUpdate: OnUpdate; + readonly #onRename: OnRename; + readonly #onRemove: OnRemove; + + constructor(config: ToolConfig, onUpdate: OnUpdate, onRename: OnRename, onRemove: OnRemove) { + validateAndWarnToolName(config.name); + + // Separate protocol fields from SDK fields + const { handler, ...protocolFields } = config; + this.#protocolFields = protocolFields; + this.#handler = handler; + + this.#onUpdate = onUpdate; + this.#onRename = onRename; + this.#onRemove = onRemove; + } + + // Protocol field getters (delegate to #protocolFields) + get name(): string { + return this.#protocolFields.name; + } + get title(): string | undefined { + return this.#protocolFields.title; + } + get description(): string | undefined { + return this.#protocolFields.description; + } + get icons(): Icon[] | undefined { + return this.#protocolFields.icons; + } + get inputSchema(): AnySchema | undefined { + return this.#protocolFields.inputSchema; + } + get outputSchema(): AnySchema | undefined { + return this.#protocolFields.outputSchema; + } + get annotations(): ToolAnnotations | undefined { + return this.#protocolFields.annotations; + } + get execution(): ToolExecution | undefined { + return this.#protocolFields.execution; + } + get _meta(): Record | undefined { + return this.#protocolFields._meta; + } + + // SDK-specific getters + get handler(): AnyToolHandler { + return this.#handler; + } + get enabled(): boolean { + return this.#enabled; + } + + /** + * Enables the tool. + * @returns this for chaining + */ + public enable(): this { + if (!this.#enabled) { + this.#enabled = true; + this.#onUpdate(); + } + return this; + } + + /** + * Disables the tool. + * @returns this for chaining + */ + public disable(): this { + if (this.#enabled) { + this.#enabled = false; + this.#onUpdate(); + } + return this; + } + + /** + * Renames the tool. + * @param newName - The new name for the tool + * @returns this for chaining + */ + public rename(newName: string): this { + if (newName !== this.#protocolFields.name) { + validateAndWarnToolName(newName); + const oldName = this.#protocolFields.name; + this.#protocolFields.name = newName; + this.#onRename(oldName, newName, this); + } + return this; + } + + /** + * Removes the tool from the registry. + */ + public remove(): void { + this.#onRemove(this.#protocolFields.name); + } + + /** + * Updates the tool's properties. + * @param updates - The properties to update + */ + public update(updates: Partial & { enabled?: boolean; name?: string | null }): void { + const { name: nameUpdate, enabled: enabledUpdate, handler: handlerUpdate, ...protocolUpdates } = updates; + // Handle name change (rename or remove) + if (nameUpdate !== undefined) { + if (nameUpdate === null) { + this.remove(); + return; + } + this.rename(nameUpdate); + } + + // Extract special fields, update protocol fields in one go + Object.assign(this.#protocolFields, protocolUpdates); + + // Update SDK-specific fields + if (handlerUpdate !== undefined) this.#handler = handlerUpdate; + + // Handle enabled (triggers its own notification) + if (enabledUpdate === undefined) { + this.#onUpdate(); + } else if (enabledUpdate) { + this.enable(); + } else { + this.disable(); + } + } + + /** + * Executes the tool handler with the given arguments and context. + * Handles both regular callbacks and task-based handlers. + */ + public async executor(args: unknown, ctx: ServerContext): Promise { + const handler = this.#handler; + const isTaskHandler = typeof handler === 'object' && handler !== null && 'createTask' in handler; + + if (isTaskHandler) { + type TaskCreateFn = (args: unknown, ctx: CreateTaskServerContext) => CreateTaskResult | Promise; + type TaskCreateNoArgsFn = (ctx: CreateTaskServerContext) => CreateTaskResult | Promise; + const taskHandler = handler as { createTask: TaskCreateFn | TaskCreateNoArgsFn }; + if (!ctx.task?.store) { + throw new Error('No task store provided.'); + } + const taskCtx: CreateTaskServerContext = { ...ctx, task: { store: ctx.task.store, requestedTtl: ctx.task?.requestedTtl } }; + if (this.#protocolFields.inputSchema) { + return (taskHandler.createTask as TaskCreateFn)(args, taskCtx); + } + return (taskHandler.createTask as TaskCreateNoArgsFn)(taskCtx); + } + + if (this.#protocolFields.inputSchema) { + const callback = handler as (args: unknown, ctx: ServerContext) => CallToolResult | Promise; + return callback(args, ctx); + } + + const callback = handler as (ctx: ServerContext) => CallToolResult | Promise; + return callback(ctx); + } + + /** + * Converts to the Tool protocol type (for list responses). + * Converts Zod schemas to JSON Schema format. + */ + public toProtocolTool(): Tool { + return { + ...this.#protocolFields, + // Override schemas with JSON Schema conversion + inputSchema: this.#protocolFields.inputSchema + ? (schemaToJson(this.#protocolFields.inputSchema, { io: 'input' }) as Tool['inputSchema']) + : EMPTY_OBJECT_JSON_SCHEMA, + outputSchema: this.#protocolFields.outputSchema + ? (schemaToJson(this.#protocolFields.outputSchema, { io: 'output' }) as Tool['outputSchema']) + : undefined + }; + } +} diff --git a/packages/server/src/server/primitives/types.ts b/packages/server/src/server/primitives/types.ts new file mode 100644 index 000000000..a60da774b --- /dev/null +++ b/packages/server/src/server/primitives/types.ts @@ -0,0 +1,23 @@ +/** + * Shared callback types for registered primitives (tools, prompts, resources). + * These callbacks are passed to class constructors for McpServer communication. + */ + +/** + * Callback invoked when a registered item is updated (properties changed, enabled/disabled). + */ +export type OnUpdate = () => void; + +/** + * Callback invoked when a registered item is renamed. + * @param oldName - The previous name + * @param newName - The new name + * @param item - The item being renamed + */ +export type OnRename = (oldName: string, newName: string, item: T) => void; + +/** + * Callback invoked when a registered item is removed. + * @param name - The name of the item being removed + */ +export type OnRemove = (name: string) => void; diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 416f05102..c89f6bdd7 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -487,7 +487,7 @@ describe('Zod v4', () => { // Update the tool tool.update({ - callback: async () => ({ + handler: async () => ({ content: [ { type: 'text', @@ -557,11 +557,11 @@ describe('Zod v4', () => { // Update the tool with a different schema tool.update({ - paramsSchema: z.object({ + inputSchema: z.object({ name: z.string(), value: z.number() }), - callback: async ({ name, value }) => ({ + handler: async ({ name, value }) => ({ content: [ { type: 'text', @@ -649,7 +649,7 @@ describe('Zod v4', () => { result: z.number(), sum: z.number() }), - callback: async () => ({ + handler: async () => ({ content: [{ type: 'text', text: '' }], structuredContent: { result: 42, @@ -728,7 +728,7 @@ describe('Zod v4', () => { // Now update the tool tool.update({ - callback: async () => ({ + handler: async () => ({ content: [ { type: 'text',