Merge branch 'main' into request-propagation-in-tools

This commit is contained in:
Konstantin Konstantinov 2025-06-16 18:51:44 +03:00 • committed by GitHub
commit 71abccb874
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1837 additions and 249 deletions

View file

@ -15,6 +15,9 @@ export enum McpZodTypeKind {
export type CompleteCallback<T extends ZodTypeAny = ZodTypeAny> = (
value: T["_input"],
context?: {
arguments?: Record<string, string>;
},
) => T["_input"][] | Promise<T["_input"][]>;
export interface CompletableDef<T extends ZodTypeAny = ZodTypeAny>

View file

@ -19,6 +19,7 @@ import { ResourceTemplate } from "./mcp.js";
import { completable } from "./completable.js";
import { UriTemplate } from "../shared/uriTemplate.js";
import { RequestInfo } from "./types/types.js";
import { getDisplayName } from "../shared/metadataUtils.js";
const mockRequestInfo: RequestInfo = {
headers: {
@ -3529,12 +3530,12 @@ describe("prompt()", () => {
);
expect(result.resources).toHaveLength(2);
// Resource 1 should have its own metadata
expect(result.resources[0].name).toBe("Resource 1");
expect(result.resources[0].description).toBe("Individual resource description");
expect(result.resources[0].mimeType).toBe("text/plain");
// Resource 2 should inherit template metadata
expect(result.resources[1].name).toBe("Resource 2");
expect(result.resources[1].description).toBe("Template description");
@ -3600,10 +3601,419 @@ describe("prompt()", () => {
);
expect(result.resources).toHaveLength(1);
// All fields should be from the individual resource, not the template
expect(result.resources[0].name).toBe("Overridden Name");
expect(result.resources[0].description).toBe("Overridden description");
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");
});
test("should support resource template completion with resolved context", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.registerResource(
"test",
new ResourceTemplate("github://repos/{owner}/{repo}", {
list: undefined,
complete: {
repo: (value, context) => {
if (context?.arguments?.["owner"] === "org1") {
return ["project1", "project2", "project3"].filter(r => r.startsWith(value));
} else if (context?.arguments?.["owner"] === "org2") {
return ["repo1", "repo2", "repo3"].filter(r => r.startsWith(value));
}
return [];
},
},
}),
{
title: "GitHub Repository",
description: "Repository information"
},
async () => ({
contents: [
{
uri: "github://repos/test/test",
text: "Test content",
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
// Test with microsoft owner
const result1 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/resource",
uri: "github://repos/{owner}/{repo}",
},
argument: {
name: "repo",
value: "p",
},
context: {
arguments: {
owner: "org1",
},
},
},
},
CompleteResultSchema,
);
expect(result1.completion.values).toEqual(["project1", "project2", "project3"]);
expect(result1.completion.total).toBe(3);
// Test with facebook owner
const result2 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/resource",
uri: "github://repos/{owner}/{repo}",
},
argument: {
name: "repo",
value: "r",
},
context: {
arguments: {
owner: "org2",
},
},
},
},
CompleteResultSchema,
);
expect(result2.completion.values).toEqual(["repo1", "repo2", "repo3"]);
expect(result2.completion.total).toBe(3);
// Test with no resolved context
const result3 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/resource",
uri: "github://repos/{owner}/{repo}",
},
argument: {
name: "repo",
value: "t",
},
},
},
CompleteResultSchema,
);
expect(result3.completion.values).toEqual([]);
expect(result3.completion.total).toBe(0);
});
test("should support prompt argument completion with resolved context", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.registerPrompt(
"test-prompt",
{
title: "Team Greeting",
description: "Generate a greeting for team members",
argsSchema: {
department: completable(z.string(), (value) => {
return ["engineering", "sales", "marketing", "support"].filter(d => d.startsWith(value));
}),
name: completable(z.string(), (value, context) => {
const department = context?.arguments?.["department"];
if (department === "engineering") {
return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value));
} else if (department === "sales") {
return ["David", "Eve", "Frank"].filter(n => n.startsWith(value));
} else if (department === "marketing") {
return ["Grace", "Henry", "Iris"].filter(n => n.startsWith(value));
}
return ["Guest"].filter(n => n.startsWith(value));
}),
}
},
async ({ department, name }) => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: `Hello ${name}, welcome to the ${department} team!`,
},
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
// Test with engineering department
const result1 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "A",
},
context: {
arguments: {
department: "engineering",
},
},
},
},
CompleteResultSchema,
);
expect(result1.completion.values).toEqual(["Alice"]);
// Test with sales department
const result2 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "D",
},
context: {
arguments: {
department: "sales",
},
},
},
},
CompleteResultSchema,
);
expect(result2.completion.values).toEqual(["David"]);
// Test with marketing department
const result3 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "G",
},
context: {
arguments: {
department: "marketing",
},
},
},
},
CompleteResultSchema,
);
expect(result3.completion.values).toEqual(["Grace"]);
// Test with no resolved context
const result4 = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "G",
},
},
},
CompleteResultSchema,
);
expect(result4.completion.values).toEqual(["Guest"]);
});
});

