diff --git a/src/everything/server/index.ts b/src/everything/server/index.ts index a7e18f89..4f9bb1f2 100644 --- a/src/everything/server/index.ts +++ b/src/everything/server/index.ts @@ -10,6 +10,7 @@ import { registerTools } from "../tools/index.js"; import { registerResources } from "../resources/index.js"; import { registerPrompts } from "../prompts/index.js"; import { stopSimulatedLogging } from "./logging.js"; +import { setRootsListChangedHandler } from "./roots.js"; // Everything Server factory export const createServer = () => { @@ -48,9 +49,13 @@ export const createServer = () => { // Set resource subscription handlers setSubscriptionHandlers(server); - // Return server instance and cleanup function + // Return server instance, client connection handler, and cleanup function return { server, + clientConnected: (sessionId?: string) => { + // Set the roots list changed handler + setRootsListChangedHandler(server, sessionId); + }, cleanup: (sessionId?: string) => { // Stop any simulated logging or resource updates that may have been initiated. stopSimulatedLogging(sessionId); diff --git a/src/everything/server/roots.ts b/src/everything/server/roots.ts new file mode 100644 index 00000000..3525df17 --- /dev/null +++ b/src/everything/server/roots.ts @@ -0,0 +1,63 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + Root, + RootsListChangedNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +// Track roots by session id +const roots: Map = new Map< + string | undefined, + Root[] +>(); + +/** + * Sets a handler for the "RootsListChanged" notification from the client. + * + * This handler updates the local roots list when notified and logs relevant + * acknowledgement or error. + * + * @param {McpServer} mcpServer - The instance of the McpServer managing server communication. + * @param {string | undefined} sessionId - An optional session ID used for logging purposes. + */ +export const setRootsListChangedHandler = ( + mcpServer: McpServer, + sessionId?: string +) => { + const server = mcpServer.server; + + // Set the notification handler + server.setNotificationHandler( + RootsListChangedNotificationSchema, + async () => { + try { + // Request the updated roots list from the client + const response = await server.listRoots(); + if (response && "roots" in response) { + // Store the roots list for this client + roots.set(sessionId, response.roots); + + // Notify the client of roots received + await server.sendLoggingMessage( + { + level: "info", + logger: "everything-server", + data: `Roots updated: ${response.roots.length} root(s) received from client`, + }, + sessionId + ); + } + } catch (error) { + await server.sendLoggingMessage( + { + level: "error", + logger: "everything-server", + data: `Failed to request roots from client: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + sessionId + ); + } + } + ); +}; diff --git a/src/everything/tools/get-roots-list.ts b/src/everything/tools/get-roots-list.ts new file mode 100644 index 00000000..069635d1 --- /dev/null +++ b/src/everything/tools/get-roots-list.ts @@ -0,0 +1,76 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +// Tool configuration +const name = "get-roots-list"; +const config = { + title: "Get Roots List Tool", + description: + "Lists the current MCP roots provided by the client. Demonstrates the roots protocol capability even though this server doesn't access files.", + inputSchema: {}, +}; + +/** + * Registers the 'get-roots-list' tool with the given MCP server. + * + * If the client does not support the roots protocol, the tool is not registered. + * + * The registered tool interacts with the MCP roots protocol, which enables the server to access information about + * the client's workspace directories or file system roots. When supported by the client, the server retrieves + * and formats the current list of roots for display. + * + * Key behaviors: + * - Determines whether the connected MCP client supports the roots protocol by checking client capabilities. + * - Fetches and formats the list of roots, including their names and URIs, if supported by the client. + * - Handles cases where roots are not supported, or no roots are currently provided, with explanatory messages. + * + * @param {McpServer} server - The server instance interacting with the MCP client and managing the roots protocol. + */ +export const registerGetRootsListTool = (server: McpServer) => { + const clientSupportsRoots = + server.server.getClientCapabilities()?.roots?.listChanged; + if (!clientSupportsRoots) { + server.registerTool( + name, + config, + async (args, extra): Promise => { + const currentRoots = (await server.server.listRoots()).roots; + if (currentRoots.length === 0) { + return { + content: [ + { + type: "text", + text: + "The client supports roots but no roots are currently configured.\n\n" + + "This could mean:\n" + + "1. The client hasn't provided any roots yet\n" + + "2. The client provided an empty roots list\n" + + "3. The roots configuration is still being loaded", + }, + ], + }; + } + + const rootsList = currentRoots + .map((root, index) => { + return `${index + 1}. ${root.name || "Unnamed Root"}\n URI: ${ + root.uri + }`; + }) + .join("\n\n"); + + return { + content: [ + { + type: "text", + text: + `Current MCP Roots (${currentRoots.length} total):\n\n${rootsList}\n\n` + + "Note: This server demonstrates the roots protocol capability but doesn't actually access files. " + + "The roots are provided by the MCP client and can be used by servers that need file system access.", + }, + ], + }; + } + ); + } +}; diff --git a/src/everything/tools/index.ts b/src/everything/tools/index.ts index bb950e0a..27e87df7 100644 --- a/src/everything/tools/index.ts +++ b/src/everything/tools/index.ts @@ -4,6 +4,7 @@ import { registerEchoTool } from "./echo.js"; import { registerGetEnvTool } from "./get-env.js"; import { registerGetResourceLinksTool } from "./get-resource-links.js"; import { registerGetResourceReferenceTool } from "./get-resource-reference.js"; +import { registerGetRootsListTool } from "./get-roots-list.js"; import { registerGetStructuredContentTool } from "./get-structured-content.js"; import { registerGetSumTool } from "./get-sum.js"; import { registerGetTinyImageTool } from "./get-tiny-image.js"; @@ -11,8 +12,8 @@ import { registerGZipFileAsResourceTool } from "./gzip-file-as-resource.js"; import { registerLongRunningOperationTool } from "./long-running-operation.js"; import { registerToggleLoggingTool } from "./toggle-logging.js"; import { registerToggleSubscriberUpdatesTool } from "./toggle-subscriber-updates.js"; -import { registerTriggerSamplingRequestTool } from "./trigger-sampling-request.js"; import { registerTriggerElicitationRequestTool } from "./trigger-elicitation-request.js"; +import { registerTriggerSamplingRequestTool } from "./trigger-sampling-request.js"; /** * Register the tools with the MCP server. @@ -24,6 +25,7 @@ export const registerTools = (server: McpServer) => { registerGetEnvTool(server); registerGetResourceLinksTool(server); registerGetResourceReferenceTool(server); + registerGetRootsListTool(server); registerGetStructuredContentTool(server); registerGetSumTool(server); registerGetTinyImageTool(server); diff --git a/src/everything/tools/trigger-elicitation-request.ts b/src/everything/tools/trigger-elicitation-request.ts index cb112122..2deefdc3 100644 --- a/src/everything/tools/trigger-elicitation-request.ts +++ b/src/everything/tools/trigger-elicitation-request.ts @@ -39,110 +39,122 @@ export const registerTriggerElicitationRequestTool = (server: McpServer) => { params: { message: "Please provide inputs for the following fields:", requestedSchema: { - type: 'object', + type: "object", properties: { name: { - title: 'String', - type: 'string', - description: 'Your full, legal name', + title: "String", + type: "string", + description: "Your full, legal name", }, check: { - title: 'Boolean', - type: 'boolean', - description: 'Agree to the terms and conditions', + title: "Boolean", + type: "boolean", + description: "Agree to the terms and conditions", }, firstLine: { - title: 'String with default', - type: 'string', - description: 'Favorite first line of a story', - default: 'It was a dark and stormy night.', + title: "String with default", + type: "string", + description: "Favorite first line of a story", + default: "It was a dark and stormy night.", }, email: { - title: 'String with email format', - type: 'string', - format: 'email', - description: 'Your email address (will be verified, and never shared with anyone else)', + title: "String with email format", + type: "string", + format: "email", + description: + "Your email address (will be verified, and never shared with anyone else)", }, homepage: { - type: 'string', - format: 'uri', - title: 'String with uri format', - description: 'Portfolio / personal website', + type: "string", + format: "uri", + title: "String with uri format", + description: "Portfolio / personal website", }, birthdate: { - title: 'String with date format', - type: 'string', - format: 'date', - description: 'Your date of birth', + title: "String with date format", + type: "string", + format: "date", + description: "Your date of birth", }, integer: { - title: 'Integer', - type: 'integer', - description: 'Your favorite integer (do not give us your phone number, pin, or other sensitive info)', + title: "Integer", + type: "integer", + description: + "Your favorite integer (do not give us your phone number, pin, or other sensitive info)", minimum: 1, maximum: 100, default: 42, }, number: { - title: 'Number in range 1-1000', - type: 'number', - description: 'Favorite number (there are no wrong answers)', + title: "Number in range 1-1000", + type: "number", + description: "Favorite number (there are no wrong answers)", minimum: 0, maximum: 1000, default: 3.14, }, untitledSingleSelectEnum: { - type: 'string', - title: 'Untitled Single Select Enum', - description: 'Choose your favorite friend', - enum: ['Monica', 'Rachel', 'Joey', 'Chandler', 'Ross', 'Phoebe'], - default: 'Monica' + type: "string", + title: "Untitled Single Select Enum", + description: "Choose your favorite friend", + enum: [ + "Monica", + "Rachel", + "Joey", + "Chandler", + "Ross", + "Phoebe", + ], + default: "Monica", }, untitledMultipleSelectEnum: { - type: 'array', - title: 'Untitled Multiple Select Enum', - description: 'Choose your favorite instruments', + type: "array", + title: "Untitled Multiple Select Enum", + description: "Choose your favorite instruments", minItems: 1, maxItems: 3, - items: { type: 'string', enum: ['Guitar', 'Piano', 'Violin', 'Drums', 'Bass'] }, - default: ['Guitar'] + items: { + type: "string", + enum: ["Guitar", "Piano", "Violin", "Drums", "Bass"], + }, + default: ["Guitar"], }, titledSingleSelectEnum: { - type: 'string', - title: 'Titled Single Select Enum', - description: 'Choose your favorite hero', + type: "string", + title: "Titled Single Select Enum", + description: "Choose your favorite hero", oneOf: [ - { const: 'hero-1', title: 'Superman' }, - { const: 'hero-2', title: 'Green Lantern' }, - { const: 'hero-3', title: 'Wonder Woman' } + { const: "hero-1", title: "Superman" }, + { const: "hero-2", title: "Green Lantern" }, + { const: "hero-3", title: "Wonder Woman" }, ], - default: 'hero-1' + default: "hero-1", }, titledMultipleSelectEnum: { - type: 'array', - title: 'Titled Multiple Select Enum', - description: 'Choose your favorite types of fish', + type: "array", + title: "Titled Multiple Select Enum", + description: "Choose your favorite types of fish", minItems: 1, maxItems: 3, items: { anyOf: [ - { const: 'fish-1', title: 'Tuna' }, - { const: 'fish-2', title: 'Salmon' }, - { const: 'fish-3', title: 'Trout' } - ] + { const: "fish-1", title: "Tuna" }, + { const: "fish-2", title: "Salmon" }, + { const: "fish-3", title: "Trout" }, + ], }, - default: ['fish-1'] + default: ["fish-1"], }, legacyTitledEnum: { - type: 'string', - title: 'Legacy Titled Single Select Enum', - description: 'Choose your favorite type of pet', - enum: ['pet-1', 'pet-2', 'pet-3', 'pet-4', 'pet-5'], - enumNames: ['Cats', 'Dogs', 'Birds', 'Fish', 'Reptiles'], - default: 'pet-1', - } + type: "string", + title: "Legacy Titled Single Select Enum", + description: "Choose your favorite type of pet", + enum: ["pet-1", "pet-2", "pet-3", "pet-4", "pet-5"], + enumNames: ["Cats", "Dogs", "Birds", "Fish", "Reptiles"], + default: "pet-1", + }, }, - required: ['name'], + required: ["name"], }, }, }, diff --git a/src/everything/transports/sse.ts b/src/everything/transports/sse.ts index 4b615150..a8c3fc4b 100644 --- a/src/everything/transports/sse.ts +++ b/src/everything/transports/sse.ts @@ -21,7 +21,7 @@ const transports: Map = new Map< app.get("/sse", async (req, res) => { let transport: SSEServerTransport; - const { server, cleanup } = createServer(); + const { server, clientConnected, cleanup } = createServer(); if (req?.query?.sessionId) { const sessionId = req?.query?.sessionId as string; @@ -38,6 +38,8 @@ app.get("/sse", async (req, res) => { // Connect server to transport await server.connect(transport); const sessionId = transport.sessionId; + clientConnected(sessionId); + console.error("Client Connected: ", sessionId); // Handle close of connection diff --git a/src/everything/transports/stdio.ts b/src/everything/transports/stdio.ts index 0624ee34..0e3b1726 100644 --- a/src/everything/transports/stdio.ts +++ b/src/everything/transports/stdio.ts @@ -7,10 +7,10 @@ console.error("Starting default (STDIO) server..."); async function main() { const transport = new StdioServerTransport(); - const { server, cleanup } = createServer(); + const { server, clientConnected, cleanup } = createServer(); await server.connect(transport); - + clientConnected(); // Cleanup on exit process.on("SIGINT", async () => { await server.close(); diff --git a/src/everything/transports/streamableHttp.ts b/src/everything/transports/streamableHttp.ts index ad0d41cc..c0181cf5 100644 --- a/src/everything/transports/streamableHttp.ts +++ b/src/everything/transports/streamableHttp.ts @@ -35,7 +35,7 @@ app.post("/mcp", async (req: Request, res: Response) => { // Reuse existing transport transport = transports.get(sessionId)!; } else if (!sessionId) { - const { server, cleanup } = createServer(); + const { server, clientConnected, cleanup } = createServer(); // New initialization request const eventStore = new InMemoryEventStore(); @@ -47,6 +47,7 @@ app.post("/mcp", async (req: Request, res: Response) => { // This avoids race conditions where requests might come in before the session is stored console.log(`Session initialized with ID: ${sessionId}`); transports.set(sessionId, transport); + clientConnected(sessionId); }, });