Merge branch 'main' into ihrpr/elicitation
This commit is contained in:
commit
54c30109fb
8 changed files with 2212 additions and 271 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -265,10 +265,12 @@ export class Server<
|
|||
this._clientCapabilities = request.params.capabilities;
|
||||
this._clientVersion = request.params.clientInfo;
|
||||
|
||||
return {
|
||||
protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
|
||||
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
|
||||
? requestedVersion
|
||||
: LATEST_PROTOCOL_VERSION,
|
||||
: LATEST_PROTOCOL_VERSION;
|
||||
|
||||
return {
|
||||
protocolVersion,
|
||||
capabilities: this.getCapabilities(),
|
||||
serverInfo: this._serverInfo,
|
||||
...(this._instructions && { instructions: this._instructions }),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,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", () => {
|
||||
/***
|
||||
|
|
@ -3458,6 +3459,555 @@ describe("prompt()", () => {
|
|||
expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true);
|
||||
expect(result.messages[0].content.text).toContain("Received request ID:");
|
||||
});
|
||||
|
||||
/***
|
||||
* Test: Resource Template Metadata Priority
|
||||
*/
|
||||
test("should prioritize individual resource metadata over template metadata", async () => {
|
||||
const mcpServer = new McpServer({
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
});
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.resource(
|
||||
"test",
|
||||
new ResourceTemplate("test://resource/{id}", {
|
||||
list: async () => ({
|
||||
resources: [
|
||||
{
|
||||
name: "Resource 1",
|
||||
uri: "test://resource/1",
|
||||
description: "Individual resource description",
|
||||
mimeType: "text/plain",
|
||||
},
|
||||
{
|
||||
name: "Resource 2",
|
||||
uri: "test://resource/2",
|
||||
// This resource has no description or mimeType
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
{
|
||||
description: "Template description",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
async (uri) => ({
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
text: "Test content",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
mcpServer.server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
const result = await client.request(
|
||||
{
|
||||
method: "resources/list",
|
||||
},
|
||||
ListResourcesResultSchema,
|
||||
);
|
||||
|
||||
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");
|
||||
expect(result.resources[1].mimeType).toBe("application/json");
|
||||
});
|
||||
|
||||
/***
|
||||
* Test: Resource Template Metadata Overrides All Fields
|
||||
*/
|
||||
test("should allow resource to override all template metadata fields", async () => {
|
||||
const mcpServer = new McpServer({
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
});
|
||||
const client = new Client({
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
});
|
||||
|
||||
mcpServer.resource(
|
||||
"test",
|
||||
new ResourceTemplate("test://resource/{id}", {
|
||||
list: async () => ({
|
||||
resources: [
|
||||
{
|
||||
name: "Overridden Name",
|
||||
uri: "test://resource/1",
|
||||
description: "Overridden description",
|
||||
mimeType: "text/markdown",
|
||||
// Add any other metadata fields if they exist
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "Template Name",
|
||||
description: "Template description",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
async (uri) => ({
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
text: "Test content",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
mcpServer.server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
const result = await client.request(
|
||||
{
|
||||
method: "resources/list",
|
||||
},
|
||||
ListResourcesResultSchema,
|
||||
);
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elicitInput()", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -373,8 +375,9 @@ export class McpServer {
|
|||
const result = await template.resourceTemplate.listCallback(extra);
|
||||
for (const resource of result.resources) {
|
||||
templateResources.push({
|
||||
...resource,
|
||||
...template.metadata,
|
||||
// the defined resource metadata should override the template metadata if present
|
||||
...resource,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -468,6 +471,7 @@ export class McpServer {
|
|||
([name, prompt]): Prompt => {
|
||||
return {
|
||||
name,
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
arguments: prompt.argsSchema
|
||||
? promptArgumentsFromSchema(prompt.argsSchema)
|
||||
|
|
@ -575,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();
|
||||
|
|
@ -605,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();
|
||||
|
|
@ -633,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,
|
||||
|
|
@ -642,6 +763,7 @@ export class McpServer {
|
|||
callback: ToolCallback<ZodRawShape | undefined>
|
||||
): RegisteredTool {
|
||||
const registeredTool: RegisteredTool = {
|
||||
title,
|
||||
description,
|
||||
inputSchema:
|
||||
inputSchema === undefined ? undefined : z.object(inputSchema),
|
||||
|
|
@ -658,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
|
||||
|
|
@ -779,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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -788,6 +911,7 @@ export class McpServer {
|
|||
registerTool<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape>(
|
||||
name: string,
|
||||
config: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema?: InputArgs;
|
||||
outputSchema?: OutputArgs;
|
||||
|
|
@ -799,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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -856,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()
|
||||
|
|
@ -884,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
|
||||
|
|
@ -925,6 +1068,9 @@ export class McpServer {
|
|||
*/
|
||||
export type CompleteResourceTemplateCallback = (
|
||||
value: string,
|
||||
context?: {
|
||||
arguments?: Record<string, string>;
|
||||
},
|
||||
) => string[] | Promise<string[]>;
|
||||
|
||||
/**
|
||||
|
|
@ -999,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;
|
||||
|
|
@ -1008,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
|
||||
};
|
||||
|
||||
|
|
@ -1064,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
|
||||
};
|
||||
|
||||
|
|
@ -1084,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
|
||||
};
|
||||
|
||||
|
|
@ -1109,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
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ const MAXIMUM_MESSAGE_SIZE = "4mb";
|
|||
export class SSEServerTransport implements Transport {
|
||||
private _sseResponse?: ServerResponse;
|
||||
private _sessionId: string;
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
|
||||
|
|
|
|||
|
|
@ -185,6 +185,8 @@ async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMe
|
|||
|
||||
if (sessionId) {
|
||||
headers["mcp-session-id"] = sessionId;
|
||||
// After initialization, include the protocol version header
|
||||
headers["mcp-protocol-version"] = "2025-03-26";
|
||||
}
|
||||
|
||||
return fetch(baseUrl, {
|
||||
|
|
@ -277,7 +279,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/);
|
||||
});
|
||||
|
||||
it("should pandle post requests via sse response correctly", async () => {
|
||||
it("should handle post requests via sse response correctly", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId);
|
||||
|
|
@ -376,6 +378,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -417,6 +420,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -448,6 +452,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -459,6 +464,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -477,6 +483,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
headers: {
|
||||
Accept: "application/json",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -670,6 +677,7 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -705,7 +713,10 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
// Now DELETE the session
|
||||
const deleteResponse = await fetch(tempUrl, {
|
||||
method: "DELETE",
|
||||
headers: { "mcp-session-id": tempSessionId || "" },
|
||||
headers: {
|
||||
"mcp-session-id": tempSessionId || "",
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
|
|
@ -721,13 +732,124 @@ describe("StreamableHTTPServerTransport", () => {
|
|||
// Try to delete with invalid session ID
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "DELETE",
|
||||
headers: { "mcp-session-id": "invalid-session-id" },
|
||||
headers: {
|
||||
"mcp-session-id": "invalid-session-id",
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const errorData = await response.json();
|
||||
expectErrorResponse(errorData, -32001, /Session not found/);
|
||||
});
|
||||
|
||||
describe("protocol version header validation", () => {
|
||||
it("should accept requests with matching protocol version", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
// Send request with matching protocol version
|
||||
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should accept requests without protocol version header", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
// Send request without protocol version header
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
// No mcp-protocol-version header
|
||||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.toolsList),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should reject requests with unsupported protocol version", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
// Send request with unsupported protocol version
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "1999-01-01", // Unsupported version
|
||||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.toolsList),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/);
|
||||
});
|
||||
|
||||
it("should accept when protocol version differs from negotiated version", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
// Spy on console.warn to verify warning is logged
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
// Send request with different but supported protocol version
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2024-11-05", // Different but supported version
|
||||
},
|
||||
body: JSON.stringify(TEST_MESSAGES.toolsList),
|
||||
});
|
||||
|
||||
// Request should still succeed
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should handle protocol version validation for GET requests", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
// GET request with unsupported protocol version
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "invalid-version",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/);
|
||||
});
|
||||
|
||||
it("should handle protocol version validation for DELETE requests", async () => {
|
||||
sessionId = await initializeServer();
|
||||
|
||||
// DELETE request with unsupported protocol version
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "invalid-version",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("StreamableHTTPServerTransport with AuthInfo", () => {
|
||||
|
|
@ -1120,6 +1242,7 @@ describe("StreamableHTTPServerTransport with resumability", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -1196,6 +1319,7 @@ describe("StreamableHTTPServerTransport with resumability", () => {
|
|||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-session-id": sessionId,
|
||||
"mcp-protocol-version": "2025-03-26",
|
||||
"last-event-id": firstEventId
|
||||
},
|
||||
});
|
||||
|
|
@ -1282,14 +1406,20 @@ describe("StreamableHTTPServerTransport in stateless mode", () => {
|
|||
// Open first SSE stream
|
||||
const stream1 = await fetch(baseUrl, {
|
||||
method: "GET",
|
||||
headers: { Accept: "text/event-stream" },
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-protocol-version": "2025-03-26"
|
||||
},
|
||||
});
|
||||
expect(stream1.status).toBe(200);
|
||||
|
||||
// Open second SSE stream - should still be rejected, stateless mode still only allows one
|
||||
const stream2 = await fetch(baseUrl, {
|
||||
method: "GET",
|
||||
headers: { Accept: "text/event-stream" },
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"mcp-protocol-version": "2025-03-26"
|
||||
},
|
||||
});
|
||||
expect(stream2.status).toBe(409); // Conflict - only one stream allowed
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Transport } from "../shared/transport.js";
|
||||
import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js";
|
||||
import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../types.js";
|
||||
import getRawBody from "raw-body";
|
||||
import contentType from "content-type";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
|
@ -110,7 +110,7 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
private _eventStore?: EventStore;
|
||||
private _onsessioninitialized?: (sessionId: string) => void;
|
||||
|
||||
sessionId?: string | undefined;
|
||||
sessionId?: string;
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
|
||||
|
|
@ -172,6 +172,9 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
if (!this.validateSession(req, res)) {
|
||||
return;
|
||||
}
|
||||
if (!this.validateProtocolVersion(req, res)) {
|
||||
return;
|
||||
}
|
||||
// Handle resumability: check for Last-Event-ID header
|
||||
if (this._eventStore) {
|
||||
const lastEventId = req.headers['last-event-id'] as string | undefined;
|
||||
|
|
@ -378,11 +381,17 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
}
|
||||
|
||||
}
|
||||
// If an Mcp-Session-Id is returned by the server during initialization,
|
||||
// clients using the Streamable HTTP transport MUST include it
|
||||
// in the Mcp-Session-Id header on all of their subsequent HTTP requests.
|
||||
if (!isInitializationRequest && !this.validateSession(req, res)) {
|
||||
return;
|
||||
if (!isInitializationRequest) {
|
||||
// If an Mcp-Session-Id is returned by the server during initialization,
|
||||
// clients using the Streamable HTTP transport MUST include it
|
||||
// in the Mcp-Session-Id header on all of their subsequent HTTP requests.
|
||||
if (!this.validateSession(req, res)) {
|
||||
return;
|
||||
}
|
||||
// Mcp-Protocol-Version header is required for all requests after initialization.
|
||||
if (!this.validateProtocolVersion(req, res)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -457,6 +466,9 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
if (!this.validateSession(req, res)) {
|
||||
return;
|
||||
}
|
||||
if (!this.validateProtocolVersion(req, res)) {
|
||||
return;
|
||||
}
|
||||
await this.close();
|
||||
res.writeHead(200).end();
|
||||
}
|
||||
|
|
@ -524,6 +536,25 @@ export class StreamableHTTPServerTransport implements Transport {
|
|||
return true;
|
||||
}
|
||||
|
||||
private validateProtocolVersion(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
let protocolVersion = req.headers["mcp-protocol-version"] ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION;
|
||||
if (Array.isArray(protocolVersion)) {
|
||||
protocolVersion = protocolVersion[protocolVersion.length - 1];
|
||||
}
|
||||
|
||||
if (!SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) {
|
||||
res.writeHead(400).end(JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32000,
|
||||
message: `Bad Request: Unsupported protocol version (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")})`
|
||||
},
|
||||
id: null
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Close all SSE connections
|
||||
|
|
|
|||
236
src/server/title.test.ts
Normal file
236
src/server/title.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue