diff --git a/README.md b/README.md index 2800cfb1..dcc14aa5 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,26 @@ server.registerTool( ); ``` +#### Tool Enabled State + +Tools can be conditionally enabled during registration using the `enabled` parameter: + +```typescript +// Environment-based enabling +server.registerTool("debug-tool", { + description: "Debug utilities", + enabled: process.env.NODE_ENV === "development" +}, handler); + +// Permission-based enabling +server.registerTool("admin-tool", { + description: "Admin operations", + enabled: user.hasRole("admin") +}, handler); +``` + +When `enabled: false`, tools don't appear in listings and cannot be called. Tools default to `enabled: true`. + #### ResourceLinks Tools can return `ResourceLink` objects to reference resources without embedding their full content. This is essential for performance when dealing with large files or many resources - clients can then selectively read only the resources they need using the provided URIs. diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 10e550df..193c7c09 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -2603,6 +2603,238 @@ describe("resource()", () => { }); }); +describe("registerResource()", () => { + /*** + * Test: Resource Registration with enabled: true (default) + */ + test("should register resource with enabled: true by default", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerResource( + "test", + "test://resource", + { + title: "Test Resource", + description: "A 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"); + expect(result.resources[0].title).toBe("Test Resource"); + expect(result.resources[0].description).toBe("A test resource"); + }); + + /*** + * Test: Resource Registration with enabled: false + */ + test("should not list resource when enabled: false", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerResource( + "test", + "test://resource", + { + title: "Test Resource", + description: "A test resource", + enabled: false, + }, + 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(0); + }); + + /*** + * Test: Resource Template Registration with enabled: false + */ + test("should not list resource template resources when enabled: false", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerResource( + "test", + new ResourceTemplate("test://resource/{id}", { + list: async () => ({ + resources: [ + { + uri: "test://resource/1", + name: "Test Resource 1", + }, + ], + }), + }), + { + title: "Test Resource Template", + description: "A test resource template", + enabled: false, + }, + 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(0); + }); + + /*** + * Test: Dynamic enable/disable of registered resource + */ + test("should allow enabling/disabling registered resource", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const resource = mcpServer.registerResource( + "test", + "test://resource", + { + title: "Test Resource", + enabled: false, + }, + async () => ({ + contents: [ + { + uri: "test://resource", + text: "Test content", + }, + ], + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Initially disabled + let result = await client.request( + { + method: "resources/list", + }, + ListResourcesResultSchema, + ); + expect(result.resources).toHaveLength(0); + + // Enable the resource + resource.enable(); + + result = await client.request( + { + method: "resources/list", + }, + ListResourcesResultSchema, + ); + expect(result.resources).toHaveLength(1); + expect(result.resources[0].name).toBe("test"); + + // Disable the resource + resource.disable(); + + result = await client.request( + { + method: "resources/list", + }, + ListResourcesResultSchema, + ); + expect(result.resources).toHaveLength(0); + }); +}); + describe("prompt()", () => { /*** * Test: Zero-Argument Prompt Registration @@ -4291,3 +4523,384 @@ describe("elicitInput()", () => { }]); }); }); + +describe("Tool enabled state", () => { + it("should register tool with enabled: false", async () => { + const server = new McpServer({ name: "test", version: "1.0.0" }); + + // Register tool with enabled: false + const tool = server.registerTool( + "disabled_tool", + { + description: "A tool that starts disabled", + enabled: false, + }, + async () => ({ + content: [{ type: "text", text: "This tool is disabled" }], + }) + ); + + expect(tool.enabled).toBe(false); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // List tools should not include disabled tool + const result = await client.request( + { + method: "tools/list", + }, + ListToolsResultSchema, + ); + + expect(result.tools).toHaveLength(0); + + // Enable the tool + tool.enable(); + expect(tool.enabled).toBe(true); + + // Now list tools should include the enabled tool + const result2 = await client.request( + { + method: "tools/list", + }, + ListToolsResultSchema, + ); + + expect(result2.tools).toHaveLength(1); + expect(result2.tools[0].name).toBe("disabled_tool"); + }); + + it("should register tool with enabled: true by default", async () => { + const server = new McpServer({ name: "test", version: "1.0.0" }); + + // Register tool without specifying enabled (should default to true) + const tool = server.registerTool( + "default_enabled_tool", + { + description: "A tool with default enabled state", + }, + async () => ({ + content: [{ type: "text", text: "This tool is enabled by default" }], + }) + ); + + expect(tool.enabled).toBe(true); + }); + + it("should register tool with enabled: true explicitly", async () => { + const server = new McpServer({ name: "test", version: "1.0.0" }); + + // Register tool with enabled: true explicitly + const tool = server.registerTool( + "enabled_tool", + { + description: "A tool explicitly enabled", + enabled: true, + }, + async () => ({ + content: [{ type: "text", text: "This tool is enabled" }], + }) + ); + + expect(tool.enabled).toBe(true); + }); +}); + +describe("registerPrompt()", () => { + /*** + * Test: Prompt Registration with enabled: true (default) + */ + test("should register prompt with enabled: true by default", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerPrompt( + "test", + { + title: "Test Prompt", + description: "A 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), + ]); + + 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].title).toBe("Test Prompt"); + expect(result.prompts[0].description).toBe("A test prompt"); + }); + + /*** + * Test: Prompt Registration with enabled: false + */ + test("should not list prompt when enabled: false", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerPrompt( + "test", + { + title: "Test Prompt", + description: "A test prompt", + enabled: false, + }, + 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(0); + }); + + /*** + * Test: Prompt Registration with arguments and enabled: false + */ + test("should not list prompt with arguments when enabled: false", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerPrompt( + "test", + { + title: "Test Prompt", + description: "A test prompt with arguments", + argsSchema: { + name: z.string(), + age: z.string().optional(), + }, + enabled: false, + }, + async ({ name, age }) => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Hello ${name}${age ? `, age ${age}` : ""}`, + }, + }, + ], + }) + ); + + 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(0); + }); + + /*** + * Test: Dynamic enable/disable of registered prompt + */ + test("should allow enabling/disabling registered prompt", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const prompt = mcpServer.registerPrompt( + "test", + { + title: "Test Prompt", + enabled: false, + }, + 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), + ]); + + // Initially disabled + let result = await client.request( + { + method: "prompts/list", + }, + ListPromptsResultSchema, + ); + expect(result.prompts).toHaveLength(0); + + // Enable the prompt + prompt.enable(); + + result = await client.request( + { + method: "prompts/list", + }, + ListPromptsResultSchema, + ); + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0].name).toBe("test"); + + // Disable the prompt + prompt.disable(); + + result = await client.request( + { + method: "prompts/list", + }, + ListPromptsResultSchema, + ); + expect(result.prompts).toHaveLength(0); + }); + + /*** + * Test: Attempt to call disabled prompt should fail + */ + test("should throw error when trying to get disabled prompt", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerPrompt( + "test", + { + title: "Test Prompt", + enabled: false, + }, + 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), + ]); + + // Attempt to get disabled prompt should fail + await expect( + client.request( + { + method: "prompts/get", + params: { + name: "test", + }, + }, + GetPromptResultSchema, + ), + ).rejects.toThrow(/Prompt test disabled/); + }); +}); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 791facef..f8058437 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -368,7 +368,7 @@ export class McpServer { for (const template of Object.values( this._registeredResourceTemplates, )) { - if (!template.resourceTemplate.listCallback) { + if (!template.enabled || !template.resourceTemplate.listCallback) { continue; } @@ -391,6 +391,8 @@ export class McpServer { async () => { const resourceTemplates = Object.entries( this._registeredResourceTemplates, + ).filter( + ([_, template]) => template.enabled, ).map(([name, template]) => ({ name, uriTemplate: template.resourceTemplate.uriTemplate.toString(), @@ -422,6 +424,9 @@ export class McpServer { for (const template of Object.values( this._registeredResourceTemplates, )) { + if (!template.enabled) { + continue; + } const variables = template.resourceTemplate.uriTemplate.match( uri.toString(), ); @@ -584,7 +589,8 @@ export class McpServer { undefined, uriOrTemplate, metadata, - readCallback as ReadResourceCallback + readCallback as ReadResourceCallback, + true ); this.setResourceRequestHandlers(); @@ -600,7 +606,8 @@ export class McpServer { undefined, uriOrTemplate, metadata, - readCallback as ReadResourceTemplateCallback + readCallback as ReadResourceTemplateCallback, + true ); this.setResourceRequestHandlers(); @@ -616,19 +623,19 @@ export class McpServer { registerResource( name: string, uriOrTemplate: string, - config: ResourceMetadata, + config: ResourceConfig, readCallback: ReadResourceCallback ): RegisteredResource; registerResource( name: string, uriOrTemplate: ResourceTemplate, - config: ResourceMetadata, + config: ResourceConfig, readCallback: ReadResourceTemplateCallback ): RegisteredResourceTemplate; registerResource( name: string, uriOrTemplate: string | ResourceTemplate, - config: ResourceMetadata, + config: ResourceConfig, readCallback: ReadResourceCallback | ReadResourceTemplateCallback ): RegisteredResource | RegisteredResourceTemplate { if (typeof uriOrTemplate === "string") { @@ -636,12 +643,14 @@ export class McpServer { throw new Error(`Resource ${uriOrTemplate} is already registered`); } + const { enabled = true, ...metadata } = config; const registeredResource = this._createRegisteredResource( name, (config as BaseMetadata).title, uriOrTemplate, - config, - readCallback as ReadResourceCallback + metadata, + readCallback as ReadResourceCallback, + enabled ); this.setResourceRequestHandlers(); @@ -652,12 +661,14 @@ export class McpServer { throw new Error(`Resource template ${name} is already registered`); } + const { enabled = true, ...metadata } = config; const registeredResourceTemplate = this._createRegisteredResourceTemplate( name, (config as BaseMetadata).title, uriOrTemplate, - config, - readCallback as ReadResourceTemplateCallback + metadata, + readCallback as ReadResourceTemplateCallback, + enabled ); this.setResourceRequestHandlers(); @@ -671,14 +682,15 @@ export class McpServer { title: string | undefined, uri: string, metadata: ResourceMetadata | undefined, - readCallback: ReadResourceCallback + readCallback: ReadResourceCallback, + enabled: boolean = true ): RegisteredResource { const registeredResource: RegisteredResource = { name, title, metadata, readCallback, - enabled: true, + enabled, disable: () => registeredResource.update({ enabled: false }), enable: () => registeredResource.update({ enabled: true }), remove: () => registeredResource.update({ uri: null }), @@ -704,14 +716,15 @@ export class McpServer { title: string | undefined, template: ResourceTemplate, metadata: ResourceMetadata | undefined, - readCallback: ReadResourceTemplateCallback + readCallback: ReadResourceTemplateCallback, + enabled: boolean = true ): RegisteredResourceTemplate { const registeredResourceTemplate: RegisteredResourceTemplate = { resourceTemplate: template, title, metadata, readCallback, - enabled: true, + enabled, disable: () => registeredResourceTemplate.update({ enabled: false }), enable: () => registeredResourceTemplate.update({ enabled: true }), remove: () => registeredResourceTemplate.update({ name: null }), @@ -737,14 +750,15 @@ export class McpServer { title: string | undefined, description: string | undefined, argsSchema: PromptArgsRawShape | undefined, - callback: PromptCallback + callback: PromptCallback, + enabled: boolean = true ): RegisteredPrompt { const registeredPrompt: RegisteredPrompt = { title, description, argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), callback, - enabled: true, + enabled, disable: () => registeredPrompt.update({ enabled: false }), enable: () => registeredPrompt.update({ enabled: true }), remove: () => registeredPrompt.update({ name: null }), @@ -772,7 +786,8 @@ export class McpServer { inputSchema: ZodRawShape | undefined, outputSchema: ZodRawShape | undefined, annotations: ToolAnnotations | undefined, - callback: ToolCallback + callback: ToolCallback, + enabled: boolean = true ): RegisteredTool { const registeredTool: RegisteredTool = { title, @@ -783,7 +798,7 @@ export class McpServer { outputSchema === undefined ? undefined : z.object(outputSchema), annotations, callback, - enabled: true, + enabled, disable: () => registeredTool.update({ enabled: false }), enable: () => registeredTool.update({ enabled: true }), remove: () => registeredTool.update({ name: null }), @@ -928,6 +943,7 @@ export class McpServer { inputSchema?: InputArgs; outputSchema?: OutputArgs; annotations?: ToolAnnotations; + enabled?: boolean; }, cb: ToolCallback ): RegisteredTool { @@ -935,7 +951,7 @@ export class McpServer { throw new Error(`Tool ${name} is already registered`); } - const { title, description, inputSchema, outputSchema, annotations } = config; + const { title, description, inputSchema, outputSchema, annotations, enabled } = config; return this._createRegisteredTool( name, @@ -944,7 +960,8 @@ export class McpServer { inputSchema, outputSchema, annotations, - cb as ToolCallback + cb as ToolCallback, + enabled ); } @@ -998,7 +1015,8 @@ export class McpServer { undefined, description, argsSchema, - cb + cb, + true ); this.setPromptRequestHandlers(); @@ -1012,25 +1030,22 @@ export class McpServer { */ registerPrompt( name: string, - config: { - title?: string; - description?: string; - argsSchema?: Args; - }, + config: PromptConfig, cb: PromptCallback ): RegisteredPrompt { if (this._registeredPrompts[name]) { throw new Error(`Prompt ${name} is already registered`); } - const { title, description, argsSchema } = config; + const { title, description, argsSchema, enabled = true } = config; const registeredPrompt = this._createRegisteredPrompt( name, title, description, argsSchema, - cb as PromptCallback + cb as PromptCallback, + enabled ); this.setPromptRequestHandlers(); @@ -1208,6 +1223,23 @@ function isZodTypeLike(value: unknown): value is ZodType { */ export type ResourceMetadata = Omit; +/** + * Configuration for registering a resource + */ +export type ResourceConfig = ResourceMetadata & { + enabled?: boolean; +}; + +/** + * Configuration for registering a prompt + */ +export type PromptConfig = { + title?: string; + description?: string; + argsSchema?: Args; + enabled?: boolean; +}; + /** * Callback to list all resources matching a given template. */