diff --git a/README.md b/README.md index d13378b5..f9f080b9 100644 --- a/README.md +++ b/README.md @@ -226,14 +226,36 @@ All resources, tools, and prompts support an optional `title` field for better U **Note:** The `register*` methods (`registerTool`, `registerPrompt`, `registerResource`) are the recommended approach for new code. The older methods (`tool`, `prompt`, `resource`) remain available for backwards compatibility. +#### Title Precedence for Tools + +For tools specifically, there are two ways to specify a title: +- `title` field in the tool configuration +- `annotations.title` field (when using the older `tool()` method with annotations) + +The precedence order is: `title` → `annotations.title` → `name` + +```typescript +// Using registerTool (recommended) +server.registerTool("my_tool", { + title: "My Tool", // This title takes precedence + annotations: { + title: "Annotation Title" // This is ignored if title is set + } +}, handler); + +// Using tool with annotations (older API) +server.tool("my_tool", "description", { + title: "Annotation Title" // This is used as title +}, handler); +``` When building clients, use the provided utility to get the appropriate display name: ```typescript import { getDisplayName } from "@modelcontextprotocol/sdk/shared/metadataUtils.js"; -// Falls back to 'name' if 'title' is not provided -const displayName = getDisplayName(tool); // Returns title if available, otherwise name +// Automatically handles the precedence: title → annotations.title → name +const displayName = getDisplayName(tool); ``` ## Running Your Server diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 63d7d60a..efa176df 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -318,7 +318,12 @@ async function listTools(): Promise { console.log(' No tools available'); } else { for (const tool of toolsResult.tools) { - console.log(` - ${getDisplayName(tool)}: ${tool.description}`); + const displayName = getDisplayName(tool); + if (displayName !== tool.name) { + console.log(` - ${tool.name} (${displayName}): ${tool.description}`); + } else { + console.log(` - ${tool.name}: ${tool.description}`); + } } } } catch (error) { diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 6ef33540..36a8f7b8 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -18,6 +18,7 @@ import { import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; +import { getDisplayName } from "../shared/metadataUtils.js"; describe("McpServer", () => { /*** @@ -3598,3 +3599,140 @@ describe("prompt()", () => { expect(result.resources[0].mimeType).toBe("text/markdown"); }); }); + +describe("Tool title precedence", () => { + test("should follow correct title precedence: title → annotations.title → name", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + // Tool 1: Only name + mcpServer.tool( + "tool_name_only", + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + // Tool 2: Name and annotations.title + mcpServer.tool( + "tool_with_annotations_title", + "Tool with annotations title", + { + title: "Annotations Title" + }, + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + // Tool 3: Name and title (using registerTool) + mcpServer.registerTool( + "tool_with_title", + { + title: "Regular Title", + description: "Tool with regular title" + }, + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + // Tool 4: All three - title should win + mcpServer.registerTool( + "tool_with_all_titles", + { + title: "Regular Title Wins", + description: "Tool with all titles", + annotations: { + title: "Annotations Title Should Not Show" + } + }, + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + const result = await client.request( + { method: "tools/list" }, + ListToolsResultSchema, + ); + + + expect(result.tools).toHaveLength(4); + + // Tool 1: Only name - should display name + const tool1 = result.tools.find(t => t.name === "tool_name_only"); + expect(tool1).toBeDefined(); + expect(getDisplayName(tool1!)).toBe("tool_name_only"); + + // Tool 2: Name and annotations.title - should display annotations.title + const tool2 = result.tools.find(t => t.name === "tool_with_annotations_title"); + expect(tool2).toBeDefined(); + expect(tool2!.annotations?.title).toBe("Annotations Title"); + expect(getDisplayName(tool2!)).toBe("Annotations Title"); + + // Tool 3: Name and title - should display title + const tool3 = result.tools.find(t => t.name === "tool_with_title"); + expect(tool3).toBeDefined(); + expect(tool3!.title).toBe("Regular Title"); + expect(getDisplayName(tool3!)).toBe("Regular Title"); + + // Tool 4: All three - title should take precedence + const tool4 = result.tools.find(t => t.name === "tool_with_all_titles"); + expect(tool4).toBeDefined(); + expect(tool4!.title).toBe("Regular Title Wins"); + expect(tool4!.annotations?.title).toBe("Annotations Title Should Not Show"); + expect(getDisplayName(tool4!)).toBe("Regular Title Wins"); + }); + + test("getDisplayName unit tests for title precedence", () => { + + // Test 1: Only name + expect(getDisplayName({ name: "tool_name" })).toBe("tool_name"); + + // Test 2: Name and title - title wins + expect(getDisplayName({ + name: "tool_name", + title: "Tool Title" + })).toBe("Tool Title"); + + // Test 3: Name and annotations.title - annotations.title wins + expect(getDisplayName({ + name: "tool_name", + annotations: { title: "Annotations Title" } + })).toBe("Annotations Title"); + + // Test 4: All three - title wins (correct precedence) + expect(getDisplayName({ + name: "tool_name", + title: "Regular Title", + annotations: { title: "Annotations Title" } + })).toBe("Regular Title"); + + // Test 5: Empty title should not be used + expect(getDisplayName({ + name: "tool_name", + title: "", + annotations: { title: "Annotations Title" } + })).toBe("Annotations Title"); + + // Test 6: Undefined vs null handling + expect(getDisplayName({ + name: "tool_name", + title: undefined, + annotations: { title: "Annotations Title" } + })).toBe("Annotations Title"); + }); +}); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7c77add1..3c1c00d9 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -22,6 +22,7 @@ import { CompleteResult, PromptReference, ResourceTemplateReference, + BaseMetadata, Resource, ListResourcesResult, ListResourceTemplatesRequestSchema, @@ -128,7 +129,7 @@ export class McpServer { if (tool.outputSchema) { toolDefinition.outputSchema = zodToJsonSchema( - tool.outputSchema, + tool.outputSchema, { strictUnions: true } ) as Tool["outputSchema"]; } @@ -586,27 +587,13 @@ export class McpServer { throw new Error(`Resource ${uriOrTemplate} is already registered`); } - const registeredResource: RegisteredResource = { + const registeredResource = this._createRegisteredResource( name, + undefined, + uriOrTemplate, metadata, - readCallback: readCallback as ReadResourceCallback, - enabled: true, - disable: () => registeredResource.update({ enabled: false }), - enable: () => registeredResource.update({ enabled: true }), - remove: () => registeredResource.update({ uri: null }), - update: (updates) => { - if (typeof updates.uri !== "undefined" && updates.uri !== uriOrTemplate) { - delete this._registeredResources[uriOrTemplate] - if (updates.uri) this._registeredResources[updates.uri] = registeredResource - } - if (typeof updates.name !== "undefined") registeredResource.name = updates.name - if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResources[uriOrTemplate] = registeredResource; + readCallback as ReadResourceCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -616,27 +603,13 @@ export class McpServer { throw new Error(`Resource template ${name} is already registered`); } - const registeredResourceTemplate: RegisteredResourceTemplate = { - resourceTemplate: uriOrTemplate, + const registeredResourceTemplate = this._createRegisteredResourceTemplate( + name, + undefined, + uriOrTemplate, metadata, - readCallback: readCallback as ReadResourceTemplateCallback, - enabled: true, - disable: () => registeredResourceTemplate.update({ enabled: false }), - enable: () => registeredResourceTemplate.update({ enabled: true }), - remove: () => registeredResourceTemplate.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredResourceTemplates[name] - if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate - } - if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template - if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResourceTemplates[name] = registeredResourceTemplate; + readCallback as ReadResourceTemplateCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -651,11 +624,7 @@ export class McpServer { registerResource( name: string, uriOrTemplate: string | ResourceTemplate, - config: ResourceMetadata & { - title?: string; - description?: string; - mimeType?: string; - }, + config: ResourceMetadata, readCallback: ReadResourceCallback | ReadResourceTemplateCallback ): RegisteredResource | RegisteredResourceTemplate { if (typeof uriOrTemplate === "string") { @@ -663,27 +632,13 @@ export class McpServer { throw new Error(`Resource ${uriOrTemplate} is already registered`); } - const registeredResource: RegisteredResource = { + const registeredResource = this._createRegisteredResource( name, - metadata: config, - readCallback: readCallback as ReadResourceCallback, - enabled: true, - disable: () => registeredResource.update({ enabled: false }), - enable: () => registeredResource.update({ enabled: true }), - remove: () => registeredResource.update({ uri: null }), - update: (updates) => { - if (typeof updates.uri !== "undefined" && updates.uri !== uriOrTemplate) { - delete this._registeredResources[uriOrTemplate] - if (updates.uri) this._registeredResources[updates.uri] = registeredResource - } - if (typeof updates.name !== "undefined") registeredResource.name = updates.name - if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResources[uriOrTemplate] = registeredResource; + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -693,27 +648,13 @@ export class McpServer { throw new Error(`Resource template ${name} is already registered`); } - const registeredResourceTemplate: RegisteredResourceTemplate = { - resourceTemplate: uriOrTemplate, - metadata: config, - readCallback: readCallback as ReadResourceTemplateCallback, - enabled: true, - disable: () => registeredResourceTemplate.update({ enabled: false }), - enable: () => registeredResourceTemplate.update({ enabled: true }), - remove: () => registeredResourceTemplate.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredResourceTemplates[name] - if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate - } - if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template - if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResourceTemplates[name] = registeredResourceTemplate; + const registeredResourceTemplate = this._createRegisteredResourceTemplate( + name, + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceTemplateCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -721,8 +662,108 @@ export class McpServer { } } + private _createRegisteredResource( + name: string, + title: string | undefined, + uri: string, + 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 (typeof updates.uri !== "undefined" && updates.uri !== uri) { + delete this._registeredResources[uri] + if (updates.uri) this._registeredResources[updates.uri] = registeredResource + } + if (typeof updates.name !== "undefined") registeredResource.name = updates.name + if (typeof updates.title !== "undefined") registeredResource.title = updates.title + if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled + this.sendResourceListChanged() + }, + }; + this._registeredResources[uri] = registeredResource; + return registeredResource; + } + + private _createRegisteredResourceTemplate( + name: string, + title: string | undefined, + template: ResourceTemplate, + 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 (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredResourceTemplates[name] + if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate + } + if (typeof updates.title !== "undefined") registeredResourceTemplate.title = updates.title + if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template + if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled + this.sendResourceListChanged() + }, + }; + this._registeredResourceTemplates[name] = registeredResourceTemplate; + return registeredResourceTemplate; + } + + private _createRegisteredPrompt( + name: string, + title: string | undefined, + description: string | undefined, + argsSchema: PromptArgsRawShape | undefined, + callback: PromptCallback + ): RegisteredPrompt { + const registeredPrompt: RegisteredPrompt = { + title, + description, + argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), + callback, + enabled: true, + disable: () => registeredPrompt.update({ enabled: false }), + enable: () => registeredPrompt.update({ enabled: true }), + remove: () => registeredPrompt.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredPrompts[name] + if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt + } + if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title + if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description + if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) + if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback + if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled + this.sendPromptListChanged() + }, + }; + this._registeredPrompts[name] = registeredPrompt; + return registeredPrompt; + } + private _createRegisteredTool( name: string, + title: string | undefined, description: string | undefined, inputSchema: ZodRawShape | undefined, outputSchema: ZodRawShape | undefined, @@ -730,6 +771,7 @@ export class McpServer { callback: ToolCallback ): RegisteredTool { const registeredTool: RegisteredTool = { + title, description, inputSchema: inputSchema === undefined ? undefined : z.object(inputSchema), @@ -868,7 +910,7 @@ export class McpServer { } const callback = rest[0] as ToolCallback; - return this._createRegisteredTool(name, description, inputSchema, outputSchema, annotations, callback) + return this._createRegisteredTool(name, undefined, description, inputSchema, outputSchema, annotations, callback) } /** @@ -891,21 +933,15 @@ export class McpServer { const { title, description, inputSchema, outputSchema, annotations } = config; - const registeredTool = this._createRegisteredTool( + return this._createRegisteredTool( name, + title, description, inputSchema, outputSchema, annotations, cb as ToolCallback ); - - // Set title if provided - if (title !== undefined) { - registeredTool.title = title; - } - - return registeredTool; } /** @@ -953,28 +989,13 @@ export class McpServer { } const cb = rest[0] as PromptCallback; - const registeredPrompt: RegisteredPrompt = { + const registeredPrompt = this._createRegisteredPrompt( + name, + undefined, description, - argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), - callback: cb, - enabled: true, - disable: () => registeredPrompt.update({ enabled: false }), - enable: () => registeredPrompt.update({ enabled: true }), - remove: () => registeredPrompt.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredPrompts[name] - if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt - } - if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title - if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description - if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) - if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback - if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled - this.sendPromptListChanged() - }, - }; - this._registeredPrompts[name] = registeredPrompt; + argsSchema, + cb + ); this.setPromptRequestHandlers(); this.sendPromptListChanged() @@ -1000,29 +1021,13 @@ export class McpServer { const { title, description, arguments: argsSchema } = config; - const registeredPrompt: RegisteredPrompt = { + const registeredPrompt = this._createRegisteredPrompt( + name, title, description, - argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), - callback: cb as PromptCallback, - enabled: true, - disable: () => registeredPrompt.update({ enabled: false }), - enable: () => registeredPrompt.update({ enabled: true }), - remove: () => registeredPrompt.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredPrompts[name] - if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt - } - if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title - if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description - if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) - if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback - if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled - this.sendPromptListChanged() - }, - }; - this._registeredPrompts[name] = registeredPrompt; + argsSchema, + cb as PromptCallback + ); this.setPromptRequestHandlers(); this.sendPromptListChanged() @@ -1155,16 +1160,16 @@ export type RegisteredTool = { enable(): void; disable(): void; update( - updates: { - name?: string | null, + updates: { + name?: string | null, title?: string, - description?: string, - paramsSchema?: InputArgs, - outputSchema?: OutputArgs, - annotations?: ToolAnnotations, - callback?: ToolCallback, - enabled?: boolean - }): void + description?: string, + paramsSchema?: InputArgs, + outputSchema?: OutputArgs, + annotations?: ToolAnnotations, + callback?: ToolCallback, + enabled?: boolean + }): void remove(): void }; @@ -1212,12 +1217,13 @@ export type ReadResourceCallback = ( export type RegisteredResource = { name: string; + title?: string; metadata?: ResourceMetadata; readCallback: ReadResourceCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string, uri?: string | null, metadata?: ResourceMetadata, callback?: ReadResourceCallback, enabled?: boolean }): void + update(updates: { name?: string, title?: string, uri?: string | null, metadata?: ResourceMetadata, callback?: ReadResourceCallback, enabled?: boolean }): void remove(): void }; @@ -1232,12 +1238,13 @@ export type ReadResourceTemplateCallback = ( export type RegisteredResourceTemplate = { resourceTemplate: ResourceTemplate; + title?: string; metadata?: ResourceMetadata; readCallback: ReadResourceTemplateCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void + update(updates: { name?: string | null, title?: string, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void remove(): void }; diff --git a/src/shared/metadataUtils.ts b/src/shared/metadataUtils.ts index d581a6b2..410827a5 100644 --- a/src/shared/metadataUtils.ts +++ b/src/shared/metadataUtils.ts @@ -6,11 +6,26 @@ import { BaseMetadata } from "../types.js"; /** * Gets the display name for an object with BaseMetadata. - * Returns the title if available, otherwise falls back to name. + * For tools, the precedence is: title → annotations.title → name + * For other objects: title → name * This implements the spec requirement: "if no title is provided, name should be used for display purposes" */ export function getDisplayName(metadata: BaseMetadata): string { - return metadata.title ?? metadata.name; + // First check for title (not undefined and not empty string) + if (metadata.title !== undefined && metadata.title !== '') { + return metadata.title; + } + + // Then check for annotations.title (only present in Tool objects) + if ('annotations' in metadata) { + const metadataWithAnnotations = metadata as BaseMetadata & { annotations?: { title?: string } }; + if (metadataWithAnnotations.annotations?.title) { + return metadataWithAnnotations.annotations.title; + } + } + + // Finally fall back to name + return metadata.name; } /** diff --git a/src/types.ts b/src/types.ts index 9ece70d4..4d1d14d2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -203,7 +203,14 @@ export const BaseMetadataSchema = z .object({ /** Intended for programmatic or logical use, but used as a display name in past specs or fallback */ name: z.string(), - /** Intended for UI and end-user contexts — optimized to be human-readable */ + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. + * + * If not provided, the name should be used for display (except for Tool, + * where `annotations.title` should be given precedence over using `name`, + * if present). + */ title: z.optional(z.string()), }) .passthrough();