[WIP] Refactor everything server to be more modular and use recommended APIs.

Adding Trigger Elicitation Request and Get Roots List tools

* Updated architecture.md

* Added roots.ts
  - tracks roots by sessionId
  - setRootsListChangedHandler
    - listens for roots changed notification from the client
      - updates the roots map by sessionId
      - sends log notification or error to the client

* In server/index.ts
  - import setRootsListChangedHandler
  - in clientConnected callback
    - call setRootsListChangedHandler passing server and sessionId

* In sse.ts, stdio.ts, and streamableHttp.ts
  - receive clientConnected from server factory
  - call clientConnected when server is connected to transport
* Added get-roots-list.ts
  - registerGetRootsListTool
    - Registers the 'get-roots-list' tool with the given MCP server.

* Added trigger-elicitation-request.ts
  - registerTriggerElicitationRequestTool
    - registered tool sends an elicitation request that exercises all supported field types

* In tools/index.ts
  - imports registerTriggerElicitationRequestTool and registerGetRootsListTool
  - in registerTools
    - call registerTriggerElicitationRequestTool and registerGetRootsListTool, passing server
This commit is contained in:
cliffhall
2025-12-11 20:25:37 -05:00
parent ea6fe271cd
commit 18ef6aa69b
8 changed files with 229 additions and 68 deletions

View File

@@ -10,6 +10,7 @@ import { registerTools } from "../tools/index.js";
import { registerResources } from "../resources/index.js"; import { registerResources } from "../resources/index.js";
import { registerPrompts } from "../prompts/index.js"; import { registerPrompts } from "../prompts/index.js";
import { stopSimulatedLogging } from "./logging.js"; import { stopSimulatedLogging } from "./logging.js";
import { setRootsListChangedHandler } from "./roots.js";
// Everything Server factory // Everything Server factory
export const createServer = () => { export const createServer = () => {
@@ -48,9 +49,13 @@ export const createServer = () => {
// Set resource subscription handlers // Set resource subscription handlers
setSubscriptionHandlers(server); setSubscriptionHandlers(server);
// Return server instance and cleanup function // Return server instance, client connection handler, and cleanup function
return { return {
server, server,
clientConnected: (sessionId?: string) => {
// Set the roots list changed handler
setRootsListChangedHandler(server, sessionId);
},
cleanup: (sessionId?: string) => { cleanup: (sessionId?: string) => {
// Stop any simulated logging or resource updates that may have been initiated. // Stop any simulated logging or resource updates that may have been initiated.
stopSimulatedLogging(sessionId); stopSimulatedLogging(sessionId);

View File

@@ -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<string | undefined, Root[]> = 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
);
}
}
);
};

View File

@@ -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<CallToolResult> => {
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.",
},
],
};
}
);
}
};

View File

