This commit is contained in:
ihrpr 2025-06-16 10:43:31 +01:00
commit d1c94a2c69
No known key found for this signature in database
6 changed files with 348 additions and 154 deletions

View file

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

View file

@ -318,7 +318,12 @@ async function listTools(): Promise<void> {
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) {

View file

@ -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");
});
});

View file

@ -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<PromptArgsRawShape | undefined>
): 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<ZodRawShape | undefined>
): 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<ZodRawShape | undefined>;
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<ZodRawShape | undefined>
);
// 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<PromptArgsRawShape | undefined>;
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<PromptArgsRawShape | undefined>,
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<PromptArgsRawShape | undefined>
);
this.setPromptRequestHandlers();
this.sendPromptListChanged()
@ -1155,16 +1160,16 @@ export type RegisteredTool = {
enable(): void;
disable(): void;
update<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape>(
updates: {
name?: string | null,
updates: {
name?: string | null,
title?: string,
description?: string,
paramsSchema?: InputArgs,
outputSchema?: OutputArgs,
annotations?: ToolAnnotations,
callback?: ToolCallback<InputArgs>,
enabled?: boolean
}): void
description?: string,
paramsSchema?: InputArgs,
outputSchema?: OutputArgs,
annotations?: ToolAnnotations,
callback?: ToolCallback<InputArgs>,
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
};

View file

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

View file

@ -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();