diff --git a/README.md b/README.md index a6ec645c..4fcdff17 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,68 @@ server.tool( ## Advanced Usage +### Dynamic Servers + +If you want to offer an initial set of tools/prompts/resources, but later add additional ones based on user action or external state change, you can add/update/remove them _after_ the Server is connected. This will automatically emit the corresponding `listChanged` notificaions: + +```ts +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +const server = new McpServer({ + name: "Dynamic Example", + version: "1.0.0" +}); + +const listMessageTool = server.tool( + "listMessages", + { channel: z.string() }, + async ({ channel }) => ({ + content: [{ type: "text", text: await listMessages(channel) }] + }) +); + +const putMessageTool = server.tool( + "putMessage", + { channel: z.string(), message: z.string() }, + async ({ channel, message }) => ({ + content: [{ type: "text", text: await putMessage(channel, string) }] + }) +); +// Until we upgrade auth, `putMessage` is disabled (won't show up in listTools) +putMessageTool.disable() + +const upgradeAuthTool = server.tool( + "upgradeAuth", + { permission: z.enum(["write', vadmin"])}, + // Any mutations here will automatically emit `listChanged` notifications + async ({ permission }) => { + const { ok, err, previous } = await upgradeAuthAndStoreToken(permission) + if (!ok) return {content: [{ type: "text", text: `Error: ${err}` }]} + + // If we previously had read-only access, 'putMessage' is now available + if (previous === "read") { + putMessageTool.enable() + } + + if (permission === 'write') { + // If we've just upgraded to 'write' permissions, we can still call 'upgradeAuth' + // but can only upgrade to 'admin'. + upgradeAuthTool.update({ + paramSchema: { permission: z.enum(["admin"]) }, // change validation rules + }) + } else { + // If we're now an admin, we no longer have anywhere to upgrade to, so fully remove that tool + upgradeAuthTool.remove() + } + } +) + +// Connect as normal +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + ### Low-Level Server For more control, you can use the low-level Server class directly: diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index f33c669f..eaac5c71 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -12,6 +12,7 @@ import { GetPromptResultSchema, CompleteResultSchema, LoggingMessageNotificationSchema, + Notification, } from "../types.js"; import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; @@ -36,10 +37,14 @@ describe("McpServer", () => { { 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(); @@ -56,6 +61,16 @@ describe("McpServer", () => { data: "Test log message", }), ).resolves.not.toThrow(); + + expect(notifications).toMatchObject([ + { + "method": "notifications/message", + params: { + level: "info", + data: "Test log message", + } + } + ]) }); }); @@ -100,10 +115,14 @@ describe("tool()", () => { 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: [ @@ -119,7 +138,7 @@ describe("tool()", () => { await Promise.all([ client.connect(clientTransport), - mcpServer.server.connect(serverTransport), + mcpServer.connect(serverTransport), ]); const result = await client.request( @@ -134,6 +153,254 @@ describe("tool()", () => { 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("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("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("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("should register tool with args schema", async () => { @@ -637,127 +904,433 @@ describe("resource()", () => { expect(result.resources[0].uri).toBe("test://resource"); }); - test("should register resource with metadata", async () => { + 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); + }; - mcpServer.resource( - "test", - "test://resource", - { - description: "Test resource", - mimeType: "text/plain", - }, - async () => ({ + // 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: "Test content", + text: "Updated content", }, ], - }), - ); + }) + }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([ client.connect(clientTransport), - mcpServer.server.connect(serverTransport), + mcpServer.connect(serverTransport), ]); + // Read the resource and verify we get the updated content const result = await client.request( { - method: "resources/list", + method: "resources/read", + params: { + uri: "test://resource", + }, }, - ListResourcesResultSchema, + ReadResourceResultSchema, ); - expect(result.resources).toHaveLength(1); - expect(result.resources[0].description).toBe("Test resource"); - expect(result.resources[0].mimeType).toBe("text/plain"); + 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("should register resource template", async () => { + 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); + }; - mcpServer.resource( + // Register initial resource template + const resourceTemplate = mcpServer.resource( "test", new ResourceTemplate("test://resource/{id}", { list: undefined }), - async () => ({ + async (uri) => ({ contents: [ { - uri: "test://resource/123", - text: "Test content", + 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.server.connect(serverTransport), + mcpServer.connect(serverTransport), ]); + // Read the resource and verify we get the updated content const result = await client.request( { - method: "resources/templates/list", + method: "resources/read", + params: { + uri: "test://resource/123", + }, }, - ListResourceTemplatesResultSchema, + ReadResourceResultSchema, ); - expect(result.resourceTemplates).toHaveLength(1); - expect(result.resourceTemplates[0].name).toBe("test"); - expect(result.resourceTemplates[0].uriTemplate).toBe( - "test://resource/{id}", - ); + 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("should register resource template with listCallback", async () => { + 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); + }; - 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: [ - { + // 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("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("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("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("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("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", }, @@ -1173,6 +1746,303 @@ describe("prompt()", () => { expect(result.prompts[0].name).toBe("test"); expect(result.prompts[0].arguments).toBeUndefined(); }); + 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("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("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("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("should register prompt with args schema", async () => { const mcpServer = new McpServer({ diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 484084fc..652f9774 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -98,13 +98,17 @@ export class McpServer { ); this.server.registerCapabilities({ - tools: {}, - }); + tools: { + listChanged: true + } + }) this.server.setRequestHandler( ListToolsRequestSchema, (): ListToolsResult => ({ - tools: Object.entries(this._registeredTools).map( + tools: Object.entries(this._registeredTools).filter( + ([, tool]) => tool.enabled, + ).map( ([name, tool]): Tool => { return { name, @@ -131,6 +135,13 @@ export class McpServer { ); } + if (!tool.enabled) { + throw new McpError( + ErrorCode.InvalidParams, + `Tool ${request.params.name} disabled`, + ); + } + if (tool.inputSchema) { const parseResult = await tool.inputSchema.safeParseAsync( request.params.arguments, @@ -220,7 +231,14 @@ export class McpServer { if (!prompt) { throw new McpError( ErrorCode.InvalidParams, - `Prompt ${request.params.ref.name} not found`, + `Prompt ${ref.name} not found`, + ); + } + + if (!prompt.enabled) { + throw new McpError( + ErrorCode.InvalidParams, + `Prompt ${ref.name} disabled`, ); } @@ -287,13 +305,17 @@ export class McpServer { ); this.server.registerCapabilities({ - resources: {}, - }); + resources: { + listChanged: true + } + }) this.server.setRequestHandler( ListResourcesRequestSchema, async (request, extra) => { - const resources = Object.entries(this._registeredResources).map( + const resources = Object.entries(this._registeredResources).filter( + ([_, resource]) => resource.enabled, + ).map( ([uri, resource]) => ({ uri, name: resource.name, @@ -345,6 +367,12 @@ export class McpServer { // First check for exact resource match const resource = this._registeredResources[uri.toString()]; if (resource) { + if (!resource.enabled) { + throw new McpError( + ErrorCode.InvalidParams, + `Resource ${uri} disabled`, + ); + } return resource.readCallback(uri, extra); } @@ -387,13 +415,17 @@ export class McpServer { ); this.server.registerCapabilities({ - prompts: {}, - }); + prompts: { + listChanged: true + } + }) this.server.setRequestHandler( ListPromptsRequestSchema, (): ListPromptsResult => ({ - prompts: Object.entries(this._registeredPrompts).map( + prompts: Object.entries(this._registeredPrompts).filter( + ([, prompt]) => prompt.enabled, + ).map( ([name, prompt]): Prompt => { return { name, @@ -418,6 +450,13 @@ export class McpServer { ); } + if (!prompt.enabled) { + throw new McpError( + ErrorCode.InvalidParams, + `Prompt ${request.params.name} disabled`, + ); + } + if (prompt.argsSchema) { const parseResult = await prompt.argsSchema.safeParseAsync( request.params.arguments, @@ -447,7 +486,7 @@ export class McpServer { /** * Registers a resource `name` at a fixed URI, which will use the given callback to respond to read requests. */ - resource(name: string, uri: string, readCallback: ReadResourceCallback): void; + resource(name: string, uri: string, readCallback: ReadResourceCallback): RegisteredResource; /** * Registers a resource `name` at a fixed URI with metadata, which will use the given callback to respond to read requests. @@ -457,7 +496,7 @@ export class McpServer { uri: string, metadata: ResourceMetadata, readCallback: ReadResourceCallback, - ): void; + ): RegisteredResource; /** * Registers a resource `name` with a template pattern, which will use the given callback to respond to read requests. @@ -466,7 +505,7 @@ export class McpServer { name: string, template: ResourceTemplate, readCallback: ReadResourceTemplateCallback, - ): void; + ): RegisteredResourceTemplate; /** * Registers a resource `name` with a template pattern and metadata, which will use the given callback to respond to read requests. @@ -476,13 +515,13 @@ export class McpServer { template: ResourceTemplate, metadata: ResourceMetadata, readCallback: ReadResourceTemplateCallback, - ): void; + ): RegisteredResourceTemplate; resource( name: string, uriOrTemplate: string | ResourceTemplate, ...rest: unknown[] - ): void { + ): RegisteredResource | RegisteredResourceTemplate { let metadata: ResourceMetadata | undefined; if (typeof rest[0] === "object") { metadata = rest.shift() as ResourceMetadata; @@ -497,35 +536,73 @@ export class McpServer { throw new Error(`Resource ${uriOrTemplate} is already registered`); } - this._registeredResources[uriOrTemplate] = { + const registeredResource: RegisteredResource = { name, 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; + + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResource; } else { if (this._registeredResourceTemplates[name]) { throw new Error(`Resource template ${name} is already registered`); } - this._registeredResourceTemplates[name] = { + const registeredResourceTemplate: RegisteredResourceTemplate = { resourceTemplate: 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; - this.setResourceRequestHandlers(); + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResourceTemplate; + } } /** * Registers a zero-argument tool `name`, which will run the given function when the client calls it. */ - tool(name: string, cb: ToolCallback): void; + tool(name: string, cb: ToolCallback): RegisteredTool; /** * Registers a zero-argument tool `name` (with a description) which will run the given function when the client calls it. */ - tool(name: string, description: string, cb: ToolCallback): void; + tool(name: string, description: string, cb: ToolCallback): RegisteredTool; /** * Registers a tool `name` accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. @@ -534,7 +611,7 @@ export class McpServer { name: string, paramsSchema: Args, cb: ToolCallback, - ): void; + ): RegisteredTool; /** * Registers a tool `name` (with a description) accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. @@ -544,9 +621,9 @@ export class McpServer { description: string, paramsSchema: Args, cb: ToolCallback, - ): void; + ): RegisteredTool; - tool(name: string, ...rest: unknown[]): void { + tool(name: string, ...rest: unknown[]): RegisteredTool { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); } @@ -562,25 +639,44 @@ export class McpServer { } const cb = rest[0] as ToolCallback; - this._registeredTools[name] = { + const registeredTool: RegisteredTool = { description, inputSchema: paramsSchema === undefined ? undefined : z.object(paramsSchema), callback: cb, + enabled: true, + disable: () => registeredTool.update({ enabled: false }), + enable: () => registeredTool.update({ enabled: true }), + remove: () => registeredTool.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredTools[name] + if (updates.name) this._registeredTools[updates.name] = registeredTool + } + 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 + if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled + this.sendToolListChanged() + }, }; + this._registeredTools[name] = registeredTool; this.setToolRequestHandlers(); + this.sendToolListChanged() + + return registeredTool } /** * Registers a zero-argument prompt `name`, which will run the given function when the client calls it. */ - prompt(name: string, cb: PromptCallback): void; + prompt(name: string, cb: PromptCallback): RegisteredPrompt; /** * Registers a zero-argument prompt `name` (with a description) which will run the given function when the client calls it. */ - prompt(name: string, description: string, cb: PromptCallback): void; + prompt(name: string, description: string, cb: PromptCallback): RegisteredPrompt; /** * Registers a prompt `name` accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. @@ -589,7 +685,7 @@ export class McpServer { name: string, argsSchema: Args, cb: PromptCallback, - ): void; + ): RegisteredPrompt; /** * Registers a prompt `name` (with a description) accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. @@ -599,9 +695,9 @@ export class McpServer { description: string, argsSchema: Args, cb: PromptCallback, - ): void; + ): RegisteredPrompt; - prompt(name: string, ...rest: unknown[]): void { + prompt(name: string, ...rest: unknown[]): RegisteredPrompt { if (this._registeredPrompts[name]) { throw new Error(`Prompt ${name} is already registered`); } @@ -617,13 +713,67 @@ export class McpServer { } const cb = rest[0] as PromptCallback; - this._registeredPrompts[name] = { + const registeredPrompt: RegisteredPrompt = { 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; this.setPromptRequestHandlers(); + this.sendPromptListChanged() + + return registeredPrompt + } + + /** + * Checks if the server is connected to a transport. + * @returns True if the server is connected + */ + isConnected() { + return this.server.transport !== undefined + } + + /** + * Sends a resource list changed event to the client, if connected. + */ + sendResourceListChanged() { + if (this.isConnected()) { + this.server.sendResourceListChanged(); + } + } + + /** + * Sends a tool list changed event to the client, if connected. + */ + sendToolListChanged() { + if (this.isConnected()) { + this.server.sendToolListChanged(); + } + } + + /** + * Sends a prompt list changed event to the client, if connected. + */ + sendPromptListChanged() { + if (this.isConnected()) { + this.server.sendPromptListChanged(); + } } } @@ -700,10 +850,15 @@ export type ToolCallback = ) => CallToolResult | Promise : (extra: RequestHandlerExtra) => CallToolResult | Promise; -type RegisteredTool = { +export type RegisteredTool = { description?: string; inputSchema?: AnyZodObject; callback: ToolCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { name?: string | null, description?: string, paramsSchema?: Args, callback?: ToolCallback, enabled?: boolean }): void + remove(): void }; const EMPTY_OBJECT_JSON_SCHEMA = { @@ -730,10 +885,15 @@ export type ReadResourceCallback = ( extra: RequestHandlerExtra, ) => ReadResourceResult | Promise; -type RegisteredResource = { +export type RegisteredResource = { name: 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 + remove(): void }; /** @@ -745,10 +905,15 @@ export type ReadResourceTemplateCallback = ( extra: RequestHandlerExtra, ) => ReadResourceResult | Promise; -type RegisteredResourceTemplate = { +export type RegisteredResourceTemplate = { resourceTemplate: ResourceTemplate; metadata?: ResourceMetadata; readCallback: ReadResourceTemplateCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { name?: string | null, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void + remove(): void }; type PromptArgsRawShape = { @@ -766,10 +931,15 @@ export type PromptCallback< ) => GetPromptResult | Promise : (extra: RequestHandlerExtra) => GetPromptResult | Promise; -type RegisteredPrompt = { +export type RegisteredPrompt = { description?: string; argsSchema?: ZodObject; callback: PromptCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { name?: string | null, description?: string, argsSchema?: Args, callback?: PromptCallback, enabled?: boolean }): void + remove(): void }; function promptArgumentsFromSchema(