diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 5a80d8aa..4e6270cc 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -14,7 +14,7 @@ import { LoggingMessageNotificationSchema, Notification, } from "../types.js"; -import { ResourceTemplate } from "./mcp.js"; +import { ResourceTemplate, CapabilityEvent } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; @@ -866,6 +866,95 @@ describe("tool()", () => { ), ).rejects.toThrow(/Tool nonexistent-tool not found/); }); + + test("should include duration in completed and error events", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const events: Array = []; + const subscription = mcpServer.onCapabilityChange((event) => { + events.push(event); + }); + + // Register tools with different behaviors + mcpServer.tool("success-tool", async () => { + // Simulate some work + await new Promise(resolve => setTimeout(resolve, 10)); + return { content: [{ type: "text", text: "Success" }] }; + }); + + mcpServer.tool("error-tool", async () => { + // Simulate some work + await new Promise(resolve => setTimeout(resolve, 10)); + throw new Error("Simulated error"); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Clear events from registration + events.length = 0; + + // Call the success tool + await client.request( + { + method: "tools/call", + params: { + name: "success-tool", + }, + }, + CallToolResultSchema, + ); + + // Find the completed event + const completedEvent = events.find(e => + e.capabilityType === "tool" && + e.capabilityName === "success-tool" && + e.action === "completed" + ) as Extract; + + expect(completedEvent).toBeDefined(); + expect(completedEvent.durationMs).toBeDefined(); + expect(completedEvent.durationMs).toBeGreaterThan(0); + + // Clear events + events.length = 0; + + // Call the error tool + await client.request( + { + method: "tools/call", + params: { + name: "error-tool", + }, + }, + CallToolResultSchema, + ); + + // Find the error event + const errorEvent = events.find(e => + e.capabilityType === "tool" && + e.capabilityName === "error-tool" && + e.action === "error" + ) as Extract; + + expect(errorEvent).toBeDefined(); + expect(errorEvent.durationMs).toBeDefined(); + expect(errorEvent.durationMs).toBeGreaterThan(0); + + subscription.close(); + }); }); describe("resource()", () => { @@ -2516,3 +2605,322 @@ describe("prompt()", () => { expect(result.completion.total).toBe(1); }); }); + +describe("CapabilityEvents", () => { + test("should emit capability events when registering and interacting with tools", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const events: Array = []; + const subscription = mcpServer.onCapabilityChange((event) => { + events.push(event); + }); + + // Register a tool (should trigger "added" event) + const tool = mcpServer.tool("test-tool", async () => ({ + content: [{ type: "text", text: "Test response" }], + })); + + expect(events.length).toBeGreaterThan(0); + expect(events[0]).toMatchObject({ + serverInfo: { name: "test server", version: "1.0" }, + capabilityType: "tool", + capabilityName: "test-tool", + action: "added", + }); + + // Update the tool (should trigger "updated" event) + tool.update({ + description: "Updated description", + }); + + expect(events.some(e => + e.capabilityType === "tool" && + e.capabilityName === "test-tool" && + e.action === "updated" + )).toBe(true); + + // Disable the tool (should trigger "disabled" event) + tool.disable(); + + expect(events.some(e => + e.capabilityType === "tool" && + e.capabilityName === "test-tool" && + e.action === "disabled" + )).toBe(true); + + // Enable the tool (should trigger "enabled" event) + tool.enable(); + + expect(events.some(e => + e.capabilityType === "tool" && + e.capabilityName === "test-tool" && + e.action === "enabled" + )).toBe(true); + + // Clean up + subscription.close(); + }); + + test("should emit capability events when tools are invoked", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const events: Array = []; + const subscription = mcpServer.onCapabilityChange((event) => { + events.push(event); + }); + + mcpServer.tool("test-tool", async () => ({ + content: [{ type: "text", text: "Test response" }], + })); + + // Clear events from registration + events.length = 0; + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + await client.request( + { + method: "tools/call", + params: { + name: "test-tool", + }, + }, + CallToolResultSchema, + ); + + // Should have "invoked" and "completed" events + expect(events.some(e => + e.capabilityType === "tool" && + e.capabilityName === "test-tool" && + e.action === "invoked" + )).toBe(true); + + expect(events.some(e => + e.capabilityType === "tool" && + e.capabilityName === "test-tool" && + e.action === "completed" + )).toBe(true); + + // The invoked and completed events should have the same invocationIndex + const invokedEvent = events.find(e => e.action === "invoked"); + const completedEvent = events.find(e => e.action === "completed"); + expect(invokedEvent?.invocationIndex).toBe(completedEvent?.invocationIndex); + + subscription.close(); + }); + + test("should emit capability events for resources", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const events: Array = []; + const subscription = mcpServer.onCapabilityChange((event) => { + events.push(event); + }); + + // Register a resource + const resource = mcpServer.resource("test-resource", "test://resource", async () => ({ + contents: [{ uri: "test://resource", text: "Test content" }], + })); + + expect(events.some(e => + e.capabilityType === "resource" && + e.capabilityName === "test-resource" && + e.action === "added" + )).toBe(true); + + // Clear events from registration + events.length = 0; + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Read the resource + await client.request( + { + method: "resources/read", + params: { + uri: "test://resource", + }, + }, + ReadResourceResultSchema, + ); + + // Should have "invoked" event + expect(events.some(e => + e.capabilityType === "resource" && + e.capabilityName === "test://resource" && + e.action === "invoked" + )).toBe(true); + + // Clear events + events.length = 0; + + // Remove the resource + resource.remove(); + + // Should have "removed" event + expect(events.some(e => + e.capabilityType === "resource" && + e.capabilityName === "test-resource" && + e.action === "removed" + )).toBe(true); + + subscription.close(); + }); + + test("should emit capability events for prompts", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const events: Array = []; + const subscription = mcpServer.onCapabilityChange((event) => { + events.push(event); + }); + + // Register a prompt + mcpServer.prompt("test-prompt", async () => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: "Test response", + }, + }, + ], + })); + + expect(events.some(e => + e.capabilityType === "prompt" && + e.capabilityName === "test-prompt" && + e.action === "added" + )).toBe(true); + + // Clear events from registration + events.length = 0; + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Get the prompt + await client.request( + { + method: "prompts/get", + params: { + name: "test-prompt", + }, + }, + GetPromptResultSchema, + ); + + // Should have "invoked" and "completed" events + expect(events.some(e => + e.capabilityType === "prompt" && + e.capabilityName === "test-prompt" && + e.action === "invoked" + )).toBe(true); + + expect(events.some(e => + e.capabilityType === "prompt" && + e.capabilityName === "test-prompt" && + e.action === "completed" + )).toBe(true); + + subscription.close(); + }); + + test("should emit error events when tool execution fails", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const events: Array = []; + const subscription = mcpServer.onCapabilityChange((event) => { + // Only capture error events + if (event.action === "error") { + events.push(event); + } + }); + + mcpServer.tool("error-tool", async () => { + throw new Error("Simulated error"); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Call the tool that throws an error + await client.request( + { + method: "tools/call", + params: { + name: "error-tool", + }, + }, + CallToolResultSchema, + ); + + // Should have error event + expect(events.length).toBe(1); + expect(events[0]).toMatchObject({ + capabilityType: "tool", + capabilityName: "error-tool", + action: "error", + }); + + // Type assert to the specific variant of CapabilityEvent that has the error property + const errorEvent = events[0] as Extract; + expect(errorEvent.error).toBeDefined(); + + subscription.close(); + }); +}); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 652f9774..3627892b 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -44,6 +44,7 @@ import { Completable, CompletableDef } from "./completable.js"; import { UriTemplate, Variables } from "../shared/uriTemplate.js"; import { RequestHandlerExtra } from "../shared/protocol.js"; import { Transport } from "../shared/transport.js"; +import { createEventNotifier } from "../shared/eventNotifier.js"; /** * High-level MCP server that provides a simpler API for working with resources, tools, and prompts. @@ -63,6 +64,11 @@ export class McpServer { private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + private _onCapabilityChange = createEventNotifier(); + private _resourceInvocationIndex = 0; + private _toolInvocationIndex = 0; + private _promptInvocationIndex = 0; + constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); } @@ -80,16 +86,31 @@ export class McpServer { * Closes the connection. */ async close(): Promise { + this._onCapabilityChange.close(); await this.server.close(); } + /** + * Event notifier for capability changes. Listeners will be notified when capabilities are added, updated, removed, + * enabled, disabled, invoked, completed, or when errors occur. + * + * @example + * const subscription = server.onCapabilityChange((event) => { + * console.log(`${event.capabilityType} ${event.capabilityName} ${event.action}`); + * }); + * + * // Later, to stop listening: + * subscription.close(); + */ + public readonly onCapabilityChange = this._onCapabilityChange.onEvent; + private _toolHandlersInitialized = false; private setToolRequestHandlers() { if (this._toolHandlersInitialized) { return; } - + this.server.assertCanSetRequestHandler( ListToolsRequestSchema.shape.method.value, ); @@ -135,11 +156,35 @@ export class McpServer { ); } + const invocationIndex = this._toolInvocationIndex++; + const startTime = performance.now(); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "invoked", + invocationIndex, + arguments: request.params.arguments, + })); + if (!tool.enabled) { - throw new McpError( + const error = new McpError( ErrorCode.InvalidParams, `Tool ${request.params.name} disabled`, ); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; } if (tool.inputSchema) { @@ -147,17 +192,51 @@ export class McpServer { request.params.arguments, ); if (!parseResult.success) { - throw new McpError( + const error = new McpError( ErrorCode.InvalidParams, `Invalid arguments for tool ${request.params.name}: ${parseResult.error.message}`, ); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; } const args = parseResult.data; const cb = tool.callback as ToolCallback; try { - return await Promise.resolve(cb(args, extra)); + return await Promise.resolve(cb(args, extra)).then((result) => { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "completed", + invocationIndex, + result, + durationMs: performance.now() - startTime, + })); + + return result; + }); } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + return { content: [ { @@ -171,8 +250,30 @@ export class McpServer { } else { const cb = tool.callback as ToolCallback; try { - return await Promise.resolve(cb(extra)); + const result = await Promise.resolve(cb(extra)); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "completed", + invocationIndex, + result, + durationMs: performance.now() - startTime, + })); + + return result; } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + return { content: [ { @@ -235,24 +336,79 @@ export class McpServer { ); } + const invocationIndex = this._promptInvocationIndex++; + const startTime = performance.now(); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: ref.name, + action: "invoked", + invocationIndex, + arguments: request.params.argument.name, + })); + if (!prompt.enabled) { - throw new McpError( + const error = new McpError( ErrorCode.InvalidParams, `Prompt ${ref.name} disabled`, ); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: ref.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; } if (!prompt.argsSchema) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: ref.name, + action: "completed", + invocationIndex, + result: EMPTY_COMPLETION_RESULT, + durationMs: performance.now() - startTime, + })); + return EMPTY_COMPLETION_RESULT; } const field = prompt.argsSchema.shape[request.params.argument.name]; if (!(field instanceof Completable)) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: ref.name, + action: "completed", + invocationIndex, + result: EMPTY_COMPLETION_RESULT, + durationMs: performance.now() - startTime, + })); + return EMPTY_COMPLETION_RESULT; } const def: CompletableDef = field._def; const suggestions = await def.complete(request.params.argument.value); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: ref.name, + action: "completed", + invocationIndex, + result: suggestions, + durationMs: performance.now() - startTime, + })); + return createCompletionResult(suggestions); } @@ -264,8 +420,21 @@ export class McpServer { (t) => t.resourceTemplate.uriTemplate.toString() === ref.uri, ); + const invocationIndex = this._resourceInvocationIndex++; + const startTime = performance.now(); + if (!template) { if (this._registeredResources[ref.uri]) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: ref.uri, + action: "completed", + invocationIndex, + result: EMPTY_COMPLETION_RESULT, + durationMs: performance.now() - startTime, + })); + // Attempting to autocomplete a fixed resource URI is not an error in the spec (but probably should be). return EMPTY_COMPLETION_RESULT; } @@ -280,11 +449,46 @@ export class McpServer { request.params.argument.name, ); if (!completer) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: ref.uri, + action: "completed", + invocationIndex, + result: EMPTY_COMPLETION_RESULT, + durationMs: performance.now() - startTime, + })); + return EMPTY_COMPLETION_RESULT; } - const suggestions = await completer(request.params.argument.value); - return createCompletionResult(suggestions); + try { + const suggestions = await completer(request.params.argument.value); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: ref.uri, + action: "completed", + invocationIndex, + result: suggestions, + durationMs: performance.now() - startTime, + })); + + return createCompletionResult(suggestions); + } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: ref.uri, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; + } } private _resourceHandlersInitialized = false; @@ -367,13 +571,62 @@ export class McpServer { // First check for exact resource match const resource = this._registeredResources[uri.toString()]; if (resource) { + const invocationIndex = this._resourceInvocationIndex++; + const startTime = performance.now(); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "invoked", + invocationIndex, + })); + if (!resource.enabled) { - throw new McpError( + const error = new McpError( ErrorCode.InvalidParams, `Resource ${uri} disabled`, ); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; + } + try { + const result = await resource.readCallback(uri, extra); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "completed", + invocationIndex, + result, + durationMs: performance.now() - startTime, + })); + + return result; + } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; } - return resource.readCallback(uri, extra); } // Then check templates @@ -384,7 +637,44 @@ export class McpServer { uri.toString(), ); if (variables) { - return template.readCallback(uri, variables, extra); + const invocationIndex = this._resourceInvocationIndex++; + const startTime = performance.now(); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "invoked", + invocationIndex, + })); + + try { + const result = await template.readCallback(uri, variables, extra); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "completed", + invocationIndex, + result, + durationMs: performance.now() - startTime, + })); + + return result; + } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; + } } } @@ -396,7 +686,7 @@ export class McpServer { ); this.setCompletionRequestHandler(); - + this._resourceHandlersInitialized = true; } @@ -450,11 +740,35 @@ export class McpServer { ); } + const invocationIndex = this._promptInvocationIndex++; + const startTime = performance.now(); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "invoked", + invocationIndex, + arguments: request.params.arguments, + })); + if (!prompt.enabled) { - throw new McpError( + const error = new McpError( ErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`, ); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; } if (prompt.argsSchema) { @@ -462,24 +776,90 @@ export class McpServer { request.params.arguments, ); if (!parseResult.success) { - throw new McpError( + const error = new McpError( ErrorCode.InvalidParams, `Invalid arguments for prompt ${request.params.name}: ${parseResult.error.message}`, ); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; } const args = parseResult.data; const cb = prompt.callback as PromptCallback; - return await Promise.resolve(cb(args, extra)); + + try { + const result = await Promise.resolve(cb(args, extra)); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "completed", + invocationIndex, + result, + durationMs: performance.now() - startTime, + })); + + return result; + } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; + } } else { const cb = prompt.callback as PromptCallback; - return await Promise.resolve(cb(extra)); + + try { + const result = await Promise.resolve(cb(extra)); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "completed", + invocationIndex, + result, + durationMs: performance.now() - startTime, + })); + + return result; + } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; + } } - }, + } ); this.setCompletionRequestHandler(); - + this._promptHandlersInitialized = true; } @@ -541,23 +921,128 @@ export class McpServer { metadata, readCallback: readCallback as ReadResourceCallback, enabled: true, - disable: () => registeredResource.update({ enabled: false }), - enable: () => registeredResource.update({ enabled: true }), - remove: () => registeredResource.update({ uri: null }), + disable: () => { + if (!registeredResource.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "disabled", + })); + + registeredResource.update({ enabled: false }); + }, + enable: () => { + if (registeredResource.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "enabled", + })); + + registeredResource.update({ enabled: true }); + }, + remove: () => { + if (uriOrTemplate === null) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "removed", + })); + + 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 + let added = false; + let removed = false; + let updated = false; + let enabled = false; + + if ( + typeof updates.uri !== "undefined" && + updates.uri !== uriOrTemplate + ) { + removed = true; + delete this._registeredResources[uriOrTemplate]; + + if (updates.uri) { + added = true; + 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() + + if (typeof updates.name !== "undefined" && updates.name !== name) { + updated = true; + registeredResource.name = updates.name; + } + + if (typeof updates.metadata !== "undefined" && updates.metadata !== metadata) { + updated = true; + registeredResource.metadata = updates.metadata; + } + + if (typeof updates.callback !== "undefined" && updates.callback !== registeredResource.readCallback) { + updated = true; + registeredResource.readCallback = updates.callback; + } + + if (typeof updates.enabled !== "undefined" && updates.enabled !== registeredResource.enabled) { + enabled = true; + registeredResource.enabled = updates.enabled; + } + + if (removed) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "removed", + })); + } + + if (added) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: registeredResource.name, + action: "added", + })); + } + + if (updated) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: registeredResource.name, + action: "updated", + })); + } + + if (enabled) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: registeredResource.name, + action: registeredResource.enabled ? "enabled" : "disabled", + })); + } + this.sendResourceListChanged(); }, }; this._registeredResources[uriOrTemplate] = registeredResource; + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "added", + })); + this.setResourceRequestHandlers(); this.sendResourceListChanged(); return registeredResource; @@ -571,23 +1056,136 @@ export class McpServer { metadata, readCallback: readCallback as ReadResourceTemplateCallback, enabled: true, - disable: () => registeredResourceTemplate.update({ enabled: false }), - enable: () => registeredResourceTemplate.update({ enabled: true }), - remove: () => registeredResourceTemplate.update({ name: null }), + disable: () => { + if (!registeredResourceTemplate.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "disabled", + })); + + registeredResourceTemplate.update({ enabled: false }); + }, + enable: () => { + if (registeredResourceTemplate.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "enabled", + })); + + registeredResourceTemplate.update({ enabled: true }); + }, + remove: () => { + if (name === null) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "removed", + })); + + registeredResourceTemplate.update({ name: null }); + }, update: (updates) => { + let added = false; + let removed = false; + let updated = false; + let enabled = false; + if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredResourceTemplates[name] - if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate + removed = true; + delete this._registeredResourceTemplates[name]; + + if (updates.name) { + added = true; + 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() + + if (typeof updates.template !== "undefined" && updates.template !== registeredResourceTemplate.resourceTemplate) { + updated = true; + registeredResourceTemplate.resourceTemplate = updates.template; + } + + if ( + typeof updates.metadata !== "undefined" && + updates.metadata !== metadata + ) { + updated = true; + registeredResourceTemplate.metadata = updates.metadata; + } + + if ( + typeof updates.callback !== "undefined" && + updates.callback !== registeredResourceTemplate.readCallback + ) { + updated = true; + registeredResourceTemplate.readCallback = updates.callback; + } + + if ( + typeof updates.enabled !== "undefined" && + updates.enabled !== registeredResourceTemplate.enabled + ) { + enabled = true; + registeredResourceTemplate.enabled = updates.enabled; + } + + if (removed) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "removed", + })); + } + + if (added) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: updates.name || name, + action: "added", + })); + } + + if (updated) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: updates.name || name, + action: "updated", + })); + } + + if (enabled) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: updates.name || name, + action: registeredResourceTemplate.enabled ? "enabled" : "disabled", + })); + } + + this.sendResourceListChanged(); }, }; this._registeredResourceTemplates[name] = registeredResourceTemplate; + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "added", + })); + this.setResourceRequestHandlers(); this.sendResourceListChanged(); return registeredResourceTemplate; @@ -645,23 +1243,138 @@ export class McpServer { 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 }), + disable: () => { + if (!registeredTool.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: name, + action: "disabled", + })); + + registeredTool.update({ enabled: false }); + }, + enable: () => { + if (registeredTool.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: name, + action: "enabled", + })); + + registeredTool.update({ enabled: true }); + }, + remove: () => { + if (name === null) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: name, + action: "removed", + })); + + registeredTool.update({ name: null }); + }, update: (updates) => { + let added = false; + let removed = false; + let updated = false; + let enabled = false; + if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredTools[name] - if (updates.name) this._registeredTools[updates.name] = registeredTool + removed = true; + delete this._registeredTools[name]; + + if (updates.name) { + added = true; + 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() + + if ( + typeof updates.description !== "undefined" && + updates.description !== description + ) { + updated = true; + registeredTool.description = updates.description; + } + + if ( + typeof updates.paramsSchema !== "undefined" && + updates.paramsSchema !== paramsSchema + ) { + updated = true; + registeredTool.inputSchema = z.object(updates.paramsSchema); + } + + if ( + typeof updates.callback !== "undefined" && + updates.callback !== registeredTool.callback + ) { + updated = true; + registeredTool.callback = updates.callback; + } + + if ( + typeof updates.enabled !== "undefined" && + updates.enabled !== registeredTool.enabled + ) { + enabled = true; + registeredTool.enabled = updates.enabled; + } + + if (removed) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: name, + action: "removed", + })); + } + + if (added) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: updates.name || name, + action: "added", + })); + } + + if (updated) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: updates.name || name, + action: "updated", + })); + } + + if (enabled) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: updates.name || name, + action: registeredTool.enabled ? "enabled" : "disabled", + })); + } + + this.sendToolListChanged(); }, }; this._registeredTools[name] = registeredTool; + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: name, + action: "added", + })); + this.setToolRequestHandlers(); this.sendToolListChanged() @@ -718,23 +1431,138 @@ export class McpServer { 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 }), + disable: () => { + if (!registeredPrompt.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: name, + action: "disabled", + })); + + registeredPrompt.update({ enabled: false }); + }, + enable: () => { + if (registeredPrompt.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: name, + action: "enabled", + })); + + registeredPrompt.update({ enabled: true }); + }, + remove: () => { + if (name === null) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: name, + action: "removed", + })); + + registeredPrompt.update({ name: null }); + }, update: (updates) => { + let added = false; + let removed = false; + let updated = false; + let enabled = false; + if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredPrompts[name] - if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt + removed = true; + delete this._registeredPrompts[name]; + + if (updates.name) { + added = true; + 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() + + if ( + typeof updates.description !== "undefined" && + updates.description !== description + ) { + updated = true; + registeredPrompt.description = updates.description; + } + + if ( + typeof updates.argsSchema !== "undefined" && + updates.argsSchema !== argsSchema + ) { + updated = true; + registeredPrompt.argsSchema = z.object(updates.argsSchema); + } + + if ( + typeof updates.callback !== "undefined" && + updates.callback !== registeredPrompt.callback + ) { + updated = true; + registeredPrompt.callback = updates.callback; + } + + if ( + typeof updates.enabled !== "undefined" && + updates.enabled !== registeredPrompt.enabled + ) { + enabled = true; + registeredPrompt.enabled = updates.enabled; + } + + if (removed) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: name, + action: "removed", + })); + } + + if (added) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: updates.name || name, + action: "added", + })); + } + + if (updated) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: updates.name || name, + action: "updated", + })); + } + + if (enabled) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: updates.name || name, + action: registeredPrompt.enabled ? "enabled" : "disabled", + })); + } + + this.sendPromptListChanged(); }, }; this._registeredPrompts[name] = registeredPrompt; + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: name, + action: "added", + })); + this.setPromptRequestHandlers(); this.sendPromptListChanged() @@ -970,3 +1798,30 @@ const EMPTY_COMPLETION_RESULT: CompleteResult = { hasMore: false, }, }; + +export type CapabilityEvent = { + readonly serverInfo: { readonly name: string; readonly version: string }; + readonly capabilityType: "resource" | "tool" | "prompt"; + readonly capabilityName: string; +} & ( + | { + readonly action: "added" | "updated" | "removed" | "enabled" | "disabled"; + } + | { + readonly action: "invoked"; + readonly invocationIndex: number; + readonly arguments?: unknown; + } + | { + readonly action: "completed"; + readonly invocationIndex: number; + readonly result?: unknown; + readonly durationMs?: number; + } + | { + readonly action: "error"; + readonly invocationIndex: number; + readonly error: unknown; + readonly durationMs?: number; + } +);