@@ -4,6 +4,7 @@ import { registerEchoTool } from "./echo.js";
import { registerGetEnvTool } from "./get-env.js"; import { registerGetEnvTool } from "./get-env.js";
import { registerGetResourceLinksTool } from "./get-resource-links.js"; import { registerGetResourceLinksTool } from "./get-resource-links.js";
import { registerGetResourceReferenceTool } from "./get-resource-reference.js"; import { registerGetResourceReferenceTool } from "./get-resource-reference.js";
import { registerGetRootsListTool } from "./get-roots-list.js";
import { registerGetStructuredContentTool } from "./get-structured-content.js"; import { registerGetStructuredContentTool } from "./get-structured-content.js";
import { registerGetSumTool } from "./get-sum.js"; import { registerGetSumTool } from "./get-sum.js";
import { registerGetTinyImageTool } from "./get-tiny-image.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 { registerLongRunningOperationTool } from "./long-running-operation.js";
import { registerToggleLoggingTool } from "./toggle-logging.js"; import { registerToggleLoggingTool } from "./toggle-logging.js";
import { registerToggleSubscriberUpdatesTool } from "./toggle-subscriber-updates.js"; import { registerToggleSubscriberUpdatesTool } from "./toggle-subscriber-updates.js";
import { registerTriggerSamplingRequestTool } from "./trigger-sampling-request.js";
import { registerTriggerElicitationRequestTool } from "./trigger-elicitation-request.js"; import { registerTriggerElicitationRequestTool } from "./trigger-elicitation-request.js";
import { registerTriggerSamplingRequestTool } from "./trigger-sampling-request.js";
/** /**
* Register the tools with the MCP server. * Register the tools with the MCP server.
@@ -24,6 +25,7 @@ export const registerTools = (server: McpServer) => {
registerGetEnvTool(server); registerGetEnvTool(server);
registerGetResourceLinksTool(server); registerGetResourceLinksTool(server);
registerGetResourceReferenceTool(server); registerGetResourceReferenceTool(server);
registerGetRootsListTool(server);
registerGetStructuredContentTool(server); registerGetStructuredContentTool(server);
registerGetSumTool(server); registerGetSumTool(server);
registerGetTinyImageTool(server); registerGetTinyImageTool(server);

View File

@@ -39,110 +39,122 @@ export const registerTriggerElicitationRequestTool = (server: McpServer) => {
params: { params: {
message: "Please provide inputs for the following fields:", message: "Please provide inputs for the following fields:",
requestedSchema: { requestedSchema: {
type: 'object', type: "object",
properties: { properties: {
name: { name: {
title: 'String', title: "String",
type: 'string', type: "string",
description: 'Your full, legal name', description: "Your full, legal name",
}, },
check: { check: {
title: 'Boolean', title: "Boolean",
type: 'boolean', type: "boolean",
description: 'Agree to the terms and conditions', description: "Agree to the terms and conditions",
}, },
firstLine: { firstLine: {
title: 'String with default', title: "String with default",
type: 'string', type: "string",
description: 'Favorite first line of a story', description: "Favorite first line of a story",
default: 'It was a dark and stormy night.', default: "It was a dark and stormy night.",
}, },
email: { email: {
title: 'String with email format', title: "String with email format",
type: 'string', type: "string",
format: 'email', format: "email",
description: 'Your email address (will be verified, and never shared with anyone else)', description:
"Your email address (will be verified, and never shared with anyone else)",
}, },
homepage: { homepage: {
type: 'string', type: "string",
format: 'uri', format: "uri",
title: 'String with uri format', title: "String with uri format",
description: 'Portfolio / personal website', description: "Portfolio / personal website",
}, },
birthdate: { birthdate: {
title: 'String with date format', title: "String with date format",
type: 'string', type: "string",
format: 'date', format: "date",
description: 'Your date of birth', description: "Your date of birth",
}, },
integer: { integer: {
title: 'Integer', title: "Integer",
type: 'integer', type: "integer",
description: 'Your favorite integer (do not give us your phone number, pin, or other sensitive info)', description:
"Your favorite integer (do not give us your phone number, pin, or other sensitive info)",
minimum: 1, minimum: 1,
maximum: 100, maximum: 100,
default: 42, default: 42,
}, },
number: { number: {
title: 'Number in range 1-1000', title: "Number in range 1-1000",
type: 'number', type: "number",
description: 'Favorite number (there are no wrong answers)', description: "Favorite number (there are no wrong answers)",
minimum: 0, minimum: 0,
maximum: 1000, maximum: 1000,
default: 3.14, default: 3.14,
}, },
untitledSingleSelectEnum: { untitledSingleSelectEnum: {
type: 'string', type: "string",
title: 'Untitled Single Select Enum', title: "Untitled Single Select Enum",
description: 'Choose your favorite friend', description: "Choose your favorite friend",
enum: ['Monica', 'Rachel', 'Joey', 'Chandler', 'Ross', 'Phoebe'], enum: [
default: 'Monica' "Monica",
"Rachel",
"Joey",
"Chandler",
"Ross",
"Phoebe",
],
default: "Monica",
}, },
untitledMultipleSelectEnum: { untitledMultipleSelectEnum: {
type: 'array', type: "array",
title: 'Untitled Multiple Select Enum', title: "Untitled Multiple Select Enum",
description: 'Choose your favorite instruments', description: "Choose your favorite instruments",
minItems: 1, minItems: 1,
maxItems: 3, maxItems: 3,
items: { type: 'string', enum: ['Guitar', 'Piano', 'Violin', 'Drums', 'Bass'] }, items: {
default: ['Guitar'] type: "string",
enum: ["Guitar", "Piano", "Violin", "Drums", "Bass"],
},
default: ["Guitar"],
}, },
titledSingleSelectEnum: { titledSingleSelectEnum: {
type: 'string', type: "string",
title: 'Titled Single Select Enum', title: "Titled Single Select Enum",
description: 'Choose your favorite hero', description: "Choose your favorite hero",
oneOf: [ oneOf: [
{ const: 'hero-1', title: 'Superman' }, { const: "hero-1", title: "Superman" },
{ const: 'hero-2', title: 'Green Lantern' }, { const: "hero-2", title: "Green Lantern" },
{ const: 'hero-3', title: 'Wonder Woman' } { const: "hero-3", title: "Wonder Woman" },
], ],
default: 'hero-1' default: "hero-1",
}, },
titledMultipleSelectEnum: { titledMultipleSelectEnum: {
type: 'array', type: "array",
title: 'Titled Multiple Select Enum', title: "Titled Multiple Select Enum",
description: 'Choose your favorite types of fish', description: "Choose your favorite types of fish",
minItems: 1, minItems: 1,
maxItems: 3, maxItems: 3,
items: { items: {
anyOf: [ anyOf: [
{ const: 'fish-1', title: 'Tuna' }, { const: "fish-1", title: "Tuna" },
{ const: 'fish-2', title: 'Salmon' }, { const: "fish-2", title: "Salmon" },
{ const: 'fish-3', title: 'Trout' } { const: "fish-3", title: "Trout" },
] ],
}, },
default: ['fish-1'] default: ["fish-1"],
}, },
legacyTitledEnum: { legacyTitledEnum: {
type: 'string', type: "string",
title: 'Legacy Titled Single Select Enum', title: "Legacy Titled Single Select Enum",
description: 'Choose your favorite type of pet', description: "Choose your favorite type of pet",
enum: ['pet-1', 'pet-2', 'pet-3', 'pet-4', 'pet-5'], enum: ["pet-1", "pet-2", "pet-3", "pet-4", "pet-5"],
enumNames: ['Cats', 'Dogs', 'Birds', 'Fish', 'Reptiles'], enumNames: ["Cats", "Dogs", "Birds", "Fish", "Reptiles"],
default: 'pet-1', default: "pet-1",
} },
}, },
required: ['name'], required: ["name"],
}, },
}, },
}, },

View File

@@ -21,7 +21,7 @@ const transports: Map<string, SSEServerTransport> = new Map<
app.get("/sse", async (req, res) => { app.get("/sse", async (req, res) => {
let transport: SSEServerTransport; let transport: SSEServerTransport;
const { server, cleanup } = createServer(); const { server, clientConnected, cleanup } = createServer();
if (req?.query?.sessionId) { if (req?.query?.sessionId) {
const sessionId = req?.query?.sessionId as string; const sessionId = req?.query?.sessionId as string;
@@ -38,6 +38,8 @@ app.get("/sse", async (req, res) => {
// Connect server to transport // Connect server to transport
await server.connect(transport); await server.connect(transport);
const sessionId = transport.sessionId; const sessionId = transport.sessionId;
clientConnected(sessionId);
console.error("Client Connected: ", sessionId); console.error("Client Connected: ", sessionId);
// Handle close of connection // Handle close of connection

View File

@@ -7,10 +7,10 @@ console.error("Starting default (STDIO) server...");
async function main() { async function main() {
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
const { server, cleanup } = createServer(); const { server, clientConnected, cleanup } = createServer();
await server.connect(transport); await server.connect(transport);
clientConnected();
// Cleanup on exit // Cleanup on exit
process.on("SIGINT", async () => { process.on("SIGINT", async () => {
await server.close(); await server.close();

View File

@@ -35,7 +35,7 @@ app.post("/mcp", async (req: Request, res: Response) => {
// Reuse existing transport // Reuse existing transport
transport = transports.get(sessionId)!; transport = transports.get(sessionId)!;
} else if (!sessionId) { } else if (!sessionId) {
const { server, cleanup } = createServer(); const { server, clientConnected, cleanup } = createServer();
// New initialization request // New initialization request
const eventStore = new InMemoryEventStore(); 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 // This avoids race conditions where requests might come in before the session is stored
console.log(`Session initialized with ID: ${sessionId}`); console.log(`Session initialized with ID: ${sessionId}`);
transports.set(sessionId, transport); transports.set(sessionId, transport);
clientConnected(sessionId);
}, },
}); });