View file

@ -21,7 +21,8 @@ import {
CompleteRequest,
CompleteResult,
PromptReference,
ResourceReference,
ResourceTemplateReference,
BaseMetadata,
Resource,
ListResourcesResult,
ListResourceTemplatesRequestSchema,
@ -113,6 +114,7 @@ export class McpServer {
([name, tool]): Tool => {
const toolDefinition: Tool = {
name,
title: tool.title,
description: tool.description,
inputSchema: tool.inputSchema
? (zodToJsonSchema(tool.inputSchema, {
@ -124,7 +126,7 @@ export class McpServer {
if (tool.outputSchema) {
toolDefinition.outputSchema = zodToJsonSchema(
tool.outputSchema,
tool.outputSchema,
{ strictUnions: true }
) as Tool["outputSchema"];
}
@ -291,13 +293,13 @@ export class McpServer {
}
const def: CompletableDef<ZodString> = field._def;
const suggestions = await def.complete(request.params.argument.value);
const suggestions = await def.complete(request.params.argument.value, request.params.context);
return createCompletionResult(suggestions);
}
private async handleResourceCompletion(
request: CompleteRequest,
ref: ResourceReference,
ref: ResourceTemplateReference,
): Promise<CompleteResult> {
const template = Object.values(this._registeredResourceTemplates).find(
(t) => t.resourceTemplate.uriTemplate.toString() === ref.uri,
@ -322,7 +324,7 @@ export class McpServer {
return EMPTY_COMPLETION_RESULT;
}
const suggestions = await completer(request.params.argument.value);
const suggestions = await completer(request.params.argument.value, request.params.context);
return createCompletionResult(suggestions);
}
@ -469,6 +471,7 @@ export class McpServer {
([name, prompt]): Prompt => {
return {
name,
title: prompt.title,
description: prompt.description,
arguments: prompt.argsSchema
? promptArgumentsFromSchema(prompt.argsSchema)
@ -576,27 +579,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();
@ -606,27 +595,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();
@ -634,8 +609,153 @@ export class McpServer {
}
}
/**
* Registers a resource with a config object and callback.
* For static resources, use a URI string. For dynamic resources, use a ResourceTemplate.
*/
registerResource(
name: string,
uriOrTemplate: string | ResourceTemplate,
config: ResourceMetadata,
readCallback: ReadResourceCallback | ReadResourceTemplateCallback
): RegisteredResource | RegisteredResourceTemplate {
if (typeof uriOrTemplate === "string") {
if (this._registeredResources[uriOrTemplate]) {
throw new Error(`Resource ${uriOrTemplate} is already registered`);
}
const registeredResource = this._createRegisteredResource(
name,
(config as BaseMetadata).title,
uriOrTemplate,
config,
readCallback as ReadResourceCallback
);
this.setResourceRequestHandlers();
this.sendResourceListChanged();
return registeredResource;
} else {
if (this._registeredResourceTemplates[name]) {
throw new Error(`Resource template ${name} is already registered`);
}
const registeredResourceTemplate = this._createRegisteredResourceTemplate(
name,
(config as BaseMetadata).title,
uriOrTemplate,
config,
readCallback as ReadResourceTemplateCallback
);
this.setResourceRequestHandlers();
this.sendResourceListChanged();
return registeredResourceTemplate;
}
}
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,
@ -643,6 +763,7 @@ export class McpServer {
callback: ToolCallback<ZodRawShape | undefined>
): RegisteredTool {
const registeredTool: RegisteredTool = {
title,
description,
inputSchema:
inputSchema === undefined ? undefined : z.object(inputSchema),
@ -659,6 +780,7 @@ export class McpServer {
delete this._registeredTools[name]
if (updates.name) this._registeredTools[updates.name] = registeredTool
}
if (typeof updates.title !== "undefined") registeredTool.title = updates.title
if (typeof updates.description !== "undefined") registeredTool.description = updates.description
if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema)
if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback
@ -780,7 +902,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)
}
/**
@ -789,6 +911,7 @@ export class McpServer {
registerTool<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape>(
name: string,
config: {
title?: string;
description?: string;
inputSchema?: InputArgs;
outputSchema?: OutputArgs;
@ -800,16 +923,17 @@ export class McpServer {
throw new Error(`Tool ${name} is already registered`);
}
const { description, inputSchema, outputSchema, annotations } = config;
const { title, description, inputSchema, outputSchema, annotations } = config;
return this._createRegisteredTool(
name,
title,
description,
inputSchema,
outputSchema,
annotations,
cb as ToolCallback<ZodRawShape | undefined>
)
);
}
/**
@ -857,27 +981,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.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()
@ -885,6 +995,38 @@ export class McpServer {
return registeredPrompt
}
/**
* Registers a prompt with a config object and callback.
*/
registerPrompt<Args extends PromptArgsRawShape>(
name: string,
config: {
title?: string;
description?: string;
argsSchema?: Args;
},
cb: PromptCallback<Args>
): RegisteredPrompt {
if (this._registeredPrompts[name]) {
throw new Error(`Prompt ${name} is already registered`);
}
const { title, description, argsSchema } = config;
const registeredPrompt = this._createRegisteredPrompt(
name,
title,
description,
argsSchema,
cb as PromptCallback<PromptArgsRawShape | undefined>
);
this.setPromptRequestHandlers();
this.sendPromptListChanged()
return registeredPrompt;
}
/**
* Checks if the server is connected to a transport.
* @returns True if the server is connected
@ -926,6 +1068,9 @@ export class McpServer {
*/
export type CompleteResourceTemplateCallback = (
value: string,
context?: {
arguments?: Record<string, string>;
},
) => string[] | Promise<string[]>;
/**
@ -1000,6 +1145,7 @@ export type ToolCallback<Args extends undefined | ZodRawShape = undefined> =
: (extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => CallToolResult | Promise<CallToolResult>;
export type RegisteredTool = {
title?: string;
description?: string;
inputSchema?: AnyZodObject;
outputSchema?: AnyZodObject;
@ -1009,15 +1155,16 @@ export type RegisteredTool = {
enable(): void;
disable(): void;
update<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape>(
updates: {
name?: string | null,
description?: string,
paramsSchema?: InputArgs,
outputSchema?: OutputArgs,
annotations?: ToolAnnotations,
callback?: ToolCallback<InputArgs>,
enabled?: boolean
}): void
updates: {
name?: string | null,
title?: string,
description?: string,
paramsSchema?: InputArgs,
outputSchema?: OutputArgs,
annotations?: ToolAnnotations,
callback?: ToolCallback<InputArgs>,
enabled?: boolean
}): void
remove(): void
};
@ -1065,12 +1212,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
};
@ -1085,12 +1233,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
};
@ -1110,13 +1259,14 @@ export type PromptCallback<
: (extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => GetPromptResult | Promise<GetPromptResult>;
export type RegisteredPrompt = {
title?: string;
description?: string;
argsSchema?: ZodObject<PromptArgsRawShape>;
callback: PromptCallback<undefined | PromptArgsRawShape>;
enabled: boolean;
enable(): void;
disable(): void;
update<Args extends PromptArgsRawShape>(updates: { name?: string | null, description?: string, argsSchema?: Args, callback?: PromptCallback<Args>, enabled?: boolean }): void
update<Args extends PromptArgsRawShape>(updates: { name?: string | null, title?: string, description?: string, argsSchema?: Args, callback?: PromptCallback<Args>, enabled?: boolean }): void
remove(): void
};

236
src/server/title.test.ts Normal file
View file

@ -0,0 +1,236 @@
import { Server } from "./index.js";
import { Client } from "../client/index.js";
import { InMemoryTransport } from "../inMemory.js";
import { z } from "zod";
import { McpServer, ResourceTemplate } from "./mcp.js";
describe("Title field backwards compatibility", () => {
it("should work with tools that have title", async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = new McpServer(
{ name: "test-server", version: "1.0.0" },
{ capabilities: {} }
);
// Register tool with title
server.registerTool(
"test-tool",
{
title: "Test Tool Display Name",
description: "A test tool",
inputSchema: {
value: z.string()
}
},
async () => ({ content: [{ type: "text", text: "result" }] })
);
const client = new Client({ name: "test-client", version: "1.0.0" });
await server.server.connect(serverTransport);
await client.connect(clientTransport);
const tools = await client.listTools();
expect(tools.tools).toHaveLength(1);
expect(tools.tools[0].name).toBe("test-tool");
expect(tools.tools[0].title).toBe("Test Tool Display Name");
expect(tools.tools[0].description).toBe("A test tool");
});
it("should work with tools without title", async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = new McpServer(
{ name: "test-server", version: "1.0.0" },
{ capabilities: {} }
);
// Register tool without title
server.tool(
"test-tool",
"A test tool",
{ value: z.string() },
async () => ({ content: [{ type: "text", text: "result" }] })
);
const client = new Client({ name: "test-client", version: "1.0.0" });
await server.server.connect(serverTransport);
await client.connect(clientTransport);
const tools = await client.listTools();
expect(tools.tools).toHaveLength(1);
expect(tools.tools[0].name).toBe("test-tool");
expect(tools.tools[0].title).toBeUndefined();
expect(tools.tools[0].description).toBe("A test tool");
});
it("should work with prompts that have title using update", async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = new McpServer(
{ name: "test-server", version: "1.0.0" },
{ capabilities: {} }
);
// Register prompt with title by updating after creation
const prompt = server.prompt(
"test-prompt",
"A test prompt",
async () => ({ messages: [{ role: "user", content: { type: "text", text: "test" } }] })
);
prompt.update({ title: "Test Prompt Display Name" });
const client = new Client({ name: "test-client", version: "1.0.0" });
await server.server.connect(serverTransport);
await client.connect(clientTransport);
const prompts = await client.listPrompts();
expect(prompts.prompts).toHaveLength(1);
expect(prompts.prompts[0].name).toBe("test-prompt");
expect(prompts.prompts[0].title).toBe("Test Prompt Display Name");
expect(prompts.prompts[0].description).toBe("A test prompt");
});
it("should work with prompts using registerPrompt", async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = new McpServer(
{ name: "test-server", version: "1.0.0" },
{ capabilities: {} }
);
// Register prompt with title using registerPrompt
server.registerPrompt(
"test-prompt",
{
title: "Test Prompt Display Name",
description: "A test prompt",
argsSchema: { input: z.string() }
},
async ({ input }) => ({
messages: [{
role: "user",
content: { type: "text", text: `test: ${input}` }
}]
})
);
const client = new Client({ name: "test-client", version: "1.0.0" });
await server.server.connect(serverTransport);
await client.connect(clientTransport);
const prompts = await client.listPrompts();
expect(prompts.prompts).toHaveLength(1);
expect(prompts.prompts[0].name).toBe("test-prompt");
expect(prompts.prompts[0].title).toBe("Test Prompt Display Name");
expect(prompts.prompts[0].description).toBe("A test prompt");
expect(prompts.prompts[0].arguments).toHaveLength(1);
});
it("should work with resources using registerResource", async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = new McpServer(
{ name: "test-server", version: "1.0.0" },
{ capabilities: {} }
);
// Register resource with title using registerResource
server.registerResource(
"test-resource",
"https://example.com/test",
{
title: "Test Resource Display Name",
description: "A test resource",
mimeType: "text/plain"
},
async () => ({
contents: [{
uri: "https://example.com/test",
text: "test content"
}]
})
);
const client = new Client({ name: "test-client", version: "1.0.0" });
await server.server.connect(serverTransport);
await client.connect(clientTransport);
const resources = await client.listResources();
expect(resources.resources).toHaveLength(1);
expect(resources.resources[0].name).toBe("test-resource");
expect(resources.resources[0].title).toBe("Test Resource Display Name");
expect(resources.resources[0].description).toBe("A test resource");
expect(resources.resources[0].mimeType).toBe("text/plain");
});
it("should work with dynamic resources using registerResource", async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = new McpServer(
{ name: "test-server", version: "1.0.0" },
{ capabilities: {} }
);
// Register dynamic resource with title using registerResource
server.registerResource(
"user-profile",
new ResourceTemplate("users://{userId}/profile", { list: undefined }),
{
title: "User Profile",
description: "User profile information"
},
async (uri, { userId }, _extra) => ({
contents: [{
uri: uri.href,
text: `Profile data for user ${userId}`
}]
})
);
const client = new Client({ name: "test-client", version: "1.0.0" });
await server.server.connect(serverTransport);
await client.connect(clientTransport);
const resourceTemplates = await client.listResourceTemplates();
expect(resourceTemplates.resourceTemplates).toHaveLength(1);
expect(resourceTemplates.resourceTemplates[0].name).toBe("user-profile");
expect(resourceTemplates.resourceTemplates[0].title).toBe("User Profile");
expect(resourceTemplates.resourceTemplates[0].description).toBe("User profile information");
expect(resourceTemplates.resourceTemplates[0].uriTemplate).toBe("users://{userId}/profile");
// Test reading the resource
const readResult = await client.readResource({ uri: "users://123/profile" });
expect(readResult.contents).toHaveLength(1);
expect(readResult.contents[0].text).toBe("Profile data for user 123");
});
it("should support serverInfo with title", async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = new Server(
{
name: "test-server",
version: "1.0.0",
title: "Test Server Display Name"
},
{ capabilities: {} }
);
const client = new Client({ name: "test-client", version: "1.0.0" });
await server.connect(serverTransport);
await client.connect(clientTransport);
const serverInfo = client.getServerVersion();
expect(serverInfo?.name).toBe("test-server");
expect(serverInfo?.version).toBe("1.0.0");
expect(serverInfo?.title).toBe("Test Server Display Name");
});
});