typescript-sdk/src/server/mcp.test.ts
Basil Hosmer d37de9b110
Add support for message field in progress notifications
Implements the new optional message field for progress notifications as
defined in the MCP specification update. This allows servers to provide
descriptive status updates alongside progress values.

Changes:
- Add optional message field to ProgressSchema in types.ts
- Add tests for progress notifications with message field
- Update protocol tests to verify message field handling

Refs: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/197

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-05-16 15:18:28 -04:00

3420 lines
81 KiB
TypeScript

import { McpServer } from "./mcp.js";
import { Client } from "../client/index.js";
import { InMemoryTransport } from "../inMemory.js";
import { z } from "zod";
import {
ListToolsResultSchema,
CallToolResultSchema,
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ReadResourceResultSchema,
ListPromptsResultSchema,
GetPromptResultSchema,
CompleteResultSchema,
LoggingMessageNotificationSchema,
Notification,
TextContent,
} from "../types.js";
import { ResourceTemplate } from "./mcp.js";
import { completable } from "./completable.js";
import { UriTemplate } from "../shared/uriTemplate.js";
describe("McpServer", () => {
/***
* Test: Basic Server Instance
*/
test("should expose underlying Server instance", () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
expect(mcpServer.server).toBeDefined();
});
/***
* Test: Notification Sending via Server
*/
test("should allow sending notifications via Server", async () => {
const mcpServer = new McpServer(
{
name: "test server",
version: "1.0",
},
{ capabilities: { logging: {} } },
);
const notifications: Notification[] = []
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification)
}
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
// This should work because we're using the underlying server
await expect(
mcpServer.server.sendLoggingMessage({
level: "info",
data: "Test log message",
}),
).resolves.not.toThrow();
expect(notifications).toMatchObject([
{
"method": "notifications/message",
params: {
level: "info",
data: "Test log message",
}
}
])
});
/***
* Test: Progress Notification with Message Field
*/
test("should send progress notifications with message field", async () => {
const mcpServer = new McpServer(
{
name: "test server",
version: "1.0",
}
);
// Create a tool that sends progress updates
mcpServer.tool(
"long-operation",
"A long running operation with progress updates",
{
steps: z.number().min(1).describe("Number of steps to perform"),
},
async ({ steps }, { sendNotification, _meta }) => {
const progressToken = _meta?.progressToken;
if (progressToken) {
// Send progress notification for each step
for (let i = 1; i <= steps; i++) {
await sendNotification({
method: "notifications/progress",
params: {
progressToken,
progress: i,
total: steps,
message: `Completed step ${i} of ${steps}`,
},
});
}
}
return { content: [{ type: "text" as const, text: `Operation completed with ${steps} steps` }] };
}
);
const progressUpdates: Array<{ progress: number, total?: number, message?: string }> = [];
const client = new Client({
name: "test client",
version: "1.0",
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
// Call the tool with progress tracking
await client.request(
{
method: "tools/call",
params: {
name: "long-operation",
arguments: { steps: 3 },
_meta: {
progressToken: "progress-test-1"
}
}
},
CallToolResultSchema,
{
onprogress: (progress) => {
progressUpdates.push(progress);
}
}
);
// Verify progress notifications were received with message field
expect(progressUpdates).toHaveLength(3);
expect(progressUpdates[0]).toMatchObject({
progress: 1,
total: 3,
message: "Completed step 1 of 3",
});
expect(progressUpdates[1]).toMatchObject({
progress: 2,
total: 3,
message: "Completed step 2 of 3",
});
expect(progressUpdates[2]).toMatchObject({
progress: 3,
total: 3,
message: "Completed step 3 of 3",
});
});
});
describe("ResourceTemplate", () => {
/***
* Test: ResourceTemplate Creation with String Pattern
*/
test("should create ResourceTemplate with string pattern", () => {
const template = new ResourceTemplate("test://{category}/{id}", {
list: undefined,
});
expect(template.uriTemplate.toString()).toBe("test://{category}/{id}");
expect(template.listCallback).toBeUndefined();
});
/***
* Test: ResourceTemplate Creation with UriTemplate Instance
*/
test("should create ResourceTemplate with UriTemplate", () => {
const uriTemplate = new UriTemplate("test://{category}/{id}");
const template = new ResourceTemplate(uriTemplate, { list: undefined });
expect(template.uriTemplate).toBe(uriTemplate);
expect(template.listCallback).toBeUndefined();
});
/***
* Test: ResourceTemplate with List Callback
*/
test("should create ResourceTemplate with list callback", async () => {
const list = jest.fn().mockResolvedValue({
resources: [{ name: "Test", uri: "test://example" }],
});
const template = new ResourceTemplate("test://{id}", { list });
expect(template.listCallback).toBe(list);
const abortController = new AbortController();
const result = await template.listCallback?.({
signal: abortController.signal,
requestId: 'not-implemented',
sendRequest: () => { throw new Error("Not implemented") },
sendNotification: () => { throw new Error("Not implemented") }
});
expect(result?.resources).toHaveLength(1);
expect(list).toHaveBeenCalled();
});
});
describe("tool()", () => {
/***
* Test: Zero-Argument Tool Registration
*/
test("should register zero-argument tool", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const notifications: Notification[] = []
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification)
}
mcpServer.tool("test", async () => ({
content: [
{
type: "text",
text: "Test 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(1);
expect(result.tools[0].name).toBe("test");
expect(result.tools[0].inputSchema).toEqual({
type: "object",
});
// Adding the tool before the connection was established means no notification was sent
expect(notifications).toHaveLength(0)
// Adding another tool triggers the update notification
mcpServer.tool("test2", async () => ({
content: [
{
type: "text",
text: "Test response",
},
],
}));
// Yield event loop to let the notification fly
await new Promise(process.nextTick)
expect(notifications).toMatchObject([
{
method: "notifications/tools/list_changed",
}
])
});
/***
* Test: Updating Existing Tool
*/
test("should update existing tool", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const notifications: Notification[] = []
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification)
}
// Register initial tool
const tool = mcpServer.tool("test", async () => ({
content: [
{
type: "text",
text: "Initial response",
},
],
}));
// Update the tool
tool.update({
callback: async () => ({
content: [
{
type: "text",
text: "Updated response",
},
],
})
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.connect(serverTransport),
]);
// Call the tool and verify we get the updated response
const result = await client.request(
{
method: "tools/call",
params: {
name: "test",
},
},
CallToolResultSchema,
);
expect(result.content).toEqual([
{
type: "text",
text: "Updated response",
},
]);
// Update happened before transport was connected, so no notifications should be expected
expect(notifications).toHaveLength(0)
});
/***
* Test: Updating Tool with Schema
*/
test("should update tool with schema", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const notifications: Notification[] = []
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification)
}
// Register initial tool
const tool = mcpServer.tool(
"test",
{
name: z.string(),
},
async ({ name }) => ({
content: [
{
type: "text",
text: `Initial: ${name}`,
},
],
}),
);
// Update the tool with a different schema
tool.update({
paramsSchema: {
name: z.string(),
value: z.number(),
},
callback: async ({name, value}) => ({
content: [
{
type: "text",
text: `Updated: ${name}, ${value}`,
},
],
})
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.connect(serverTransport),
]);
// Verify the schema was updated
const listResult = await client.request(
{
method: "tools/list",
},
ListToolsResultSchema,
);
expect(listResult.tools[0].inputSchema).toMatchObject({
properties: {
name: { type: "string" },
value: { type: "number" },
},
});
// Call the tool with the new schema
const callResult = await client.request(
{
method: "tools/call",
params: {
name: "test",
arguments: {
name: "test",
value: 42,
},
},
},
CallToolResultSchema,
);
expect(callResult.content).toEqual([
{
type: "text",
text: "Updated: test, 42",
},
]);
// Update happened before transport was connected, so no notifications should be expected
expect(notifications).toHaveLength(0)
});
/***
* Test: Tool List Changed Notifications
*/
test("should send tool list changed notifications when connected", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const notifications: Notification[] = []
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification)
}
// Register initial tool
const tool = mcpServer.tool("test", async () => ({
content: [
{
type: "text",
text: "Test response",
},
],
}));
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.connect(serverTransport),
]);
expect(notifications).toHaveLength(0)
// Now update the tool
tool.update({
callback: async () => ({
content: [
{
type: "text",
text: "Updated response",
},
],
})
});
// Yield event loop to let the notification fly
await new Promise(process.nextTick)
expect(notifications).toMatchObject([
{ method: "notifications/tools/list_changed" }
])
// Now delete the tool
tool.remove();
// Yield event loop to let the notification fly
await new Promise(process.nextTick)
expect(notifications).toMatchObject([
{ method: "notifications/tools/list_changed" },
{ method: "notifications/tools/list_changed" },
])
});
/***
* Test: Tool Registration with Parameters
*/
test("should register tool with params", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
// old api
mcpServer.tool(
"test",
{
name: z.string(),
value: z.number(),
},
async ({ name, value }) => ({
content: [
{
type: "text",
text: `${name}: ${value}`,
},
],
}),
);
// new api
mcpServer.registerTool(
"test (new api)",
{
inputSchema: { name: z.string(), value: z.number() },
},
async ({ name, value }) => ({
content: [{ type: "text", text: `${name}: ${value}` }],
})
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "tools/list",
},
ListToolsResultSchema,
);
expect(result.tools).toHaveLength(2);
expect(result.tools[0].name).toBe("test");
expect(result.tools[0].inputSchema).toMatchObject({
type: "object",
properties: {
name: { type: "string" },
value: { type: "number" },
},
});
expect(result.tools[1].name).toBe("test (new api)");
expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema);
});
/***
* Test: Tool Registration with Description
*/
test("should register tool with description", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
// old api
mcpServer.tool("test", "Test description", async () => ({
content: [
{
type: "text",
text: "Test response",
},
],
}));
// new api
mcpServer.registerTool(
"test (new api)",
{
description: "Test description",
},
async () => ({
content: [
{
type: "text" as const,
text: "Test response",
},
],
})
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "tools/list",
},
ListToolsResultSchema,
);
expect(result.tools).toHaveLength(2);
expect(result.tools[0].name).toBe("test");
expect(result.tools[0].description).toBe("Test description");
expect(result.tools[1].name).toBe("test (new api)");
expect(result.tools[1].description).toBe("Test description");
});
/***
* Test: Tool Registration with Annotations
*/
test("should register tool with annotations", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.tool("test", { title: "Test Tool", readOnlyHint: true }, async () => ({
content: [
{
type: "text",
text: "Test response",
},
],
}));
mcpServer.registerTool(
"test (new api)",
{
annotations: { title: "Test Tool", readOnlyHint: true },
},
async () => ({
content: [
{
type: "text" as const,
text: "Test response",
},
],
})
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "tools/list",
},
ListToolsResultSchema,
);
expect(result.tools).toHaveLength(2);
expect(result.tools[0].name).toBe("test");
expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true });
expect(result.tools[1].name).toBe("test (new api)");
expect(result.tools[1].annotations).toEqual({ title: "Test Tool", readOnlyHint: true });
});
/***
* Test: Tool Registration with Parameters and Annotations
*/
test("should register tool with params and annotations", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.tool(
"test",
{ name: z.string() },
{ title: "Test Tool", readOnlyHint: true },
async ({ name }) => ({
content: [{ type: "text", text: `Hello, ${name}!` }]
})
);
mcpServer.registerTool(
"test (new api)",
{
inputSchema: { name: z.string() },
annotations: { title: "Test Tool", readOnlyHint: true },
},
async ({ name }) => ({
content: [{ type: "text", text: `Hello, ${name}!` }]
})
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{ method: "tools/list" },
ListToolsResultSchema,
);
expect(result.tools).toHaveLength(2);
expect(result.tools[0].name).toBe("test");
expect(result.tools[0].inputSchema).toMatchObject({
type: "object",
properties: { name: { type: "string" } }
});
expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true });
expect(result.tools[1].name).toBe("test (new api)");
expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema);
expect(result.tools[1].annotations).toEqual(result.tools[0].annotations);
});
/***
* Test: Tool Registration with Description, Parameters, and Annotations
*/
test("should register tool with description, params, and annotations", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.tool(
"test",
"A tool with everything",
{ name: z.string() },
{ title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false },
async ({ name }) => ({
content: [{ type: "text", text: `Hello, ${name}!` }]
})
);
mcpServer.registerTool(
"test (new api)",
{
description: "A tool with everything",
inputSchema: { name: z.string() },
annotations: { title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false },
},
async ({ name }) => ({
content: [{ type: "text", text: `Hello, ${name}!` }]
})
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{ method: "tools/list" },
ListToolsResultSchema,
);
expect(result.tools).toHaveLength(2);
expect(result.tools[0].name).toBe("test");
expect(result.tools[0].description).toBe("A tool with everything");
expect(result.tools[0].inputSchema).toMatchObject({
type: "object",
properties: { name: { type: "string" } }
});
expect(result.tools[0].annotations).toEqual({
title: "Complete Test Tool",
readOnlyHint: true,
openWorldHint: false
});
expect(result.tools[1].name).toBe("test (new api)");
expect(result.tools[1].description).toBe("A tool with everything");
expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema);
expect(result.tools[1].annotations).toEqual(result.tools[0].annotations);
});
/***
* Test: Tool Registration with Description, Empty Parameters, and Annotations
*/
test("should register tool with description, empty params, and annotations", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.tool(
"test",
"A tool with everything but empty params",
{},
{ title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false },
async () => ({
content: [{ type: "text", text: "Test response" }]
})
);
mcpServer.registerTool(
"test (new api)",
{
description: "A tool with everything but empty params",
inputSchema: {},
annotations: { title: "Complete Test Tool with empty params", readOnlyHint: true, openWorldHint: false },
},
async () => ({
content: [{ type: "text" as const, text: "Test response" }]
})
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{ method: "tools/list" },
ListToolsResultSchema,
);
expect(result.tools).toHaveLength(2);
expect(result.tools[0].name).toBe("test");
expect(result.tools[0].description).toBe("A tool with everything but empty params");
expect(result.tools[0].inputSchema).toMatchObject({
type: "object",
properties: {}
});
expect(result.tools[0].annotations).toEqual({
title: "Complete Test Tool with empty params",
readOnlyHint: true,
openWorldHint: false
});
expect(result.tools[1].name).toBe("test (new api)");
expect(result.tools[1].description).toBe("A tool with everything but empty params");
expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema);
expect(result.tools[1].annotations).toEqual(result.tools[0].annotations);
});
/***
* Test: Tool Argument Validation
*/
test("should validate tool args", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
mcpServer.tool(
"test",
{
name: z.string(),
value: z.number(),
},
async ({ name, value }) => ({
content: [
{
type: "text",
text: `${name}: ${value}`,
},
],
}),
);
mcpServer.registerTool(
"test (new api)",
{
inputSchema: {
name: z.string(),
value: z.number(),
},
},
async ({ name, value }) => ({
content: [
{
type: "text",
text: `${name}: ${value}`,
},
],
})
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
await expect(
client.request(
{
method: "tools/call",
params: {
name: "test",
arguments: {
name: "test",
value: "not a number",
},
},
},
CallToolResultSchema,
),
).rejects.toThrow(/Invalid arguments/);
await expect(
client.request(
{
method: "tools/call",
params: {
name: "test (new api)",
arguments: {
name: "test",
value: "not a number",
},
},
},
CallToolResultSchema,
),
).rejects.toThrow(/Invalid arguments/);
});
/***
* Test: Preventing Duplicate Tool Registration
*/
test("should prevent duplicate tool registration", () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
mcpServer.tool("test", async () => ({
content: [
{
type: "text",
text: "Test response",
},
],
}));
expect(() => {
mcpServer.tool("test", async () => ({
content: [
{
type: "text",
text: "Test response 2",
},
],
}));
}).toThrow(/already registered/);
});
/***
* Test: Multiple Tool Registration
*/
test("should allow registering multiple tools", () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
// This should succeed
mcpServer.tool("tool1", () => ({ content: [] }));
// This should also succeed and not throw about request handlers
mcpServer.tool("tool2", () => ({ content: [] }));
});
/***
* Test: Tool with Output Schema and Structured Content
*/
test("should support tool with outputSchema and structuredContent", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
// Register a tool with outputSchema
mcpServer.registerTool(
"test",
{
description: "Test tool with structured output",
inputSchema: {
input: z.string(),
},
outputSchema: {
processedInput: z.string(),
resultType: z.string(),
timestamp: z.string()
},
},
async ({ input }) => ({
structuredContent: {
processedInput: input,
resultType: "structured",
timestamp: "2023-01-01T00:00:00Z"
},
})
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
// Verify the tool registration includes outputSchema
const listResult = await client.request(
{
method: "tools/list",
},
ListToolsResultSchema,
);
expect(listResult.tools).toHaveLength(1);
expect(listResult.tools[0].outputSchema).toMatchObject({
type: "object",
properties: {
processedInput: { type: "string" },
resultType: { type: "string" },
timestamp: { type: "string" }
},
required: ["processedInput", "resultType", "timestamp"]
});
// Call the tool and verify it returns valid structuredContent
const result = await client.request(
{
method: "tools/call",
params: {
name: "test",
arguments: {
input: "hello",
},
},
},
CallToolResultSchema,
);
expect(result.structuredContent).toBeDefined();
const structuredContent = result.structuredContent as {
processedInput: string;
resultType: string;
timestamp: string;
};
expect(structuredContent.processedInput).toBe("hello");
expect(structuredContent.resultType).toBe("structured");
expect(structuredContent.timestamp).toBe("2023-01-01T00:00:00Z");
// For backward compatibility, content is auto-generated from structuredContent
expect(result.content).toBeDefined();
expect(result.content!).toHaveLength(1);
expect(result.content![0]).toMatchObject({ type: "text" });
const textContent = result.content![0] as TextContent;
expect(JSON.parse(textContent.text)).toEqual(result.structuredContent);
});
/***
* Test: Schema Validation Failure for Invalid Structured Content
*/
test("should fail schema validation when tool returns invalid structuredContent", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
// Register a tool with outputSchema that returns invalid data
mcpServer.registerTool(
"test",
{
description: "Test tool with invalid structured output",
inputSchema: {
input: z.string(),
},
outputSchema: {
processedInput: z.string(),
resultType: z.string(),
timestamp: z.string()
},
},
async ({ input }) => ({
structuredContent: {
processedInput: input,
resultType: "structured",
// Missing required 'timestamp' field
someExtraField: "unexpected" // Extra field not in schema
},
})
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
// First call listTools to cache the outputSchema in the client
await client.listTools();
// Call the tool and expect it to throw a validation error
await expect(
client.callTool({
name: "test",
arguments: {
input: "hello",
},
}),
).rejects.toThrow(/Structured content does not match the tool's output schema/);
});
/***
* Test: Pass Session ID to Tool Callback
*/
test("should pass sessionId to tool callback via RequestHandlerExtra", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
let receivedSessionId: string | undefined;
mcpServer.tool("test-tool", async (extra) => {
receivedSessionId = extra.sessionId;
return {
content: [
{
type: "text",
text: "Test response",
},
],
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
// Set a test sessionId on the server transport
serverTransport.sessionId = "test-session-123";
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
await client.request(
{
method: "tools/call",
params: {
name: "test-tool",
},
},
CallToolResultSchema,
);
expect(receivedSessionId).toBe("test-session-123");
});
/***
* Test: Pass Request ID to Tool Callback
*/
test("should pass requestId to tool callback via RequestHandlerExtra", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
let receivedRequestId: string | number | undefined;
mcpServer.tool("request-id-test", async (extra) => {
receivedRequestId = extra.requestId;
return {
content: [
{
type: "text",
text: `Received request ID: ${extra.requestId}`,
},
],
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "tools/call",
params: {
name: "request-id-test",
},
},
CallToolResultSchema,
);
expect(receivedRequestId).toBeDefined();
expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true);
expect(result.content && result.content[0].text).toContain("Received request ID:");
});
/***
* Test: Send Notification within Tool Call
*/
test("should provide sendNotification within tool call", async () => {
const mcpServer = new McpServer(
{
name: "test server",
version: "1.0",
},
{ capabilities: { logging: {} } },
);
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
let receivedLogMessage: string | undefined;
const loggingMessage = "hello here is log message 1";
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {
receivedLogMessage = notification.params.data as string;
});
mcpServer.tool("test-tool", async ({ sendNotification }) => {
await sendNotification({ method: "notifications/message", params: { level: "debug", data: loggingMessage } });
return {
content: [
{
type: "text",
text: "Test response",
},
],
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
await client.request(
{
method: "tools/call",
params: {
name: "test-tool",
},
},
CallToolResultSchema,
);
expect(receivedLogMessage).toBe(loggingMessage);
});
/***
* Test: Client to Server Tool Call
*/
test("should allow client to call server tools", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
mcpServer.tool(
"test",
"Test tool",
{
input: z.string(),
},
async ({ input }) => ({
content: [
{
type: "text",
text: `Processed: ${input}`,
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "tools/call",
params: {
name: "test",
arguments: {
input: "hello",
},
},
},
CallToolResultSchema,
);
expect(result.content).toEqual([
{
type: "text",
text: "Processed: hello",
},
]);
});
/***
* Test: Graceful Tool Error Handling
*/
test("should handle server tool errors gracefully", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
mcpServer.tool("error-test", async () => {
throw new Error("Tool execution failed");
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "tools/call",
params: {
name: "error-test",
},
},
CallToolResultSchema,
);
expect(result.isError).toBe(true);
expect(result.content).toEqual([
{
type: "text",
text: "Tool execution failed",
},
]);
});
/***
* Test: McpError for Invalid Tool Name
*/
test("should throw McpError for invalid tool name", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
tools: {},
},
},
);
mcpServer.tool("test-tool", async () => ({
content: [
{
type: "text",
text: "Test response",
},
],
}));
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
await expect(
client.request(
{
method: "tools/call",
params: {
name: "nonexistent-tool",
},
},
CallToolResultSchema,
),
).rejects.toThrow(/Tool nonexistent-tool not found/);
});
});
describe("resource()", () => {
/***
* Test: Resource Registration with URI and Read Callback
*/
test("should register resource with uri and readCallback", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.resource("test", "test://resource", async () => ({
contents: [
{
uri: "test://resource",
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);
expect(result.resources[0].name).toBe("test");
expect(result.resources[0].uri).toBe("test://resource");
});
/***
* Test: Update Resource with URI
*/
test("should update resource with uri", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const notifications: Notification[] = [];
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification);
};
// Register initial resource
const resource = mcpServer.resource("test", "test://resource", async () => ({
contents: [
{
uri: "test://resource",
text: "Initial content",
},
],
}));
// Update the resource
resource.update({
callback: async () => ({
contents: [
{
uri: "test://resource",
text: "Updated content",
},
],
})
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.connect(serverTransport),
]);
// Read the resource and verify we get the updated content
const result = await client.request(
{
method: "resources/read",
params: {
uri: "test://resource",
},
},
ReadResourceResultSchema,
);
expect(result.contents).toHaveLength(1);
expect(result.contents[0].text).toBe("Updated content");
// Update happened before transport was connected, so no notifications should be expected
expect(notifications).toHaveLength(0);
});
/***
* Test: Update Resource Template
*/
test("should update resource template", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const notifications: Notification[] = [];
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification);
};
// Register initial resource template
const resourceTemplate = mcpServer.resource(
"test",
new ResourceTemplate("test://resource/{id}", { list: undefined }),
async (uri) => ({
contents: [
{
uri: uri.href,
text: "Initial content",
},
],
}),
);
// Update the resource template
resourceTemplate.update({
callback: async (uri) => ({
contents: [
{
uri: uri.href,
text: "Updated content",
},
],
})
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.connect(serverTransport),
]);
// Read the resource and verify we get the updated content
const result = await client.request(
{
method: "resources/read",
params: {
uri: "test://resource/123",
},
},
ReadResourceResultSchema,
);
expect(result.contents).toHaveLength(1);
expect(result.contents[0].text).toBe("Updated content");
// Update happened before transport was connected, so no notifications should be expected
expect(notifications).toHaveLength(0);
});
/***
* Test: Resource List Changed Notification
*/
test("should send resource list changed notification when connected", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const notifications: Notification[] = [];
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification);
};
// Register initial resource
const resource = mcpServer.resource("test", "test://resource", async () => ({
contents: [
{
uri: "test://resource",
text: "Test content",
},
],
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.connect(serverTransport),
]);
expect(notifications).toHaveLength(0);
// Now update the resource while connected
resource.update({
callback: async () => ({
contents: [
{
uri: "test://resource",
text: "Updated content",
},
],
})
});
// Yield event loop to let the notification fly
await new Promise(process.nextTick);
expect(notifications).toMatchObject([
{ method: "notifications/resources/list_changed" }
]);
});
/***
* Test: Remove Resource and Send Notification
*/
test("should remove resource and send notification when connected", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const notifications: Notification[] = [];
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification);
};
// Register initial resources
const resource1 = mcpServer.resource("resource1", "test://resource1", async () => ({
contents: [{ uri: "test://resource1", text: "Resource 1 content" }],
}));
mcpServer.resource("resource2", "test://resource2", async () => ({
contents: [{ uri: "test://resource2", text: "Resource 2 content" }],
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.connect(serverTransport),
]);
// Verify both resources are registered
let result = await client.request(
{ method: "resources/list" },
ListResourcesResultSchema,
);
expect(result.resources).toHaveLength(2);
expect(notifications).toHaveLength(0);
// Remove a resource
resource1.remove()
// Yield event loop to let the notification fly
await new Promise(process.nextTick);
// Should have sent notification
expect(notifications).toMatchObject([
{ method: "notifications/resources/list_changed" }
]);
// Verify the resource was removed
result = await client.request(
{ method: "resources/list" },
ListResourcesResultSchema,
);
expect(result.resources).toHaveLength(1);
expect(result.resources[0].uri).toBe("test://resource2");
});
/***
* Test: Remove Resource Template and Send Notification
*/
test("should remove resource template and send notification when connected", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const notifications: Notification[] = [];
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification);
};
// Register resource template
const resourceTemplate = mcpServer.resource(
"template",
new ResourceTemplate("test://resource/{id}", { list: undefined }),
async (uri) => ({
contents: [
{
uri: uri.href,
text: "Template content",
},
],
}),
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.connect(serverTransport),
]);
// Verify template is registered
const result = await client.request(
{ method: "resources/templates/list" },
ListResourceTemplatesResultSchema,
);
expect(result.resourceTemplates).toHaveLength(1);
expect(notifications).toHaveLength(0);
// Remove the template
resourceTemplate.remove()
// Yield event loop to let the notification fly
await new Promise(process.nextTick);
// Should have sent notification
expect(notifications).toMatchObject([
{ method: "notifications/resources/list_changed" }
]);
// Verify the template was removed
const result2 = await client.request(
{ method: "resources/templates/list" },
ListResourceTemplatesResultSchema,
);
expect(result2.resourceTemplates).toHaveLength(0);
});
/***
* Test: Resource Registration with Metadata
*/
test("should register resource with 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",
"test://resource",
{
description: "Test resource",
mimeType: "text/plain",
},
async () => ({
contents: [
{
uri: "test://resource",
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);
expect(result.resources[0].description).toBe("Test resource");
expect(result.resources[0].mimeType).toBe("text/plain");
});
/***
* Test: Resource Template Registration
*/
test("should register resource template", 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: undefined }),
async () => ({
contents: [
{
uri: "test://resource/123",
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/templates/list",
},
ListResourceTemplatesResultSchema,
);
expect(result.resourceTemplates).toHaveLength(1);
expect(result.resourceTemplates[0].name).toBe("test");
expect(result.resourceTemplates[0].uriTemplate).toBe(
"test://resource/{id}",
);
});
/***
* Test: Resource Template with List Callback
*/
test("should register resource template with listCallback", 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",
},
{
name: "Resource 2",
uri: "test://resource/2",
},
],
}),
}),
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);
expect(result.resources[0].name).toBe("Resource 1");
expect(result.resources[0].uri).toBe("test://resource/1");
expect(result.resources[1].name).toBe("Resource 2");
expect(result.resources[1].uri).toBe("test://resource/2");
});
/***
* Test: Template Variables to Read Callback
*/
test("should pass template variables to readCallback", 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/{category}/{id}", {
list: undefined,
}),
async (uri, { category, id }) => ({
contents: [
{
uri: uri.href,
text: `Category: ${category}, ID: ${id}`,
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "resources/read",
params: {
uri: "test://resource/books/123",
},
},
ReadResourceResultSchema,
);
expect(result.contents[0].text).toBe("Category: books, ID: 123");
});
/***
* Test: Preventing Duplicate Resource Registration
*/
test("should prevent duplicate resource registration", () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
mcpServer.resource("test", "test://resource", async () => ({
contents: [
{
uri: "test://resource",
text: "Test content",
},
],
}));
expect(() => {
mcpServer.resource("test2", "test://resource", async () => ({
contents: [
{
uri: "test://resource",
text: "Test content 2",
},
],
}));
}).toThrow(/already registered/);
});
/***
* Test: Multiple Resource Registration
*/
test("should allow registering multiple resources", () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
// This should succeed
mcpServer.resource("resource1", "test://resource1", async () => ({
contents: [
{
uri: "test://resource1",
text: "Test content 1",
},
],
}));
// This should also succeed and not throw about request handlers
mcpServer.resource("resource2", "test://resource2", async () => ({
contents: [
{
uri: "test://resource2",
text: "Test content 2",
},
],
}));
});
/***
* Test: Preventing Duplicate Resource Template Registration
*/
test("should prevent duplicate resource template registration", () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
mcpServer.resource(
"test",
new ResourceTemplate("test://resource/{id}", { list: undefined }),
async () => ({
contents: [
{
uri: "test://resource/123",
text: "Test content",
},
],
}),
);
expect(() => {
mcpServer.resource(
"test",
new ResourceTemplate("test://resource/{id}", { list: undefined }),
async () => ({
contents: [
{
uri: "test://resource/123",
text: "Test content 2",
},
],
}),
);
}).toThrow(/already registered/);
});
/***
* Test: Graceful Resource Read Error Handling
*/
test("should handle resource read errors gracefully", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.resource("error-test", "test://error", async () => {
throw new Error("Resource read failed");
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
await expect(
client.request(
{
method: "resources/read",
params: {
uri: "test://error",
},
},
ReadResourceResultSchema,
),
).rejects.toThrow(/Resource read failed/);
});
/***
* Test: McpError for Invalid Resource URI
*/
test("should throw McpError for invalid resource URI", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.resource("test", "test://resource", async () => ({
contents: [
{
uri: "test://resource",
text: "Test content",
},
],
}));
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
await expect(
client.request(
{
method: "resources/read",
params: {
uri: "test://nonexistent",
},
},
ReadResourceResultSchema,
),
).rejects.toThrow(/Resource test:\/\/nonexistent not found/);
});
/***
* Test: Resource Template Parameter Completion
*/
test("should support completion of resource template parameters", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
resources: {},
},
},
);
mcpServer.resource(
"test",
new ResourceTemplate("test://resource/{category}", {
list: undefined,
complete: {
category: () => ["books", "movies", "music"],
},
}),
async () => ({
contents: [
{
uri: "test://resource/test",
text: "Test content",
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/resource",
uri: "test://resource/{category}",
},
argument: {
name: "category",
value: "",
},
},
},
CompleteResultSchema,
);
expect(result.completion.values).toEqual(["books", "movies", "music"]);
expect(result.completion.total).toBe(3);
});
/***
* Test: Filtered Resource Template Parameter Completion
*/
test("should support filtered completion of resource template parameters", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
resources: {},
},
},
);
mcpServer.resource(
"test",
new ResourceTemplate("test://resource/{category}", {
list: undefined,
complete: {
category: (test: string) =>
["books", "movies", "music"].filter((value) =>
value.startsWith(test),
),
},
}),
async () => ({
contents: [
{
uri: "test://resource/test",
text: "Test content",
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/resource",
uri: "test://resource/{category}",
},
argument: {
name: "category",
value: "m",
},
},
},
CompleteResultSchema,
);
expect(result.completion.values).toEqual(["movies", "music"]);
expect(result.completion.total).toBe(2);
});
/***
* Test: Pass Request ID to Resource Callback
*/
test("should pass requestId to resource callback via RequestHandlerExtra", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
resources: {},
},
},
);
let receivedRequestId: string | number | undefined;
mcpServer.resource("request-id-test", "test://resource", async (_uri, extra) => {
receivedRequestId = extra.requestId;
return {
contents: [
{
uri: "test://resource",
text: `Received request ID: ${extra.requestId}`,
},
],
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "resources/read",
params: {
uri: "test://resource",
},
},
ReadResourceResultSchema,
);
expect(receivedRequestId).toBeDefined();
expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true);
expect(result.contents[0].text).toContain("Received request ID:");
});
});
describe("prompt()", () => {
/***
* Test: Zero-Argument Prompt Registration
*/
test("should register zero-argument prompt", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.prompt("test", async () => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: "Test response",
},
},
],
}));
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "prompts/list",
},
ListPromptsResultSchema,
);
expect(result.prompts).toHaveLength(1);
expect(result.prompts[0].name).toBe("test");
expect(result.prompts[0].arguments).toBeUndefined();
});
/***
* Test: Updating Existing Prompt
*/
test("should update existing prompt", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const notifications: Notification[] = [];
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification);
};
// Register initial prompt
const prompt = mcpServer.prompt("test", async () => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: "Initial response",
},
},
],
}));
// Update the prompt
prompt.update({
callback: async () => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: "Updated response",
},
},
],
})
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.connect(serverTransport),
]);
// Call the prompt and verify we get the updated response
const result = await client.request(
{
method: "prompts/get",
params: {
name: "test",
},
},
GetPromptResultSchema,
);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content.text).toBe("Updated response");
// Update happened before transport was connected, so no notifications should be expected
expect(notifications).toHaveLength(0);
});
/***
* Test: Updating Prompt with Schema
*/
test("should update prompt with schema", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const notifications: Notification[] = [];
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification);
};
// Register initial prompt
const prompt = mcpServer.prompt(
"test",
{
name: z.string(),
},
async ({ name }) => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: `Initial: ${name}`,
},
},
],
}),
);
// Update the prompt with a different schema
prompt.update({
argsSchema: {
name: z.string(),
value: z.string(),
},
callback: async ({name, value}) => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: `Updated: ${name}, ${value}`,
},
},
],
})
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.connect(serverTransport),
]);
// Verify the schema was updated
const listResult = await client.request(
{
method: "prompts/list",
},
ListPromptsResultSchema,
);
expect(listResult.prompts[0].arguments).toHaveLength(2);
expect(listResult.prompts[0].arguments?.map(a => a.name).sort()).toEqual(["name", "value"]);
// Call the prompt with the new schema
const getResult = await client.request(
{
method: "prompts/get",
params: {
name: "test",
arguments: {
name: "test",
value: "value",
},
},
},
GetPromptResultSchema,
);
expect(getResult.messages).toHaveLength(1);
expect(getResult.messages[0].content.text).toBe("Updated: test, value");
// Update happened before transport was connected, so no notifications should be expected
expect(notifications).toHaveLength(0);
});
/***
* Test: Prompt List Changed Notification
*/
test("should send prompt list changed notification when connected", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const notifications: Notification[] = [];
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification);
};
// Register initial prompt
const prompt = mcpServer.prompt("test", async () => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: "Test response",
},
},
],
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.connect(serverTransport),
]);
expect(notifications).toHaveLength(0);
// Now update the prompt while connected
prompt.update({
callback: async () => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: "Updated response",
},
},
],
})
});
// Yield event loop to let the notification fly
await new Promise(process.nextTick);
expect(notifications).toMatchObject([
{ method: "notifications/prompts/list_changed" }
]);
});
/***
* Test: Remove Prompt and Send Notification
*/
test("should remove prompt and send notification when connected", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const notifications: Notification[] = [];
const client = new Client({
name: "test client",
version: "1.0",
});
client.fallbackNotificationHandler = async (notification) => {
notifications.push(notification);
};
// Register initial prompts
const prompt1 = mcpServer.prompt("prompt1", async () => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: "Prompt 1 response",
},
},
],
}));
mcpServer.prompt("prompt2", async () => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: "Prompt 2 response",
},
},
],
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.connect(serverTransport),
]);
// Verify both prompts are registered
let result = await client.request(
{ method: "prompts/list" },
ListPromptsResultSchema,
);
expect(result.prompts).toHaveLength(2);
expect(result.prompts.map(p => p.name).sort()).toEqual(["prompt1", "prompt2"]);
expect(notifications).toHaveLength(0);
// Remove a prompt
prompt1.remove()
// Yield event loop to let the notification fly
await new Promise(process.nextTick);
// Should have sent notification
expect(notifications).toMatchObject([
{ method: "notifications/prompts/list_changed" }
]);
// Verify the prompt was removed
result = await client.request(
{ method: "prompts/list" },
ListPromptsResultSchema,
);
expect(result.prompts).toHaveLength(1);
expect(result.prompts[0].name).toBe("prompt2");
});
/***
* Test: Prompt Registration with Arguments Schema
*/
test("should register prompt with args schema", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.prompt(
"test",
{
name: z.string(),
value: z.string(),
},
async ({ name, value }) => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: `${name}: ${value}`,
},
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "prompts/list",
},
ListPromptsResultSchema,
);
expect(result.prompts).toHaveLength(1);
expect(result.prompts[0].name).toBe("test");
expect(result.prompts[0].arguments).toEqual([
{ name: "name", required: true },
{ name: "value", required: true },
]);
});
/***
* Test: Prompt Registration with Description
*/
test("should register prompt with description", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});
mcpServer.prompt("test", "Test description", async () => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: "Test response",
},
},
],
}));
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "prompts/list",
},
ListPromptsResultSchema,
);
expect(result.prompts).toHaveLength(1);
expect(result.prompts[0].name).toBe("test");
expect(result.prompts[0].description).toBe("Test description");
});
/***
* Test: Prompt Argument Validation
*/
test("should validate prompt args", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
prompts: {},
},
},
);
mcpServer.prompt(
"test",
{
name: z.string(),
value: z.string().min(3),
},
async ({ name, value }) => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: `${name}: ${value}`,
},
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
await expect(
client.request(
{
method: "prompts/get",
params: {
name: "test",
arguments: {
name: "test",
value: "ab", // Too short
},
},
},
GetPromptResultSchema,
),
).rejects.toThrow(/Invalid arguments/);
});
/***
* Test: Preventing Duplicate Prompt Registration
*/
test("should prevent duplicate prompt registration", () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
mcpServer.prompt("test", async () => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: "Test response",
},
},
],
}));
expect(() => {
mcpServer.prompt("test", async () => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: "Test response 2",
},
},
],
}));
}).toThrow(/already registered/);
});
/***
* Test: Multiple Prompt Registration
*/
test("should allow registering multiple prompts", () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
// This should succeed
mcpServer.prompt("prompt1", async () => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: "Test response 1",
},
},
],
}));
// This should also succeed and not throw about request handlers
mcpServer.prompt("prompt2", async () => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: "Test response 2",
},
},
],
}));
});
/***
* Test: Prompt Registration with Arguments
*/
test("should allow registering prompts with arguments", () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
// This should succeed
mcpServer.prompt(
"echo",
{ message: z.string() },
({ message }) => ({
messages: [{
role: "user",
content: {
type: "text",
text: `Please process this message: ${message}`
}
}]
})
);
});
/***
* Test: Resources and Prompts with Completion Handlers
*/
test("should allow registering both resources and prompts with completion handlers", () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
// Register a resource with completion
mcpServer.resource(
"test",
new ResourceTemplate("test://resource/{category}", {
list: undefined,
complete: {
category: () => ["books", "movies", "music"],
},
}),
async () => ({
contents: [
{
uri: "test://resource/test",
text: "Test content",
},
],
}),
);
// Register a prompt with completion
mcpServer.prompt(
"echo",
{ message: completable(z.string(), () => ["hello", "world"]) },
({ message }) => ({
messages: [{
role: "user",
content: {
type: "text",
text: `Please process this message: ${message}`
}
}]
})
);
});
/***
* Test: McpError for Invalid Prompt Name
*/
test("should throw McpError for invalid prompt name", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
prompts: {},
},
},
);
mcpServer.prompt("test-prompt", async () => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: "Test response",
},
},
],
}));
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
await expect(
client.request(
{
method: "prompts/get",
params: {
name: "nonexistent-prompt",
},
},
GetPromptResultSchema,
),
).rejects.toThrow(/Prompt nonexistent-prompt not found/);
});
/***
* Test: Prompt Argument Completion
*/
test("should support completion of prompt arguments", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
prompts: {},
},
},
);
mcpServer.prompt(
"test-prompt",
{
name: completable(z.string(), () => ["Alice", "Bob", "Charlie"]),
},
async ({ name }) => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: `Hello ${name}`,
},
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "",
},
},
},
CompleteResultSchema,
);
expect(result.completion.values).toEqual(["Alice", "Bob", "Charlie"]);
expect(result.completion.total).toBe(3);
});
/***
* Test: Filtered Prompt Argument Completion
*/
test("should support filtered completion of prompt arguments", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
prompts: {},
},
},
);
mcpServer.prompt(
"test-prompt",
{
name: completable(z.string(), (test) =>
["Alice", "Bob", "Charlie"].filter((value) => value.startsWith(test)),
),
},
async ({ name }) => ({
messages: [
{
role: "assistant",
content: {
type: "text",
text: `Hello ${name}`,
},
},
],
}),
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "completion/complete",
params: {
ref: {
type: "ref/prompt",
name: "test-prompt",
},
argument: {
name: "name",
value: "A",
},
},
},
CompleteResultSchema,
);
expect(result.completion.values).toEqual(["Alice"]);
expect(result.completion.total).toBe(1);
});
/***
* Test: Pass Request ID to Prompt Callback
*/
test("should pass requestId to prompt callback via RequestHandlerExtra", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client(
{
name: "test client",
version: "1.0",
},
{
capabilities: {
prompts: {},
},
},
);
let receivedRequestId: string | number | undefined;
mcpServer.prompt("request-id-test", async (extra) => {
receivedRequestId = extra.requestId;
return {
messages: [
{
role: "assistant",
content: {
type: "text",
text: `Received request ID: ${extra.requestId}`,
},
},
],
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
mcpServer.server.connect(serverTransport),
]);
const result = await client.request(
{
method: "prompts/get",
params: {
name: "request-id-test",
},
},
GetPromptResultSchema,
);
expect(receivedRequestId).toBeDefined();
expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true);
expect(result.messages[0].content.text).toContain("Received request ID:");
});
});