diff --git a/package-lock.json b/package-lock.json index c419eda69b..12b5caf42b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1862,7 +1862,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2675,6 +2674,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -3390,7 +3405,6 @@ "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -3474,7 +3488,6 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", @@ -3644,7 +3657,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -3735,10 +3747,10 @@ }, "src/everything": { "name": "@modelcontextprotocol/server-everything", - "version": "0.6.2", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.24.0", + "@modelcontextprotocol/sdk": "^1.24.3", "cors": "^2.8.5", "express": "^5.2.1", "jszip": "^3.10.1", @@ -3751,10 +3763,48 @@ "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", + "prettier": "^2.8.8", "shx": "^0.3.4", "typescript": "^5.6.2" } }, + "src/everything/node_modules/@modelcontextprotocol/sdk": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.24.3.tgz", + "integrity": "sha512-YgSHW29fuzKKAHTGe9zjNoo+yF8KaQPzDC2W9Pv41E7/57IfY+AMGJ/aDFlgTLcVVELoggKE4syABCE75u3NCw==", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "src/filesystem": { "name": "@modelcontextprotocol/server-filesystem", "version": "0.6.3", diff --git a/src/everything/.prettierignore b/src/everything/.prettierignore new file mode 100644 index 0000000000..b6ce5590c2 --- /dev/null +++ b/src/everything/.prettierignore @@ -0,0 +1,4 @@ +packages +dist +README.md +node_modules diff --git a/src/everything/AGENTS.md b/src/everything/AGENTS.md new file mode 100644 index 0000000000..cfdcc506a0 --- /dev/null +++ b/src/everything/AGENTS.md @@ -0,0 +1,52 @@ +# MCP "Everything" Server - Development Guidelines + +## Build, Test & Run Commands + +- Build: `npm run build` - Compiles TypeScript to JavaScript +- Watch mode: `npm run watch` - Watches for changes and rebuilds automatically +- Run STDIO server: `npm run start:stdio` - Starts the MCP server using stdio transport +- Run SSE server: `npm run start:sse` - Starts the MCP server with SSE transport +- Run StreamableHttp server: `npm run start:stremableHttp` - Starts the MCP server with StreamableHttp transport +- Prepare release: `npm run prepare` - Builds the project for publishing + +## Code Style Guidelines + +- Use ES modules with `.js` extension in import paths +- Strictly type all functions and variables with TypeScript +- Follow zod schema patterns for tool input validation +- Prefer async/await over callbacks and Promise chains +- Place all imports at top of file, grouped by external then internal +- Use descriptive variable names that clearly indicate purpose +- Implement proper cleanup for timers and resources in server shutdown +- Handle errors with try/catch blocks and provide clear error messages +- Use consistent indentation (2 spaces) and trailing commas in multi-line objects +- Match existing code style, import order, and module layout in the respective folder. +- Use camelCase for variables/functions, +- Use PascalCase for types/classes, +- Use UPPER_CASE for constants +- Use kebab-case for file names and registered tools, prompts, and resources. +- Use verbs for tool names, e.g., `get-annotated-message` instead of `annotated-message` + +## Extending the Server + +The Everything Server is designed to be extended at well-defined points. +See [Extension Points](docs/extension.md) and [Project Structure](docs/structure.md). +The server factory is `src/everything/server/index.ts` and registers all features during startup as well as handling post-connection setup. + +### High-level + +- Tools live under `src/everything/tools/` and are registered via `registerTools(server)`. +- Resources live under `src/everything/resources/` and are registered via `registerResources(server)`. +- Prompts live under `src/everything/prompts/` and are registered via `registerPrompts(server)`. +- Subscriptions and simulated update routines are under `src/everything/resources/subscriptions.ts`. +- Logging helpers are under `src/everything/server/logging.ts`. +- Transport managers are under `src/everything/transports/`. + +### When adding a new feature + +- Follow the existing file/module pattern in its folder (naming, exports, and registration function). +- Export a `registerX(server)` function that registers new items with the MCP SDK in the same style as existing ones. +- Wire your new module into the central index (e.g., update `tools/index.ts`, `resources/index.ts`, or `prompts/index.ts`). +- Ensure schemas (for tools) are accurate JSON Schema and include helpful descriptions and examples. + `server/index.ts` and usages in `logging.ts` and `subscriptions.ts`. +- Keep the docs in `src/everything/docs/` up to date if you add or modify noteworthy features. diff --git a/src/everything/CLAUDE.md b/src/everything/CLAUDE.md deleted file mode 100644 index 9135020c98..0000000000 --- a/src/everything/CLAUDE.md +++ /dev/null @@ -1,20 +0,0 @@ -# MCP "Everything" Server - Development Guidelines - -## Build, Test & Run Commands -- Build: `npm run build` - Compiles TypeScript to JavaScript -- Watch mode: `npm run watch` - Watches for changes and rebuilds automatically -- Run server: `npm run start` - Starts the MCP server using stdio transport -- Run SSE server: `npm run start:sse` - Starts the MCP server with SSE transport -- Prepare release: `npm run prepare` - Builds the project for publishing - -## Code Style Guidelines -- Use ES modules with `.js` extension in import paths -- Strictly type all functions and variables with TypeScript -- Follow zod schema patterns for tool input validation -- Prefer async/await over callbacks and Promise chains -- Place all imports at top of file, grouped by external then internal -- Use descriptive variable names that clearly indicate purpose -- Implement proper cleanup for timers and resources in server shutdown -- Follow camelCase for variables/functions, PascalCase for types/classes, UPPER_CASE for constants -- Handle errors with try/catch blocks and provide clear error messages -- Use consistent indentation (2 spaces) and trailing commas in multi-line objects \ No newline at end of file diff --git a/src/everything/README.md b/src/everything/README.md index 35274f617b..8109e4449f 100644 --- a/src/everything/README.md +++ b/src/everything/README.md @@ -1,166 +1,17 @@ # Everything MCP Server +**[Architecture](docs/architecture.md) +| [Project Structure](docs/structure.md) +| [Startup Process](docs/startup.md) +| [Server Features](docs/features.md) +| [Extension Points](docs/extension.md) +| [How It Works](docs/how-it-works.md)** + This MCP server attempts to exercise all the features of the MCP protocol. It is not intended to be a useful server, but rather a test server for builders of MCP clients. It implements prompts, tools, resources, sampling, and more to showcase MCP capabilities. -## Components - -### Tools - -1. `echo` - - Simple tool to echo back input messages - - Input: - - `message` (string): Message to echo back - - Returns: Text content with echoed message - -2. `add` - - Adds two numbers together - - Inputs: - - `a` (number): First number - - `b` (number): Second number - - Returns: Text result of the addition - -3. `longRunningOperation` - - Demonstrates progress notifications for long operations - - Inputs: - - `duration` (number, default: 10): Duration in seconds - - `steps` (number, default: 5): Number of progress steps - - Returns: Completion message with duration and steps - - Sends progress notifications during execution - -4. `printEnv` - - Prints all environment variables - - Useful for debugging MCP server configuration - - No inputs required - - Returns: JSON string of all environment variables - -5. `sampleLLM` - - Demonstrates LLM sampling capability using MCP sampling feature - - Inputs: - - `prompt` (string): The prompt to send to the LLM - - `maxTokens` (number, default: 100): Maximum tokens to generate - - Returns: Generated LLM response - -6. `getTinyImage` - - Returns a small test image - - No inputs required - - Returns: Base64 encoded PNG image data - -7. `annotatedMessage` - - Demonstrates how annotations can be used to provide metadata about content - - Inputs: - - `messageType` (enum: "error" | "success" | "debug"): Type of message to demonstrate different annotation patterns - - `includeImage` (boolean, default: false): Whether to include an example image - - Returns: Content with varying annotations: - - Error messages: High priority (1.0), visible to both user and assistant - - Success messages: Medium priority (0.7), user-focused - - Debug messages: Low priority (0.3), assistant-focused - - Optional image: Medium priority (0.5), user-focused - - Example annotations: - ```json - { - "priority": 1.0, - "audience": ["user", "assistant"] - } - ``` - -8. `getResourceReference` - - Returns a resource reference that can be used by MCP clients - - Inputs: - - `resourceId` (number, 1-100): ID of the resource to reference - - Returns: A resource reference with: - - Text introduction - - Embedded resource with `type: "resource"` - - Text instruction for using the resource URI - -9. `startElicitation` - - Initiates an elicitation (interaction) within the MCP client. - - Inputs: - - `color` (string): Favorite color - - `number` (number, 1-100): Favorite number - - `pets` (enum): Favorite pet - - Returns: Confirmation of the elicitation demo with selection summary. - -10. `structuredContent` - - Demonstrates a tool returning structured content using the example in the specification - - Provides an output schema to allow testing of client SHOULD advisory to validate the result using the schema - - Inputs: - - `location` (string): A location or ZIP code, mock data is returned regardless of value - - Returns: a response with - - `structuredContent` field conformant to the output schema - - A backward compatible Text Content field, a SHOULD advisory in the specification - -11. `listRoots` - - Lists the current MCP roots provided by the client - - Demonstrates the roots protocol capability even though this server doesn't access files - - No inputs required - - Returns: List of current roots with their URIs and names, or a message if no roots are set - - Shows how servers can interact with the MCP roots protocol - -### Resources - -The server provides 100 test resources in two formats: -- Even numbered resources: - - Plaintext format - - URI pattern: `test://static/resource/{even_number}` - - Content: Simple text description - -- Odd numbered resources: - - Binary blob format - - URI pattern: `test://static/resource/{odd_number}` - - Content: Base64 encoded binary data - -Resource features: -- Supports pagination (10 items per page) -- Allows subscribing to resource updates -- Demonstrates resource templates -- Auto-updates subscribed resources every 5 seconds - -### Prompts - -1. `simple_prompt` - - Basic prompt without arguments - - Returns: Single message exchange - -2. `complex_prompt` - - Advanced prompt demonstrating argument handling - - Required arguments: - - `temperature` (string): Temperature setting - - Optional arguments: - - `style` (string): Output style preference - - Returns: Multi-turn conversation with images - -3. `resource_prompt` - - Demonstrates embedding resource references in prompts - - Required arguments: - - `resourceId` (number): ID of the resource to embed (1-100) - - Returns: Multi-turn conversation with an embedded resource reference - - Shows how to include resources directly in prompt messages - -### Roots - -The server demonstrates the MCP roots protocol capability: - -- Declares `roots: { listChanged: true }` capability to indicate support for roots -- Handles `roots/list_changed` notifications from clients -- Requests initial roots during server initialization -- Provides a `listRoots` tool to display current roots -- Logs roots-related events for demonstration purposes - -Note: This server doesn't actually access files, but demonstrates how servers can interact with the roots protocol for clients that need to understand which directories are available for file operations. - -### Logging - -The server sends random-leveled log messages every 15 seconds, e.g.: +## Tools, Resources, Prompts, and Other Features -```json -{ - "method": "notifications/message", - "params": { - "level": "info", - "data": "Info-level message" - } -} -``` +A complete list of the registered MCP primitives and other protocol features demonstrated can be found in the [Server Features](docs/features.md) document. ## Usage with Claude Desktop (uses [stdio Transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio)) diff --git a/src/everything/docs/architecture.md b/src/everything/docs/architecture.md new file mode 100644 index 0000000000..728cfd4010 --- /dev/null +++ b/src/everything/docs/architecture.md @@ -0,0 +1,44 @@ +# Everything Server – Architecture + +**Architecture +| [Project Structure](structure.md) +| [Startup Process](startup.md) +| [Server Features](features.md) +| [Extension Points](extension.md) +| [How It Works](how-it-works.md)** + +This documentation summarizes the current layout and runtime architecture of the `src/everything` package. +It explains how the server starts, how transports are wired, where tools, prompts, and resources are registered, and how to extend the system. + +## High‑level Overview + +### Purpose + +A minimal, modular MCP server showcasing core Model Context Protocol features. It exposes simple tools, prompts, and resources, and can be run over multiple transports (STDIO, SSE, and Streamable HTTP). + +### Design + +A small “server factory” constructs the MCP server and registers features. +Transports are separate entry points that create/connect the server and handle network concerns. +Tools, prompts, and resources are organized in their own submodules. + +### Multi‑client + +The server supports multiple concurrent clients. Tracking per session data is demonstrated with +resource subscriptions and simulated logging. + +## Build and Distribution + +- TypeScript sources are compiled into `dist/` via `npm run build`. +- The `build` script copies `docs/` into `dist/` so instruction files ship alongside the compiled server. +- The CLI bin is configured in `package.json` as `mcp-server-everything` → `dist/index.js`. + +## [Project Structure](structure.md) + +## [Startup Process](startup.md) + +## [Server Features](features.md) + +## [Extension Points](extension.md) + +## [How It Works](how-it-works.md) diff --git a/src/everything/docs/extension.md b/src/everything/docs/extension.md new file mode 100644 index 0000000000..1d77730448 --- /dev/null +++ b/src/everything/docs/extension.md @@ -0,0 +1,23 @@ +# Everything Server - Extension Points + +**[Architecture](architecture.md) +| [Project Structure](structure.md) +| [Startup Process](startup.md) +| [Server Features](features.md) +| Extension Points +| [How It Works](how-it-works.md)** + +## Adding Tools + +- Create a new file under `tools/` with your `registerXTool(server)` function that registers the tool via `server.registerTool(...)`. +- Export and call it from `tools/index.ts` inside `registerTools(server)`. + +## Adding Prompts + +- Create a new file under `prompts/` with your `registerXPrompt(server)` function that registers the prompt via `server.registerPrompt(...)`. +- Export and call it from `prompts/index.ts` inside `registerPrompts(server)`. + +## Adding Resources + +- Create a new file under `resources/` with your `registerXResources(server)` function using `server.registerResource(...)` (optionally with `ResourceTemplate`). +- Export and call it from `resources/index.ts` inside `registerResources(server)`. diff --git a/src/everything/docs/features.md b/src/everything/docs/features.md new file mode 100644 index 0000000000..c10f311fa4 --- /dev/null +++ b/src/everything/docs/features.md @@ -0,0 +1,52 @@ +# Everything Server - Features + +**[Architecture](architecture.md) +| [Project Structure](structure.md) +| [Startup Process](startup.md) +| Server Features +| [Extension Points](extension.md) +| [How It Works](how-it-works.md)** + +## Tools + +- `echo` (tools/echo.ts): Echoes the provided `message: string`. Uses Zod to validate inputs. +- `get-annotated-message` (tools/get-annotated-message.ts): Returns a `text` message annotated with `priority` and `audience` based on `messageType` (`error`, `success`, or `debug`); can optionally include an annotated `image`. +- `get-env` (tools/get-env.ts): Returns all environment variables from the running process as pretty-printed JSON text. +- `get-resource-links` (tools/get-resource-links.ts): Returns an intro `text` block followed by multiple `resource_link` items. For a requested `count` (1–10), alternates between dynamic Text and Blob resources using URIs from `resources/templates.ts`. +- `get-resource-reference` (tools/get-resource-reference.ts): Accepts `resourceType` (`text` or `blob`) and `resourceId` (positive integer). Returns a concrete `resource` content block (with its `uri`, `mimeType`, and data) with surrounding explanatory `text`. +- `get-roots-list` (tools/get-roots-list.ts): Returns the last list of roots sent by the client. +- `gzip-file-as-resource` (tools/gzip-file-as-resource.ts): Accepts a `name` and `data` (URL or data URI), fetches the data subject to size/time/domain constraints, compresses it, registers it as a session resource at `demo://resource/session/` with `mimeType: application/gzip`, and returns either a `resource_link` (default) or an inline `resource` depending on `outputType`. +- `get-structured-content` (tools/get-structured-content.ts): Demonstrates structured responses. Accepts `location` input and returns both backward‑compatible `content` (a `text` block containing JSON) and `structuredContent` validated by an `outputSchema` (temperature, conditions, humidity). +- `get-sum` (tools/get-sum.ts): For two numbers `a` and `b` calculates and returns their sum. Uses Zod to validate inputs. +- `get-tiny-image` (tools/get-tiny-image.ts): Returns a tiny PNG MCP logo as an `image` content item with brief descriptive text before and after. +- `trigger-long-running-operation` (tools/trigger-trigger-long-running-operation.ts): Simulates a multi-step operation over a given `duration` and number of `steps`; reports progress via `notifications/progress` when a `progressToken` is provided by the client. +- `toggle-simulated-logging` (tools/toggle-simulated-logging.ts): Starts or stops simulated, random‑leveled logging for the invoking session. Respects the client’s selected minimum logging level. +- `toggle-subscriber-updates` (tools/toggle-subscriber-updates.ts): Starts or stops simulated resource update notifications for URIs the invoking session has subscribed to. +- `trigger-sampling-request` (tools/trigger-sampling-request.ts): Issues a `sampling/createMessage` request to the client/LLM using provided `prompt` and optional generation controls; returns the LLM’s response payload. + +## Prompts + +- `simple-prompt` (prompts/simple.ts): No-argument prompt that returns a static user message. +- `args-prompt` (prompts/args.ts): Two-argument prompt with `city` (required) and `state` (optional) used to compose a question. +- `completable-prompt` (prompts/completions.ts): Demonstrates argument auto-completions with the SDK’s `completable` helper; `department` completions drive context-aware `name` suggestions. +- `resource-prompt` (prompts/resource.ts): Accepts `resourceType` ("Text" or "Blob") and `resourceId` (string convertible to integer) and returns messages that include an embedded dynamic resource of the selected type generated via `resources/templates.ts`. + +## Resources + +- Dynamic Text: `demo://resource/dynamic/text/{index}` (content generated on the fly) +- Dynamic Blob: `demo://resource/dynamic/blob/{index}` (base64 payload generated on the fly) +- Static Documents: `demo://resource/static/document/` (serves files from `src/everything/docs/` as static file-based resources) +- Session Scoped: `demo://resource/session/` (per-session resources registered dynamically; available only for the lifetime of the session) + +## Resource Subscriptions and Notifications + +- Simulated update notifications are opt‑in and off by default. +- Clients may subscribe/unsubscribe to resource URIs using the MCP `resources/subscribe` and `resources/unsubscribe` requests. +- Use the `toggle-subscriber-updates` tool to start/stop a per‑session interval that emits `notifications/resources/updated { uri }` only for URIs that session has subscribed to. +- Multiple concurrent clients are supported; each client’s subscriptions are tracked per session and notifications are delivered independently via the server instance associated with that session. + +## Simulated Logging + +- Simulated logging is available but off by default. +- Use the `toggle-simulated-logging` tool to start/stop periodic log messages of varying levels (debug, info, notice, warning, error, critical, alert, emergency) per session. +- Clients can control the minimum level they receive via the standard MCP `logging/setLevel` request. diff --git a/src/everything/docs/how-it-works.md b/src/everything/docs/how-it-works.md new file mode 100644 index 0000000000..514c6f5663 --- /dev/null +++ b/src/everything/docs/how-it-works.md @@ -0,0 +1,45 @@ +# Everything Server - How It Works + +**[Architecture](architecture.md) +| [Project Structure](structure.md) +| [Startup Process](startup.md) +| [Server Features](features.md) +| [Extension Points](extension.md) +| How It Works** + +# Conditional Tool Registration + +### Module: `server/index.ts` + +- Some tools require client support for the capability they demonstrate. These are: + - `get-roots-list` + - `trigger-elicitation-request` + - `trigger-sampling-request` +- Client capabilities aren't known until after initilization handshake is complete. +- Most tools are registered immediately during the Server Factory execution, prior to client connection. +- To defer registration of these commands until client capabilities are known, a `registerConditionalTools(server)` function is invoked from an `onintitialized` handler. + +## Resource Subscriptions + +### Module: `resources/subscriptions.ts` + +- Tracks subscribers per URI: `Map>`. +- Installs handlers via `setSubscriptionHandlers(server)` to process subscribe/unsubscribe requests and keep the map updated. +- Updates are started/stopped on demand by the `toggle-subscriber-updates` tool, which calls `beginSimulatedResourceUpdates(server, sessionId)` and `stopSimulatedResourceUpdates(sessionId)`. +- `cleanup(sessionId?)` calls `stopSimulatedResourceUpdates(sessionId)` to clear intervals and remove session‑scoped state. + +## Session‑scoped Resources + +### Module: `resources/session.ts` + +- `getSessionResourceURI(name: string)`: Builds a session resource URI: `demo://resource/session/`. +- `registerSessionResource(server, resource, type, payload)`: Registers a resource with the given `uri`, `name`, and `mimeType`, returning a `resource_link`. The content is served from memory for the life of the session only. Supports `type: "text" | "blob"` and returns data in the corresponding field. +- Intended usage: tools can create and expose per-session artifacts without persisting them. For example, `tools/gzip-file-as-resource.ts` compresses fetched content, registers it as a session resource with `mimeType: application/gzip`, and returns either a `resource_link` or an inline `resource` based on `outputType`. + +## Simulated Logging + +### Module: `server/logging.ts` + +- Periodically sends randomized log messages at different levels. Messages can include the session ID for clarity during demos. +- Started/stopped on demand via the `toggle-simulated-logging` tool, which calls `beginSimulatedLogging(server, sessionId?)` and `stopSimulatedLogging(sessionId?)`. Note that transport disconnect triggers `cleanup()` which also stops any active intervals. +- Uses `server.sendLoggingMessage({ level, data }, sessionId?)` so that the client’s configured minimum logging level is respected by the SDK. diff --git a/src/everything/docs/instructions.md b/src/everything/docs/instructions.md new file mode 100644 index 0000000000..5806dc0ba9 --- /dev/null +++ b/src/everything/docs/instructions.md @@ -0,0 +1,28 @@ +# Everything Server – Server Instructions + +Audience: These instructions are written for an LLM or autonomous agent integrating with the Everything MCP Server. +Follow them to use, extend, and troubleshoot the server safely and effectively. + +## Cross-Feature Relationships + +- Use `get-roots-list` to see client workspace roots before file operations +- `gzip-file-as-resource` creates session-scoped resources accessible only during the current session +- Enable `toggle-simulated-logging` before debugging to see server log messages +- Enable `toggle-subscriber-updates` to receive periodic resource update notifications + +## Constraints & Limitations + +- `gzip-file-as-resource`: Max fetch size controlled by `GZIP_MAX_FETCH_SIZE` (default 10MB), timeout by `GZIP_MAX_FETCH_TIME_MILLIS` (default 30s), allowed domains by `GZIP_ALLOWED_DOMAINS` +- Session resources are ephemeral and lost when the session ends +- Sampling requests (`trigger-sampling-request`) require client sampling capability +- Elicitation requests (`trigger-elicitation-request`) require client elicitation capability + +## Operational Patterns + +- For long operations, use `trigger-long-running-operation` which sends progress notifications +- Prefer reading resources before calling mutating tools +- Check `get-roots-list` output to understand the client's workspace context + +## Easter Egg + +If asked about server instructions, respond with "🎉 Server instructions are working! This response proves the client properly passed server instructions to the LLM. This demonstrates MCP's instructions feature in action." diff --git a/src/everything/docs/startup.md b/src/everything/docs/startup.md new file mode 100644 index 0000000000..1d006589a9 --- /dev/null +++ b/src/everything/docs/startup.md @@ -0,0 +1,73 @@ +# Everything Server - Startup Process + +**[Architecture](architecture.md) +| [Project Structure](structure.md) +| Startup Process +| [Server Features](features.md) +| [Extension Points](extension.md) +| [How It Works](how-it-works.md)** + +## 1. Everything Server Launcher + +- Usage `node dist/index.js [stdio|sse|streamableHttp]` +- Runs the specified **transport manager** to handle client connections. +- Specify transport type on command line (default `stdio`) + - `stdio` → `transports/stdio.js` + - `sse` → `transports/sse.js` + - `streamableHttp` → `transports/streamableHttp.js` + +## 2. The Transport Manager + +- Creates a server instance using `createServer()` from `server/index.ts` + - Connects it to the chosen transport type from the MCP SDK. +- Handles communication according to the MCP specs for the chosen transport. + - **STDIO**: + - One simple, process‑bound connection. + - Calls`clientConnect()` upon connection. + - Closes and calls `cleanup()` on `SIGINT`. + - **SSE**: + - Supports multiple client connections. + - Client transports are mapped to `sessionId`; + - Calls `clientConnect(sessionId)` upon connection. + - Hooks server’s `onclose` to clean and remove session. + - Exposes + - `/sse` **GET** (SSE stream) + - `/message` **POST** (JSON‑RPC messages) + - **Streamable HTTP**: + - Supports multiple client connections. + - Client transports are mapped to `sessionId`; + - Calls `clientConnect(sessionId)` upon connection. + - Exposes `/mcp` for + - **POST** (JSON‑RPC messages) + - **GET** (SSE stream) + - **DELETE** (termination) + - Uses an event store for resumability and stores transports by `sessionId`. + - Calls `cleanup(sessionId)` on **DELETE**. + +## 3. The Server Factory + +- Invoke `createServer()` from `server/index.ts` +- Creates a new `McpServer` instance with + - **Capabilities**: + - `tools: {}` + - `logging: {}` + - `prompts: {}` + - `resources: { subscribe: true }` + - **Server Instructions** + - Loaded from the docs folder (`server-instructions.md`). + - **Registrations** + - Registers **tools** via `registerTools(server)`. + - Registers **resources** via `registerResources(server)`. + - Registers **prompts** via `registerPrompts(server)`. + - **Other Request Handlers** + - Sets up resource subscription handlers via `setSubscriptionHandlers(server)`. + - Roots list change handler is added post-connection via + - **Returns** + - The `McpServer` instance + - A `clientConnect(sessionId)` callback that enables post-connection setup + - A `cleanup(sessionId?)` callback that stops any active intervals and removes any session‑scoped state + +## Enabling Multiple Clients + +Some of the transport managers defined in the `transports` folder can support multiple clients. +In order to do so, they must map certain data to a session identifier. diff --git a/src/everything/docs/structure.md b/src/everything/docs/structure.md new file mode 100644 index 0000000000..6bcedcd425 --- /dev/null +++ b/src/everything/docs/structure.md @@ -0,0 +1,182 @@ +# Everything Server - Project Structure + +**[Architecture](architecture.md) +| Project Structure +| [Startup Process](startup.md) +| [Server Features](features.md) +| [Extension Points](extension.md) +| [How It Works](how-it-works.md)** + +``` +src/everything + ├── index.ts + ├── AGENTS.md + ├── package.json + ├── docs + │ ├── architecture.md + │ ├── extension.md + │ ├── features.md + │ ├── how-it-works.md + │ ├── instructions.md + │ ├── startup.md + │ └── structure.md + ├── prompts + │ ├── index.ts + │ ├── args.ts + │ ├── completions.ts + │ ├── simple.ts + │ └── resource.ts + ├── resources + │ ├── index.ts + │ ├── files.ts + │ ├── session.ts + │ ├── subscriptions.ts + │ └── templates.ts + ├── server + │ ├── index.ts + │ ├── logging.ts + │ └── roots.ts + ├── tools + │ ├── index.ts + │ ├── echo.ts + │ ├── get-annotated-message.ts + │ ├── get-env.ts + │ ├── get-resource-links.ts + │ ├── get-resource-reference.ts + │ ├── get-roots-list.ts + │ ├── get-structured-content.ts + │ ├── get-sum.ts + │ ├── get-tiny-image.ts + │ ├── gzip-file-as-resource.ts + │ ├── toggle-simulated-logging.ts + │ ├── toggle-subscriber-updates.ts + │ ├── trigger-elicitation-request.ts + │ ├── trigger-long-running-operation.ts + │ └── trigger-sampling-request.ts + └── transports + ├── sse.ts + ├── stdio.ts + └── streamableHttp.ts +``` + +# Project Contents + +## `src/everything`: + +### `index.ts` + +- CLI entry point that selects and runs a specific transport module based on the first CLI argument: `stdio`, `sse`, or `streamableHttp`. + +### `AGENTS.md` + +- Directions for Agents/LLMs explaining coding guidelines and how to appropriately extend the server. + +### `package.json` + +- Package metadata and scripts: + - `build`: TypeScript compile to `dist/`, copies `docs/` into `dist/` and marks the compiled entry scripts as executable. + - `start:stdio`, `start:sse`, `start:streamableHttp`: Run built transports from `dist/`. +- Declares dependencies on `@modelcontextprotocol/sdk`, `express`, `cors`, `zod`, etc. + +### `docs/` + +- `architecture.md` + - This document. +- `server-instructions.md` + - Human‑readable instructions intended to be passed to the client/LLM as for guidance on server use. Loaded by the server at startup and returned in the "initialize" exchange. + +### `prompts/` + +- `index.ts` + - `registerPrompts(server)` orchestrator; delegates to prompt factory/registration methods from in individual prompt files. +- `simple.ts` + - Registers `simple-prompt`: a prompt with no arguments that returns a single user message. +- `args.ts` + - Registers `args-prompt`: a prompt with two arguments (`city` required, `state` optional) used to compose a message. +- `completions.ts` + - Registers `completable-prompt`: a prompt whose arguments support server-driven completions using the SDK’s `completable(...)` helper (e.g., completing `department` and context-aware `name`). +- `resource.ts` + - Exposes `registerEmbeddedResourcePrompt(server)` which registers `resource-prompt` — a prompt that accepts `resourceType` ("Text" or "Blob") and `resourceId` (integer), and embeds a dynamically generated resource of the requested type within the returned messages. Internally reuses helpers from `resources/templates.ts`. + +### `resources/` + +- `index.ts` + - `registerResources(server)` orchestrator; delegates to resource factory/registration methods from individual resource files. +- `templates.ts` + - Registers two dynamic, template‑driven resources using `ResourceTemplate`: + - Text: `demo://resource/dynamic/text/{index}` (MIME: `text/plain`) + - Blob: `demo://resource/dynamic/blob/{index}` (MIME: `application/octet-stream`, Base64 payload) + - The `{index}` path variable must be a finite positive integer. Content is generated on demand with a timestamp. + - Exposes helpers `textResource(uri, index)`, `textResourceUri(index)`, `blobResource(uri, index)`, and `blobResourceUri(index)` so other modules can construct and embed dynamic resources directly (e.g., from prompts). +- `files.ts` + - Registers static file-based resources for each file in the `docs/` folder. + - URIs follow the pattern: `demo://resource/static/document/`. + - Serves markdown files as `text/markdown`, `.txt` as `text/plain`, `.json` as `application/json`, others default to `text/plain`. + +### `server/` + +- `index.ts` + - Server factory that creates an `McpServer` with declared capabilities, loads server instructions, and registers tools, prompts, and resources. + - Sets resource subscription handlers via `setSubscriptionHandlers(server)`. + - Exposes `{ server, cleanup }` to the chosen transport. Cleanup stops any running intervals in the server when the transport disconnects. +- `logging.ts` + - Implements simulated logging. Periodically sends randomized log messages at various levels to the connected client session. Started/stopped on demand via a dedicated tool. + +### `tools/` + +- `index.ts` + - `registerTools(server)` orchestrator; delegates to tool factory/registration methods in individual tool files. +- `echo.ts` + - Registers an `echo` tool that takes a message and returns `Echo: {message}`. +- `get-annotated-message.ts` + - Registers an `annotated-message` tool which demonstrates annotated content items by emitting a primary `text` message with `annotations` that vary by `messageType` (`"error" | "success" | "debug"`), and optionally includes an annotated `image` (tiny PNG) when `includeImage` is true. +- `get-env.ts` + - Registers a `get-env` tool that returns the current process environment variables as formatted JSON text; useful for debugging configuration. +- `get-resource-links.ts` + - Registers a `get-resource-links` tool that returns an intro `text` block followed by multiple `resource_link` items. +- `get-resource-reference.ts` + - Registers a `get-resource-reference` tool that returns a reference for a selected dynamic resource. +- `get-roots-list.ts` + - Registers a `get-roots-list` tool that returns the last list of roots sent by the client. +- `gzip-file-as-resource.ts` + - Registers a `gzip-file-as-resource` tool that fetches content from a URL or data URI, compresses it, and then either: + - returns a `resource_link` to a session-scoped resource (default), or + - returns an inline `resource` with the gzipped data. The resource will be still discoverable for the duration of the session via `resources/list`. + - Uses `resources/session.ts` to register the gzipped blob as a per-session resource at a URI like `demo://resource/session/` with `mimeType: application/gzip`. + - Environment controls: + - `GZIP_MAX_FETCH_SIZE` (bytes, default 10 MiB) + - `GZIP_MAX_FETCH_TIME_MILLIS` (ms, default 30000) + - `GZIP_ALLOWED_DOMAINS` (comma-separated allowlist; empty means all domains allowed) +- `trigger-elicitation-request.ts` + - Registers a `trigger-elicitation-request` tool that sends an `elicitation/create` request to the client/LLM and returns the elicitation result. +- `trigger-sampling-request.ts` + - Registers a `trigger-sampling-request` tool that sends a `sampling/createMessage` request to the client/LLM and returns the sampling result. +- `get-structured-content.ts` + - Registers a `get-structured-content` tool that demonstrates structuredContent block responses. +- `get-sum.ts` + - Registers an `get-sum` tool with a Zod input schema that sums two numbers `a` and `b` and returns the result. +- `get-tiny-image.ts` + - Registers a `get-tiny-image` tool, which returns a tiny PNG MCP logo as an `image` content item, along with surrounding descriptive `text` items. +- `trigger-long-running-operation.ts` + - Registers a `long-running-operation` tool that simulates a long-running task over a specified `duration` (seconds) and number of `steps`; emits `notifications/progress` updates when the client supplies a `progressToken`. +- `toggle-simulated-logging.ts` + - Registers a `toggle-simulated-logging` tool, which starts or stops simulated logging for the invoking session. +- `toggle-subscriber-updates.ts` + - Registers a `toggle-subscriber-updates` tool, which starts or stops simulated resource subscription update checks for the invoking session. + +### `transports/` + +- `stdio.ts` + - Starts a `StdioServerTransport`, created the server via `createServer()`, and connects it. + - Handles `SIGINT` to close cleanly and calls `cleanup()` to remove any live intervals. +- `sse.ts` + - Express server exposing: + - `GET /sse` to establish an SSE connection per session. + - `POST /message` for client messages. + - Manages multiple connected clients via a transport map. + - Starts an `SSEServerTransport`, created the server via `createServer()`, and connects it to a new transport. + - On server disconnect, calls `cleanup()` to remove any live intervals. +- `streamableHttp.ts` + - Express server exposing a single `/mcp` endpoint for POST (JSON‑RPC), GET (SSE stream), and DELETE (session termination) using `StreamableHTTPServerTransport`. + - Uses an `InMemoryEventStore` for resumable sessions and tracks transports by `sessionId`. + - Connects a fresh server instance on initialization POST and reuses the transport for subsequent requests. diff --git a/src/everything/everything.ts b/src/everything/everything.ts deleted file mode 100644 index 861437d28e..0000000000 --- a/src/everything/everything.ts +++ /dev/null @@ -1,1121 +0,0 @@ -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import { - CallToolRequestSchema, - ClientCapabilities, - CompleteRequestSchema, - CreateMessageRequest, - CreateMessageResultSchema, - ElicitResultSchema, - GetPromptRequestSchema, - ListPromptsRequestSchema, - ListResourcesRequestSchema, - ListResourceTemplatesRequestSchema, - ListToolsRequestSchema, - LoggingLevel, - ReadResourceRequestSchema, - Resource, - RootsListChangedNotificationSchema, - ServerNotification, - ServerRequest, - SubscribeRequestSchema, - Tool, - UnsubscribeRequestSchema, - type Root -} from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; -import { zodToJsonSchema } from "zod-to-json-schema"; -import { readFileSync } from "fs"; -import { fileURLToPath } from "url"; -import { dirname, join } from "path"; -import JSZip from "jszip"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const instructions = readFileSync(join(__dirname, "instructions.md"), "utf-8"); - -type ToolInput = Tool["inputSchema"]; -type ToolOutput = Tool["outputSchema"]; - -type SendRequest = RequestHandlerExtra["sendRequest"]; - -/* Input schemas for tools implemented in this server */ -const EchoSchema = z.object({ - message: z.string().describe("Message to echo"), -}); - -const AddSchema = z.object({ - a: z.number().describe("First number"), - b: z.number().describe("Second number"), -}); - -const LongRunningOperationSchema = z.object({ - duration: z - .number() - .default(10) - .describe("Duration of the operation in seconds"), - steps: z - .number() - .default(5) - .describe("Number of steps in the operation"), -}); - -const PrintEnvSchema = z.object({}); - -const SampleLLMSchema = z.object({ - prompt: z.string().describe("The prompt to send to the LLM"), - maxTokens: z - .number() - .default(100) - .describe("Maximum number of tokens to generate"), -}); - -const GetTinyImageSchema = z.object({}); - -const AnnotatedMessageSchema = z.object({ - messageType: z - .enum(["error", "success", "debug"]) - .describe("Type of message to demonstrate different annotation patterns"), - includeImage: z - .boolean() - .default(false) - .describe("Whether to include an example image"), -}); - -const GetResourceReferenceSchema = z.object({ - resourceId: z - .number() - .min(1) - .max(100) - .describe("ID of the resource to reference (1-100)"), -}); - -const ElicitationSchema = z.object({}); - -const GetResourceLinksSchema = z.object({ - count: z - .number() - .min(1) - .max(10) - .default(3) - .describe("Number of resource links to return (1-10)"), -}); - -const ListRootsSchema = z.object({}); - -const StructuredContentSchema = { - input: z.object({ - location: z - .string() - .trim() - .min(1) - .describe("City name or zip code"), - }), - - output: z.object({ - temperature: z - .number() - .describe("Temperature in celsius"), - conditions: z - .string() - .describe("Weather conditions description"), - humidity: z - .number() - .describe("Humidity percentage"), - }) -}; - -const ZipResourcesInputSchema = z.object({ - files: z.record(z.string().url().describe("URL of the file to include in the zip")).describe("Mapping of file names to URLs to include in the zip"), -}); - -enum ToolName { - ECHO = "echo", - ADD = "add", - LONG_RUNNING_OPERATION = "longRunningOperation", - PRINT_ENV = "printEnv", - SAMPLE_LLM = "sampleLLM", - GET_TINY_IMAGE = "getTinyImage", - ANNOTATED_MESSAGE = "annotatedMessage", - GET_RESOURCE_REFERENCE = "getResourceReference", - ELICITATION = "startElicitation", - GET_RESOURCE_LINKS = "getResourceLinks", - STRUCTURED_CONTENT = "structuredContent", - ZIP_RESOURCES = "zip", - LIST_ROOTS = "listRoots" -} - -enum PromptName { - SIMPLE = "simple_prompt", - COMPLEX = "complex_prompt", - RESOURCE = "resource_prompt", -} - -// Example completion values -const EXAMPLE_COMPLETIONS = { - style: ["casual", "formal", "technical", "friendly"], - temperature: ["0", "0.5", "0.7", "1.0"], - resourceId: ["1", "2", "3", "4", "5"], -}; - -export const createServer = () => { - const server = new Server( - { - name: "example-servers/everything", - title: "Everything Example Server", - version: "1.0.0", - }, - { - capabilities: { - prompts: {}, - resources: { subscribe: true }, - tools: {}, - logging: {}, - completions: {} - }, - instructions - } - ); - - let subscriptions: Set = new Set(); - let subsUpdateInterval: NodeJS.Timeout | undefined; - let stdErrUpdateInterval: NodeJS.Timeout | undefined; - - let logsUpdateInterval: NodeJS.Timeout | undefined; - // Store client capabilities - let clientCapabilities: ClientCapabilities | undefined; - - // Roots state management - let currentRoots: Root[] = []; - let clientSupportsRoots = false; - let sessionId: string | undefined; - - // Function to start notification intervals when a client connects - const startNotificationIntervals = (sid?: string|undefined) => { - sessionId = sid; - if (!subsUpdateInterval) { - subsUpdateInterval = setInterval(() => { - for (const uri of subscriptions) { - server.notification({ - method: "notifications/resources/updated", - params: { uri }, - }); - } - }, 10000); - } - - const maybeAppendSessionId = sessionId ? ` - SessionId ${sessionId}`: ""; - const messages: { level: LoggingLevel; data: string }[] = [ - { level: "debug", data: `Debug-level message${maybeAppendSessionId}` }, - { level: "info", data: `Info-level message${maybeAppendSessionId}` }, - { level: "notice", data: `Notice-level message${maybeAppendSessionId}` }, - { level: "warning", data: `Warning-level message${maybeAppendSessionId}` }, - { level: "error", data: `Error-level message${maybeAppendSessionId}` }, - { level: "critical", data: `Critical-level message${maybeAppendSessionId}` }, - { level: "alert", data: `Alert level-message${maybeAppendSessionId}` }, - { level: "emergency", data: `Emergency-level message${maybeAppendSessionId}` }, - ]; - - if (!logsUpdateInterval) { - console.error("Starting logs update interval"); - logsUpdateInterval = setInterval(async () => { - await server.sendLoggingMessage( messages[Math.floor(Math.random() * messages.length)], sessionId); - }, 15000); - } - }; - - // Helper method to request sampling from client - const requestSampling = async ( - context: string, - uri: string, - maxTokens: number = 100, - sendRequest: SendRequest - ) => { - const request: CreateMessageRequest = { - method: "sampling/createMessage", - params: { - messages: [ - { - role: "user", - content: { - type: "text", - text: `Resource ${uri} context: ${context}`, - }, - }, - ], - systemPrompt: "You are a helpful test server.", - maxTokens, - temperature: 0.7, - includeContext: "thisServer", - }, - }; - - return await sendRequest(request, CreateMessageResultSchema); - - }; - - const ALL_RESOURCES: Resource[] = Array.from({ length: 100 }, (_, i) => { - const uri = `test://static/resource/${i + 1}`; - if (i % 2 === 0) { - return { - uri, - name: `Resource ${i + 1}`, - mimeType: "text/plain", - text: `Resource ${i + 1}: This is a plaintext resource`, - }; - } else { - const buffer = Buffer.from(`Resource ${i + 1}: This is a base64 blob`); - return { - uri, - name: `Resource ${i + 1}`, - mimeType: "application/octet-stream", - blob: buffer.toString("base64"), - }; - } - }); - - const PAGE_SIZE = 10; - - server.setRequestHandler(ListResourcesRequestSchema, async (request) => { - const cursor = request.params?.cursor; - let startIndex = 0; - - if (cursor) { - const decodedCursor = parseInt(atob(cursor), 10); - if (!isNaN(decodedCursor)) { - startIndex = decodedCursor; - } - } - - const endIndex = Math.min(startIndex + PAGE_SIZE, ALL_RESOURCES.length); - const resources = ALL_RESOURCES.slice(startIndex, endIndex); - - let nextCursor: string | undefined; - if (endIndex < ALL_RESOURCES.length) { - nextCursor = btoa(endIndex.toString()); - } - - return { - resources, - nextCursor, - }; - }); - - server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { - return { - resourceTemplates: [ - { - uriTemplate: "test://static/resource/{id}", - name: "Static Resource", - description: "A static resource with a numeric ID", - }, - ], - }; - }); - - server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - const uri = request.params.uri; - - if (uri.startsWith("test://static/resource/")) { - const index = parseInt(uri.split("/").pop() ?? "", 10) - 1; - if (index >= 0 && index < ALL_RESOURCES.length) { - const resource = ALL_RESOURCES[index]; - return { - contents: [resource], - }; - } - } - - throw new Error(`Unknown resource: ${uri}`); - }); - - server.setRequestHandler(SubscribeRequestSchema, async (request, extra) => { - const { uri } = request.params; - subscriptions.add(uri); - return {}; - }); - - server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { - subscriptions.delete(request.params.uri); - return {}; - }); - - server.setRequestHandler(ListPromptsRequestSchema, async () => { - return { - prompts: [ - { - name: PromptName.SIMPLE, - description: "A prompt without arguments", - }, - { - name: PromptName.COMPLEX, - description: "A prompt with arguments", - arguments: [ - { - name: "temperature", - description: "Temperature setting", - required: true, - }, - { - name: "style", - description: "Output style", - required: false, - }, - ], - }, - { - name: PromptName.RESOURCE, - description: "A prompt that includes an embedded resource reference", - arguments: [ - { - name: "resourceId", - description: "Resource ID to include (1-100)", - required: true, - }, - ], - }, - ], - }; - }); - - server.setRequestHandler(GetPromptRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - - if (name === PromptName.SIMPLE) { - return { - messages: [ - { - role: "user", - content: { - type: "text", - text: "This is a simple prompt without arguments.", - }, - }, - ], - }; - } - - if (name === PromptName.COMPLEX) { - return { - messages: [ - { - role: "user", - content: { - type: "text", - text: `This is a complex prompt with arguments: temperature=${args?.temperature}, style=${args?.style}`, - }, - }, - { - role: "assistant", - content: { - type: "text", - text: "I understand. You've provided a complex prompt with temperature and style arguments. How would you like me to proceed?", - }, - }, - { - role: "user", - content: { - type: "image", - data: MCP_TINY_IMAGE, - mimeType: "image/png", - }, - }, - ], - }; - } - - if (name === PromptName.RESOURCE) { - const resourceId = parseInt(args?.resourceId as string, 10); - if (isNaN(resourceId) || resourceId < 1 || resourceId > 100) { - throw new Error( - `Invalid resourceId: ${args?.resourceId}. Must be a number between 1 and 100.` - ); - } - - const resourceIndex = resourceId - 1; - const resource = ALL_RESOURCES[resourceIndex]; - - return { - messages: [ - { - role: "user", - content: { - type: "text", - text: `This prompt includes Resource ${resourceId}. Please analyze the following resource:`, - }, - }, - { - role: "user", - content: { - type: "resource", - resource: resource, - }, - }, - ], - }; - } - - throw new Error(`Unknown prompt: ${name}`); - }); - - server.setRequestHandler(ListToolsRequestSchema, async () => { - const tools: Tool[] = [ - { - name: ToolName.ECHO, - description: "Echoes back the input", - inputSchema: zodToJsonSchema(EchoSchema) as ToolInput, - }, - { - name: ToolName.ADD, - description: "Adds two numbers", - inputSchema: zodToJsonSchema(AddSchema) as ToolInput, - }, - { - name: ToolName.LONG_RUNNING_OPERATION, - description: - "Demonstrates a long running operation with progress updates", - inputSchema: zodToJsonSchema(LongRunningOperationSchema) as ToolInput, - }, - { - name: ToolName.PRINT_ENV, - description: - "Prints all environment variables, helpful for debugging MCP server configuration", - inputSchema: zodToJsonSchema(PrintEnvSchema) as ToolInput, - }, - { - name: ToolName.SAMPLE_LLM, - description: "Samples from an LLM using MCP's sampling feature", - inputSchema: zodToJsonSchema(SampleLLMSchema) as ToolInput, - }, - { - name: ToolName.GET_TINY_IMAGE, - description: "Returns the MCP_TINY_IMAGE", - inputSchema: zodToJsonSchema(GetTinyImageSchema) as ToolInput, - }, - { - name: ToolName.ANNOTATED_MESSAGE, - description: - "Demonstrates how annotations can be used to provide metadata about content", - inputSchema: zodToJsonSchema(AnnotatedMessageSchema) as ToolInput, - }, - { - name: ToolName.GET_RESOURCE_REFERENCE, - description: - "Returns a resource reference that can be used by MCP clients", - inputSchema: zodToJsonSchema(GetResourceReferenceSchema) as ToolInput, - }, - { - name: ToolName.GET_RESOURCE_LINKS, - description: - "Returns multiple resource links that reference different types of resources", - inputSchema: zodToJsonSchema(GetResourceLinksSchema) as ToolInput, - }, - { - name: ToolName.STRUCTURED_CONTENT, - description: - "Returns structured content along with an output schema for client data validation", - inputSchema: zodToJsonSchema(StructuredContentSchema.input) as ToolInput, - outputSchema: zodToJsonSchema(StructuredContentSchema.output) as ToolOutput, - }, - { - name: ToolName.ZIP_RESOURCES, - description: "Compresses the provided resource files (mapping of name to URI, which can be a data URI) to a zip file, which it returns as a data URI resource link.", - inputSchema: zodToJsonSchema(ZipResourcesInputSchema) as ToolInput, - } - ]; - if (clientCapabilities!.roots) tools.push ({ - name: ToolName.LIST_ROOTS, - description: - "Lists the current MCP roots provided by the client. Demonstrates the roots protocol capability even though this server doesn't access files.", - inputSchema: zodToJsonSchema(ListRootsSchema) as ToolInput, - }); - if (clientCapabilities!.elicitation) tools.push ({ - name: ToolName.ELICITATION, - description: "Elicitation test tool that demonstrates how to request user input with various field types (string, boolean, email, uri, date, integer, number, enum)", - inputSchema: zodToJsonSchema(ElicitationSchema) as ToolInput, - }); - - return { tools }; - }); - - server.setRequestHandler(CallToolRequestSchema, async (request,extra) => { - const { name, arguments: args } = request.params; - - if (name === ToolName.ECHO) { - const validatedArgs = EchoSchema.parse(args); - return { - content: [{ type: "text", text: `Echo: ${validatedArgs.message}` }], - }; - } - - if (name === ToolName.ADD) { - const validatedArgs = AddSchema.parse(args); - const sum = validatedArgs.a + validatedArgs.b; - return { - content: [ - { - type: "text", - text: `The sum of ${validatedArgs.a} and ${validatedArgs.b} is ${sum}.`, - }, - ], - }; - } - - if (name === ToolName.LONG_RUNNING_OPERATION) { - const validatedArgs = LongRunningOperationSchema.parse(args); - const { duration, steps } = validatedArgs; - const stepDuration = duration / steps; - const progressToken = request.params._meta?.progressToken; - - for (let i = 1; i < steps + 1; i++) { - await new Promise((resolve) => - setTimeout(resolve, stepDuration * 1000) - ); - - if (progressToken !== undefined) { - await server.notification({ - method: "notifications/progress", - params: { - progress: i, - total: steps, - progressToken, - }, - },{relatedRequestId: extra.requestId}); - } - } - - return { - content: [ - { - type: "text", - text: `Long running operation completed. Duration: ${duration} seconds, Steps: ${steps}.`, - }, - ], - }; - } - - if (name === ToolName.PRINT_ENV) { - return { - content: [ - { - type: "text", - text: JSON.stringify(process.env, null, 2), - }, - ], - }; - } - - if (name === ToolName.SAMPLE_LLM) { - const validatedArgs = SampleLLMSchema.parse(args); - const { prompt, maxTokens } = validatedArgs; - - const result = await requestSampling( - prompt, - ToolName.SAMPLE_LLM, - maxTokens, - extra.sendRequest - ); - return { - content: [ - { type: "text", text: `LLM sampling result: ${Array.isArray(result.content) ? result.content.map(c => c.type === "text" ? c.text : JSON.stringify(c)).join("") : (result.content.type === "text" ? result.content.text : JSON.stringify(result.content))}` }, - ], - }; - } - - if (name === ToolName.GET_TINY_IMAGE) { - GetTinyImageSchema.parse(args); - return { - content: [ - { - type: "text", - text: "This is a tiny image:", - }, - { - type: "image", - data: MCP_TINY_IMAGE, - mimeType: "image/png", - }, - { - type: "text", - text: "The image above is the MCP tiny image.", - }, - ], - }; - } - - if (name === ToolName.ANNOTATED_MESSAGE) { - const { messageType, includeImage } = AnnotatedMessageSchema.parse(args); - - const content = []; - - // Main message with different priorities/audiences based on type - if (messageType === "error") { - content.push({ - type: "text", - text: "Error: Operation failed", - annotations: { - priority: 1.0, // Errors are highest priority - audience: ["user", "assistant"], // Both need to know about errors - }, - }); - } else if (messageType === "success") { - content.push({ - type: "text", - text: "Operation completed successfully", - annotations: { - priority: 0.7, // Success messages are important but not critical - audience: ["user"], // Success mainly for user consumption - }, - }); - } else if (messageType === "debug") { - content.push({ - type: "text", - text: "Debug: Cache hit ratio 0.95, latency 150ms", - annotations: { - priority: 0.3, // Debug info is low priority - audience: ["assistant"], // Technical details for assistant - }, - }); - } - - // Optional image with its own annotations - if (includeImage) { - content.push({ - type: "image", - data: MCP_TINY_IMAGE, - mimeType: "image/png", - annotations: { - priority: 0.5, - audience: ["user"], // Images primarily for user visualization - }, - }); - } - - return { content }; - } - - if (name === ToolName.GET_RESOURCE_REFERENCE) { - const validatedArgs = GetResourceReferenceSchema.parse(args); - const resourceId = validatedArgs.resourceId; - - const resourceIndex = resourceId - 1; - if (resourceIndex < 0 || resourceIndex >= ALL_RESOURCES.length) { - throw new Error(`Resource with ID ${resourceId} does not exist`); - } - - const resource = ALL_RESOURCES[resourceIndex]; - - return { - content: [ - { - type: "text", - text: `Returning resource reference for Resource ${resourceId}:`, - }, - { - type: "resource", - resource: resource, - }, - { - type: "text", - text: `You can access this resource using the URI: ${resource.uri}`, - }, - ], - }; - } - - if (name === ToolName.ELICITATION) { - ElicitationSchema.parse(args); - - const elicitationResult = await extra.sendRequest({ - method: 'elicitation/create', - params: { - message: 'Please provide inputs for the following fields:', - requestedSchema: { - type: 'object', - properties: { - name: { - title: 'String', - type: 'string', - description: 'Your full, legal name', - }, - check: { - 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.', - }, - email: { - 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', - }, - birthdate: { - 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)', - minimum: 1, - maximum: 100, - default: 42, - }, - number: { - 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' - }, - untitledMultipleSelectEnum: { - 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'] - }, - titledSingleSelectEnum: { - 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' } - ], - default: 'hero-1' - }, - titledMultipleSelectEnum: { - 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' } - ] - }, - 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', - } - }, - required: ['name'], - }, - }, - }, ElicitResultSchema, { timeout: 10 * 60 * 1000 /* 10 minutes */ }); - - // Handle different response actions - const content = []; - - if (elicitationResult.action === 'accept' && elicitationResult.content) { - content.push({ - type: "text", - text: `✅ User provided the requested information!`, - }); - - // Only access elicitationResult.content when action is accept - const userData = elicitationResult.content; - const lines = []; - if (userData.name) lines.push(`- Name: ${userData.name}`); - if (userData.check !== undefined) lines.push(`- Agreed to terms: ${userData.check}`); - if (userData.color) lines.push(`- Favorite Color: ${userData.color}`); - if (userData.email) lines.push(`- Email: ${userData.email}`); - if (userData.homepage) lines.push(`- Homepage: ${userData.homepage}`); - if (userData.birthdate) lines.push(`- Birthdate: ${userData.birthdate}`); - if (userData.integer !== undefined) lines.push(`- Favorite Integer: ${userData.integer}`); - if (userData.number !== undefined) lines.push(`- Favorite Number: ${userData.number}`); - if (userData.petType) lines.push(`- Pet Type: ${userData.petType}`); - - content.push({ - type: "text", - text: `User inputs:\n${lines.join('\n')}`, - }); - } else if (elicitationResult.action === 'decline') { - content.push({ - type: "text", - text: `❌ User declined to provide the requested information.`, - }); - } else if (elicitationResult.action === 'cancel') { - content.push({ - type: "text", - text: `⚠️ User cancelled the elicitation dialog.`, - }); - } - - // Include raw result for debugging - content.push({ - type: "text", - text: `\nRaw result: ${JSON.stringify(elicitationResult, null, 2)}`, - }); - - return { content }; - } - - if (name === ToolName.GET_RESOURCE_LINKS) { - const { count } = GetResourceLinksSchema.parse(args); - const content = []; - - // Add intro text - content.push({ - type: "text", - text: `Here are ${count} resource links to resources available in this server (see full output in tool response if your client does not support resource_link yet):`, - }); - - // Return resource links to actual resources from ALL_RESOURCES - const actualCount = Math.min(count, ALL_RESOURCES.length); - for (let i = 0; i < actualCount; i++) { - const resource = ALL_RESOURCES[i]; - content.push({ - type: "resource_link", - uri: resource.uri, - name: resource.name, - description: `Resource ${i + 1}: ${resource.mimeType === "text/plain" - ? "plaintext resource" - : "binary blob resource" - }`, - mimeType: resource.mimeType, - }); - } - - return { content }; - } - - if (name === ToolName.STRUCTURED_CONTENT) { - // The same response is returned for every input. - const validatedArgs = StructuredContentSchema.input.parse(args); - - const weather = { - temperature: 22.5, - conditions: "Partly cloudy", - humidity: 65 - } - - const backwardCompatiblecontent = { - type: "text", - text: JSON.stringify(weather) - } - - return { - content: [backwardCompatiblecontent], - structuredContent: weather - }; - } - - if (name === ToolName.ZIP_RESOURCES) { - const { files } = ZipResourcesInputSchema.parse(args); - - const zip = new JSZip(); - - for (const [fileName, fileUrl] of Object.entries(files)) { - try { - const response = await fetch(fileUrl); - if (!response.ok) { - throw new Error(`Failed to fetch ${fileUrl}: ${response.statusText}`); - } - const arrayBuffer = await response.arrayBuffer(); - zip.file(fileName, arrayBuffer); - } catch (error) { - throw new Error(`Error fetching file ${fileUrl}: ${error instanceof Error ? error.message : String(error)}`); - } - } - - const uri = `data:application/zip;base64,${await zip.generateAsync({ type: "base64" })}`; - - return { - content: [ - { - type: "resource_link", - mimeType: "application/zip", - uri, - }, - ], - }; - } - - if (name === ToolName.LIST_ROOTS) { - ListRootsSchema.parse(args); - - if (!clientSupportsRoots) { - return { - content: [ - { - type: "text", - text: "The MCP client does not support the roots protocol.\n\n" + - "This means the server cannot access information about the client's workspace directories or file system 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." - } - ] - }; - } - - throw new Error(`Unknown tool: ${name}`); - }); - - server.setRequestHandler(CompleteRequestSchema, async (request) => { - const { ref, argument } = request.params; - - if (ref.type === "ref/resource") { - const resourceId = ref.uri.split("/").pop(); - if (!resourceId) return { completion: { values: [] } }; - - // Filter resource IDs that start with the input value - const values = EXAMPLE_COMPLETIONS.resourceId.filter((id) => - id.startsWith(argument.value) - ); - return { completion: { values, hasMore: false, total: values.length } }; - } - - if (ref.type === "ref/prompt") { - // Handle completion for prompt arguments - const completions = - EXAMPLE_COMPLETIONS[argument.name as keyof typeof EXAMPLE_COMPLETIONS]; - if (!completions) return { completion: { values: [] } }; - - const values = completions.filter((value) => - value.startsWith(argument.value) - ); - return { completion: { values, hasMore: false, total: values.length } }; - } - - throw new Error(`Unknown reference type`); - }); - - // Roots protocol handlers - server.setNotificationHandler(RootsListChangedNotificationSchema, async () => { - try { - // Request the updated roots list from the client - const response = await server.listRoots(); - if (response && 'roots' in response) { - currentRoots = response.roots; - - // Log the roots update for demonstration - await server.sendLoggingMessage({ - level: "info", - logger: "everything-server", - data: `Roots updated: ${currentRoots.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); - } - }); - - // Handle post-initialization setup for roots - server.oninitialized = async () => { - clientCapabilities = server.getClientCapabilities(); - - if (clientCapabilities?.roots) { - clientSupportsRoots = true; - try { - const response = await server.listRoots(); - if (response && 'roots' in response) { - currentRoots = response.roots; - - await server.sendLoggingMessage({ - level: "info", - logger: "everything-server", - data: `Initial roots received: ${currentRoots.length} root(s) from client`, - }, sessionId); - } else { - await server.sendLoggingMessage({ - level: "warning", - logger: "everything-server", - data: "Client returned no roots set", - }, sessionId); - } - } catch (error) { - await server.sendLoggingMessage({ - level: "error", - logger: "everything-server", - data: `Failed to request initial roots from client: ${error instanceof Error ? error.message : String(error)}`, - }, sessionId); - } - } else { - await server.sendLoggingMessage({ - level: "info", - logger: "everything-server", - data: "Client does not support MCP roots protocol", - }, sessionId); - } - }; - - const cleanup = async () => { - if (subsUpdateInterval) clearInterval(subsUpdateInterval); - if (logsUpdateInterval) clearInterval(logsUpdateInterval); - if (stdErrUpdateInterval) clearInterval(stdErrUpdateInterval); - }; - - return { server, cleanup, startNotificationIntervals }; -}; - -const MCP_TINY_IMAGE = - "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"; diff --git a/src/everything/index.ts b/src/everything/index.ts index 801fe72165..39d50fa651 100644 --- a/src/everything/index.ts +++ b/src/everything/index.ts @@ -2,36 +2,41 @@ // Parse command line arguments first const args = process.argv.slice(2); -const scriptName = args[0] || 'stdio'; +const scriptName = args[0] || "stdio"; async function run() { - try { - // Dynamically import only the requested module to prevent all modules from initializing - switch (scriptName) { - case 'stdio': - // Import and run the default server - await import('./stdio.js'); - break; - case 'sse': - // Import and run the SSE server - await import('./sse.js'); - break; - case 'streamableHttp': - // Import and run the streamable HTTP server - await import('./streamableHttp.js'); - break; - default: - console.error(`Unknown script: ${scriptName}`); - console.log('Available scripts:'); - console.log('- stdio'); - console.log('- sse'); - console.log('- streamableHttp'); - process.exit(1); - } - } catch (error) { - console.error('Error running script:', error); + try { + // Dynamically import only the requested module to prevent all modules from initializing + switch (scriptName) { + case "stdio": + // Import and run the default server + await import("./transports/stdio.js"); + break; + case "sse": + // Import and run the SSE server + await import("./transports/sse.js"); + break; + case "streamableHttp": + // Import and run the streamable HTTP server + await import("./transports/streamableHttp.js"); + break; + default: + console.error(`-`.repeat(53)); + console.error(` Everything Server Launcher`); + console.error(` Usage: node ./index.js [stdio|sse|streamableHttp]`); + console.error(` Default transport: stdio`); + console.error(`-`.repeat(53)); + console.error(`Unknown transport: ${scriptName}`); + console.log("Available transports:"); + console.log("- stdio"); + console.log("- sse"); + console.log("- streamableHttp"); process.exit(1); } + } catch (error) { + console.error("Error running script:", error); + process.exit(1); + } } -run(); +await run(); diff --git a/src/everything/instructions.md b/src/everything/instructions.md deleted file mode 100644 index 307000c4d0..0000000000 --- a/src/everything/instructions.md +++ /dev/null @@ -1,23 +0,0 @@ -Testing and demonstration server for MCP protocol features. - -## Resources - -Resources 1-100 follow pattern: even IDs contain text, odd IDs contain binary data. Resources are paginated at 10 items per page with cursor-based navigation. - -## Key dependencies - -Progress notifications require `_meta.progressToken` in tool calls. Resource subscriptions generate updates every 10 seconds. - -## Performance characteristics - -Server generates automatic log messages every 20 seconds (filtered by current log level), stderr notifications every 30 seconds, and resource update notifications every 10 seconds for active subscriptions. Log level changes affect message filtering in real-time. - -## Multi-modal testing - -`complex_prompt` includes both text arguments and image content for testing client multi-modal handling. `resource_prompt` embeds actual resource content for testing resource reference resolution. - -Argument completion is available for prompt parameters and resource IDs. Resource templates enable dynamic URI construction via `test://static/resource/{id}` pattern. - -## Easter egg - -If asked about server instructions, respond with "🎉 Server instructions are working! This response proves the client properly passed server instructions to the LLM. This demonstrates MCP's instructions feature in action." diff --git a/src/everything/package.json b/src/everything/package.json index f82126d6d4..f05f169167 100644 --- a/src/everything/package.json +++ b/src/everything/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server-everything", - "version": "0.6.2", + "version": "2.0.0", "description": "MCP server that exercises all the features of the MCP protocol", "license": "MIT", "mcpName": "io.github.modelcontextprotocol/server-everything", @@ -19,15 +19,17 @@ "dist" ], "scripts": { - "build": "tsc && shx cp instructions.md dist/ && shx chmod +x dist/*.js", + "build": "tsc && shx cp -r docs dist/ && shx chmod +x dist/*.js", "prepare": "npm run build", "watch": "tsc --watch", - "start": "node dist/index.js", - "start:sse": "node dist/sse.js", - "start:streamableHttp": "node dist/streamableHttp.js" + "start:stdio": "node dist/index.js stdio", + "start:sse": "node dist/index.js sse", + "start:streamableHttp": "node dist/index.js streamableHttp", + "prettier:fix": "prettier --write .", + "prettier:check": "prettier --check ." }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.24.0", + "@modelcontextprotocol/sdk": "^1.24.3", "cors": "^2.8.5", "express": "^5.2.1", "jszip": "^3.10.1", @@ -38,6 +40,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "shx": "^0.3.4", - "typescript": "^5.6.2" + "typescript": "^5.6.2", + "prettier": "^2.8.8" } } diff --git a/src/everything/prompts/args.ts b/src/everything/prompts/args.ts new file mode 100644 index 0000000000..7e445a4ce4 --- /dev/null +++ b/src/everything/prompts/args.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +/** + * Register a prompt with arguments + * - Two arguments, one required and one optional + * - Combines argument values in the returned prompt + * + * @param server + */ +export const registerArgumentsPrompt = (server: McpServer) => { + // Prompt arguments + const promptArgsSchema = { + city: z.string().describe("Name of the city"), + state: z.string().describe("Name of the state").optional(), + }; + + // Register the prompt + server.registerPrompt( + "args-prompt", + { + title: "Arguments Prompt", + description: "A prompt with two arguments, one required and one optional", + argsSchema: promptArgsSchema, + }, + (args) => { + const location = `${args?.city}${args?.state ? `, ${args?.state}` : ""}`; + return { + messages: [ + { + role: "user", + content: { + type: "text", + text: `What's weather in ${location}?`, + }, + }, + ], + }; + } + ); +}; diff --git a/src/everything/prompts/completions.ts b/src/everything/prompts/completions.ts new file mode 100644 index 0000000000..e47c36e57b --- /dev/null +++ b/src/everything/prompts/completions.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; + +/** + * Register a prompt with completable arguments + * - Two required arguments, both with completion handlers + * - First argument value will be included in context for second argument + * - Allows second argument to depend on the first argument value + * + * @param server + */ +export const registerPromptWithCompletions = (server: McpServer) => { + // Prompt arguments + const promptArgsSchema = { + department: completable( + z.string().describe("Choose the department."), + (value) => { + return ["Engineering", "Sales", "Marketing", "Support"].filter((d) => + d.startsWith(value) + ); + } + ), + name: completable( + z + .string() + .describe("Choose a team member to lead the selected department."), + (value, context) => { + const department = context?.arguments?.["department"]; + if (department === "Engineering") { + return ["Alice", "Bob", "Charlie"].filter((n) => n.startsWith(value)); + } else if (department === "Sales") { + return ["David", "Eve", "Frank"].filter((n) => n.startsWith(value)); + } else if (department === "Marketing") { + return ["Grace", "Henry", "Iris"].filter((n) => n.startsWith(value)); + } else if (department === "Support") { + return ["John", "Kim", "Lee"].filter((n) => n.startsWith(value)); + } + return []; + } + ), + }; + + // Register the prompt + server.registerPrompt( + "completable-prompt", + { + title: "Team Management", + description: "First argument choice narrows values for second argument.", + argsSchema: promptArgsSchema, + }, + ({ department, name }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Please promote ${name} to the head of the ${department} team.`, + }, + }, + ], + }) + ); +}; diff --git a/src/everything/prompts/index.ts b/src/everything/prompts/index.ts new file mode 100644 index 0000000000..6efa7b7297 --- /dev/null +++ b/src/everything/prompts/index.ts @@ -0,0 +1,17 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerSimplePrompt } from "./simple.js"; +import { registerArgumentsPrompt } from "./args.js"; +import { registerPromptWithCompletions } from "./completions.js"; +import { registerEmbeddedResourcePrompt } from "./resource.js"; + +/** + * Register the prompts with the MCP server. + * + * @param server + */ +export const registerPrompts = (server: McpServer) => { + registerSimplePrompt(server); + registerArgumentsPrompt(server); + registerPromptWithCompletions(server); + registerEmbeddedResourcePrompt(server); +}; diff --git a/src/everything/prompts/resource.ts b/src/everything/prompts/resource.ts new file mode 100644 index 0000000000..03989aaa25 --- /dev/null +++ b/src/everything/prompts/resource.ts @@ -0,0 +1,93 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + resourceTypeCompleter, + resourceIdForPromptCompleter, +} from "../resources/templates.js"; +import { + textResource, + textResourceUri, + blobResourceUri, + blobResource, + RESOURCE_TYPE_BLOB, + RESOURCE_TYPE_TEXT, + RESOURCE_TYPES, +} from "../resources/templates.js"; + +/** + * Register a prompt with an embedded resource reference + * - Takes a resource type and id + * - Returns the corresponding dynamically created resource + * + * @param server + */ +export const registerEmbeddedResourcePrompt = (server: McpServer) => { + // Prompt arguments + const promptArgsSchema = { + resourceType: resourceTypeCompleter, + resourceId: resourceIdForPromptCompleter, + }; + + // Register the prompt + server.registerPrompt( + "resource-prompt", + { + title: "Resource Prompt", + description: "A prompt that includes an embedded resource reference", + argsSchema: promptArgsSchema, + }, + (args) => { + // Validate resource type argument + const resourceType = args.resourceType; + if ( + !RESOURCE_TYPES.includes( + resourceType as typeof RESOURCE_TYPE_TEXT | typeof RESOURCE_TYPE_BLOB + ) + ) { + throw new Error( + `Invalid resourceType: ${args?.resourceType}. Must be ${RESOURCE_TYPE_TEXT} or ${RESOURCE_TYPE_BLOB}.` + ); + } + + // Validate resourceId argument + const resourceId = Number(args?.resourceId); + if ( + !Number.isFinite(resourceId) || + !Number.isInteger(resourceId) || + resourceId < 1 + ) { + throw new Error( + `Invalid resourceId: ${args?.resourceId}. Must be a finite positive integer.` + ); + } + + // Get resource based on the resource type + const uri = + resourceType === RESOURCE_TYPE_TEXT + ? textResourceUri(resourceId) + : blobResourceUri(resourceId); + const resource = + resourceType === RESOURCE_TYPE_TEXT + ? textResource(uri, resourceId) + : blobResource(uri, resourceId); + + return { + messages: [ + { + role: "user", + content: { + type: "text", + text: `This prompt includes the ${resourceType} resource with id: ${resourceId}. Please analyze the following resource:`, + }, + }, + { + role: "user", + content: { + type: "resource", + resource: resource, + }, + }, + ], + }; + } + ); +}; diff --git a/src/everything/prompts/simple.ts b/src/everything/prompts/simple.ts new file mode 100644 index 0000000000..a2a0d2eea6 --- /dev/null +++ b/src/everything/prompts/simple.ts @@ -0,0 +1,29 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +/** + * Register a simple prompt with no arguments + * - Returns the fixed text of the prompt with no modifications + * + * @param server + */ +export const registerSimplePrompt = (server: McpServer) => { + // Register the prompt + server.registerPrompt( + "simple-prompt", + { + title: "Simple Prompt", + description: "A prompt with no arguments", + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: "This is a simple prompt without arguments.", + }, + }, + ], + }) + ); +}; diff --git a/src/everything/resources/files.ts b/src/everything/resources/files.ts new file mode 100644 index 0000000000..e38cb59633 --- /dev/null +++ b/src/everything/resources/files.ts @@ -0,0 +1,89 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { readdirSync, readFileSync, statSync } from "fs"; + +/** + * Register static file resources + * - Each file in src/everything/docs is exposed as an individual static resource + * - URIs follow the pattern: "demo://static/docs/" + * - Markdown (.md) files are served as mime type "text/markdown" + * - Text (.txt) files are served as mime type "text/plain" + * - JSON (.json) files are served as mime type "application/json" + * + * @param server + */ +export const registerFileResources = (server: McpServer) => { + // Read the entries in the docs directory + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const docsDir = join(__dirname, "..", "docs"); + let entries: string[] = []; + try { + entries = readdirSync(docsDir); + } catch (e) { + // If docs/ folder is missing or unreadable, just skip registration + return; + } + + // Register each file as a static resource + for (const name of entries) { + // Only process files, not directories + const fullPath = join(docsDir, name); + try { + const st = statSync(fullPath); + if (!st.isFile()) continue; + } catch { + continue; + } + + // Prepare file resource info + const uri = `demo://resource/static/document/${encodeURIComponent(name)}`; + const mimeType = getMimeType(name); + const description = `Static document file exposed from /docs: ${name}`; + + // Register file resource + server.registerResource( + name, + uri, + { mimeType, description }, + async (uri) => { + const text = readFileSafe(fullPath); + return { + contents: [ + { + uri: uri.toString(), + mimeType, + text, + }, + ], + }; + } + ); + } +}; + +/** + * Get the mimetype based on filename + * @param fileName + */ +function getMimeType(fileName: string): string { + const lower = fileName.toLowerCase(); + if (lower.endsWith(".md") || lower.endsWith(".markdown")) + return "text/markdown"; + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".json")) return "application/json"; + return "text/plain"; +} + +/** + * Read a file or return an error message if it fails + * @param path + */ +function readFileSafe(path: string): string { + try { + return readFileSync(path, "utf-8"); + } catch (e) { + return `Error reading file: ${path}. ${e}`; + } +} diff --git a/src/everything/resources/index.ts b/src/everything/resources/index.ts new file mode 100644 index 0000000000..30c6f7dcf8 --- /dev/null +++ b/src/everything/resources/index.ts @@ -0,0 +1,36 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerResourceTemplates } from "./templates.js"; +import { registerFileResources } from "./files.js"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import { readFileSync } from "fs"; + +/** + * Register the resources with the MCP server. + * @param server + */ +export const registerResources = (server: McpServer) => { + registerResourceTemplates(server); + registerFileResources(server); +}; + +/** + * Reads the server instructions from the corresponding markdown file. + * Attempts to load the content of the file located in the `docs` directory. + * If the file cannot be loaded, an error message is returned instead. + * + * @return {string} The content of the server instructions file, or an error message if reading fails. + */ +export function readInstructions(): string { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const filePath = join(__dirname, "..", "docs", "instructions.md"); + let instructions; + + try { + instructions = readFileSync(filePath, "utf-8"); + } catch (e) { + instructions = "Server instructions not loaded: " + e; + } + return instructions; +} diff --git a/src/everything/resources/session.ts b/src/everything/resources/session.ts new file mode 100644 index 0000000000..f4e16d3b78 --- /dev/null +++ b/src/everything/resources/session.ts @@ -0,0 +1,63 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Resource, ResourceLink } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Generates a session-scoped resource URI string based on the provided resource name. + * + * @param {string} name - The name of the resource to create a URI for. + * @returns {string} The formatted session resource URI. + */ +export const getSessionResourceURI = (name: string): string => { + return `demo://resource/session/${name}`; +}; + +/** + * Registers a session-scoped resource with the provided server and returns a resource link. + * + * The registered resource is available during the life of the session only; it is not otherwise persisted. + * + * @param {McpServer} server - The server instance responsible for handling the resource registration. + * @param {Resource} resource - The resource object containing metadata such as URI, name, description, and mimeType. + * @param {"text"|"blob"} type + * @param payload + * @returns {ResourceLink} An object representing the resource link, with associated metadata. + */ +export const registerSessionResource = ( + server: McpServer, + resource: Resource, + type: "text" | "blob", + payload: string +): ResourceLink => { + // Destructure resource + const { uri, name, mimeType, description, title, annotations, icons, _meta } = + resource; + + // Prepare the resource content to return + // See https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-contents + const resourceContent = + type === "text" + ? { + uri: uri.toString(), + mimeType, + text: payload, + } + : { + uri: uri.toString(), + mimeType, + blob: payload, + }; + + // Register file resource + server.registerResource( + name, + uri, + { mimeType, description, title, annotations, icons, _meta }, + async (uri) => { + return { + contents: [resourceContent], + }; + } + ); + + return { type: "resource_link", ...resource }; +}; diff --git a/src/everything/resources/subscriptions.ts b/src/everything/resources/subscriptions.ts new file mode 100644 index 0000000000..2a5e57460f --- /dev/null +++ b/src/everything/resources/subscriptions.ts @@ -0,0 +1,171 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + SubscribeRequestSchema, + UnsubscribeRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +// Track subscriber session id lists by URI +const subscriptions: Map> = new Map< + string, + Set +>(); + +// Interval to send notifications to subscribers +const subsUpdateIntervals: Map = + new Map(); + +/** + * Sets up the subscription and unsubscription handlers for the provided server. + * + * The function defines two request handlers: + * 1. A `Subscribe` handler that allows clients to subscribe to specific resource URIs. + * 2. An `Unsubscribe` handler that allows clients to unsubscribe from specific resource URIs. + * + * The `Subscribe` handler performs the following actions: + * - Extracts the URI and session ID from the request. + * - Logs a message acknowledging the subscription request. + * - Updates the internal tracking of subscribers for the given URI. + * + * The `Unsubscribe` handler performs the following actions: + * - Extracts the URI and session ID from the request. + * - Logs a message acknowledging the unsubscription request. + * - Removes the subscriber for the specified URI. + * + * @param {McpServer} server - The server instance to which subscription handlers will be attached. + */ +export const setSubscriptionHandlers = (server: McpServer) => { + // Set the subscription handler + server.server.setRequestHandler( + SubscribeRequestSchema, + async (request, extra) => { + // Get the URI to subscribe to + const { uri } = request.params; + + // Get the session id (can be undefined for stdio) + const sessionId = extra.sessionId as string; + + // Acknowledge the subscribe request + await server.sendLoggingMessage( + { + level: "info", + data: `Received Subscribe Resource request for URI: ${uri} ${ + sessionId ? `from session ${sessionId}` : "" + }`, + }, + sessionId + ); + + // Get the subscribers for this URI + const subscribers = subscriptions.has(uri) + ? (subscriptions.get(uri) as Set) + : new Set(); + subscribers.add(sessionId); + subscriptions.set(uri, subscribers); + return {}; + } + ); + + // Set the unsubscription handler + server.server.setRequestHandler( + UnsubscribeRequestSchema, + async (request, extra) => { + // Get the URI to subscribe to + const { uri } = request.params; + + // Get the session id (can be undefined for stdio) + const sessionId = extra.sessionId as string; + + // Acknowledge the subscribe request + await server.sendLoggingMessage( + { + level: "info", + data: `Received Unsubscribe Resource request: ${uri} ${ + sessionId ? `from session ${sessionId}` : "" + }`, + }, + sessionId + ); + + // Remove the subscriber + if (subscriptions.has(uri)) { + const subscribers = subscriptions.get(uri) as Set; + if (subscribers.has(sessionId)) subscribers.delete(sessionId); + } + return {}; + } + ); +}; + +/** + * Sends simulated resource update notifications to the subscribed client. + * + * This function iterates through all resource URIs stored in the subscriptions + * and checks if the specified session ID is subscribed to them. If so, it sends + * a notification through the provided server. If the session ID is no longer valid + * (disconnected), it removes the session ID from the list of subscribers. + * + * @param {McpServer} server - The server instance used to send notifications. + * @param {string | undefined} sessionId - The session ID of the client to check for subscriptions. + * @returns {Promise} Resolves once all applicable notifications are sent. + */ +const sendSimulatedResourceUpdates = async ( + server: McpServer, + sessionId: string | undefined +): Promise => { + // Search all URIs for ones this client is subscribed to + for (const uri of subscriptions.keys()) { + const subscribers = subscriptions.get(uri) as Set; + + // If this client is subscribed, send the notification + if (subscribers.has(sessionId)) { + await server.server.notification({ + method: "notifications/resources/updated", + params: { uri }, + }); + } else { + subscribers.delete(sessionId); // subscriber has disconnected + } + } +}; + +/** + * Starts the process of simulating resource updates and sending server notifications + * to the client for the resources they are subscribed to. If the update interval is + * already active, invoking this function will not start another interval. + * + * @param server + * @param sessionId + */ +export const beginSimulatedResourceUpdates = ( + server: McpServer, + sessionId: string | undefined +) => { + if (!subsUpdateIntervals.has(sessionId)) { + // Send once immediately + sendSimulatedResourceUpdates(server, sessionId); + + // Set the interval to send later resource update notifications to this client + subsUpdateIntervals.set( + sessionId, + setInterval(() => sendSimulatedResourceUpdates(server, sessionId), 5000) + ); + } +}; + +/** + * Stops simulated resource updates for a given session. + * + * This function halts any active intervals associated with the provided session ID + * and removes the session's corresponding entries from resource management collections. + * Session ID can be undefined for stdio. + * + * @param {string} [sessionId] + */ +export const stopSimulatedResourceUpdates = (sessionId?: string) => { + // Remove active intervals + if (subsUpdateIntervals.has(sessionId)) { + const subsUpdateInterval = subsUpdateIntervals.get(sessionId); + clearInterval(subsUpdateInterval); + subsUpdateIntervals.delete(sessionId); + } +}; diff --git a/src/everything/resources/templates.ts b/src/everything/resources/templates.ts new file mode 100644 index 0000000000..6d4903f74c --- /dev/null +++ b/src/everything/resources/templates.ts @@ -0,0 +1,211 @@ +import { z } from "zod"; +import { + CompleteResourceTemplateCallback, + McpServer, + ResourceTemplate, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; + +// Resource types +export const RESOURCE_TYPE_TEXT = "Text" as const; +export const RESOURCE_TYPE_BLOB = "Blob" as const; +export const RESOURCE_TYPES: string[] = [ + RESOURCE_TYPE_TEXT, + RESOURCE_TYPE_BLOB, +]; + +/** + * A completer function for resource types. + * + * This variable provides functionality to perform autocompletion for the resource types based on user input. + * It uses a schema description to validate the input and filters through a predefined list of resource types + * to return suggestions that start with the given input. + * + * The input value is expected to be a string representing the type of resource to fetch. + * The completion logic matches the input against available resource types. + */ +export const resourceTypeCompleter = completable( + z.string().describe("Type of resource to fetch"), + (value: string) => { + return RESOURCE_TYPES.filter((t) => t.startsWith(value)); + } +); + +/** + * A completer function for resource IDs as strings. + * + * The `resourceIdCompleter` accepts a string input representing the ID of a text resource + * and validates whether the provided value corresponds to an integer resource ID. + * + * NOTE: Currently, prompt arguments can only be strings since type is not field of `PromptArgument` + * Consequently, we must define it as a string and convert the argument to number before using it + * https://modelcontextprotocol.io/specification/2025-11-25/schema#promptargument + * + * If the value is a valid integer, it returns the value within an array. + * Otherwise, it returns an empty array. + * + * The input string is first transformed into a number and checked to ensure it is an integer. + * This helps validate and suggest appropriate resource IDs. + */ +export const resourceIdForPromptCompleter = completable( + z.string().describe("ID of the text resource to fetch"), + (value: string) => { + const resourceId = Number(value); + return Number.isInteger(resourceId) && resourceId > 0 ? [value] : []; + } +); + +/** + * A callback function that acts as a completer for resource ID values, validating and returning + * the input value as part of a resource template. + * + * @typedef {CompleteResourceTemplateCallback} + * @param {string} value - The input string value to be evaluated as a resource ID. + * @returns {string[]} Returns an array containing the input value if it represents a positive + * integer resource ID, otherwise returns an empty array. + */ +export const resourceIdForResourceTemplateCompleter: CompleteResourceTemplateCallback = + (value: string) => { + const resourceId = Number(value); + + return Number.isInteger(resourceId) && resourceId > 0 ? [value] : []; + }; + +const uriBase: string = "demo://resource/dynamic"; +const textUriBase: string = `${uriBase}/text`; +const blobUriBase: string = `${uriBase}/blob`; +const textUriTemplate: string = `${textUriBase}/{resourceId}`; +const blobUriTemplate: string = `${blobUriBase}/{resourceId}`; + +/** + * Create a dynamic text resource + * - Exposed for use by embedded resource prompt example + * @param uri + * @param resourceId + */ +export const textResource = (uri: URL, resourceId: number) => { + const timestamp = new Date().toLocaleTimeString(); + return { + uri: uri.toString(), + mimeType: "text/plain", + text: `Resource ${resourceId}: This is a plaintext resource created at ${timestamp}`, + }; +}; + +/** + * Create a dynamic blob resource + * - Exposed for use by embedded resource prompt example + * @param uri + * @param resourceId + */ +export const blobResource = (uri: URL, resourceId: number) => { + const timestamp = new Date().toLocaleTimeString(); + const resourceText = Buffer.from( + `Resource ${resourceId}: This is a base64 blob created at ${timestamp}` + ).toString("base64"); + return { + uri: uri.toString(), + mimeType: "text/plain", + blob: resourceText, + }; +}; + +/** + * Create a dynamic text resource URI + * - Exposed for use by embedded resource prompt example + * @param resourceId + */ +export const textResourceUri = (resourceId: number) => + new URL(`${textUriBase}/${resourceId}`); + +/** + * Create a dynamic blob resource URI + * - Exposed for use by embedded resource prompt example + * @param resourceId + */ +export const blobResourceUri = (resourceId: number) => + new URL(`${blobUriBase}/${resourceId}`); + +/** + * Parses the resource identifier from the provided URI and validates it + * against the given variables. Throws an error if the URI corresponds + * to an unknown resource or if the resource identifier is invalid. + * + * @param {URL} uri - The URI of the resource to be parsed. + * @param {Record} variables - A record containing context-specific variables that include the resourceId. + * @returns {number} The parsed and validated resource identifier as an integer. + * @throws {Error} Throws an error if the URI matches unsupported base URIs or if the resourceId is invalid. + */ +const parseResourceId = (uri: URL, variables: Record) => { + const uriError = `Unknown resource: ${uri.toString()}`; + if ( + uri.toString().startsWith(textUriBase) && + uri.toString().startsWith(blobUriBase) + ) { + throw new Error(uriError); + } else { + const idxStr = String((variables as any).resourceId ?? ""); + const idx = Number(idxStr); + if (Number.isFinite(idx) && Number.isInteger(idx) && idx > 0) { + return idx; + } else { + throw new Error(uriError); + } + } +}; + +/** + * Register resource templates with the MCP server. + * - Text and blob resources, dynamically generated from the URI {resourceId} variable + * - Any finite positive integer is acceptable for the resourceId variable + * - List resources method will not return these resources + * - These are only accessible via template URIs + * - Both blob and text resources: + * - have content that is dynamically generated, including a timestamp + * - have different template URIs + * - Blob: "demo://resource/dynamic/blob/{resourceId}" + * - Text: "demo://resource/dynamic/text/{resourceId}" + * + * @param server + */ +export const registerResourceTemplates = (server: McpServer) => { + // Register the text resource template + server.registerResource( + "Dynamic Text Resource", + new ResourceTemplate(textUriTemplate, { + list: undefined, + complete: { resourceId: resourceIdForResourceTemplateCompleter }, + }), + { + mimeType: "text/plain", + description: + "Plaintext dynamic resource fabricated from the {resourceId} variable, which must be an integer.", + }, + async (uri, variables) => { + const resourceId = parseResourceId(uri, variables); + return { + contents: [textResource(uri, resourceId)], + }; + } + ); + + // Register the blob resource template + server.registerResource( + "Dynamic Blob Resource", + new ResourceTemplate(blobUriTemplate, { + list: undefined, + complete: { resourceId: resourceIdForResourceTemplateCompleter }, + }), + { + mimeType: "application/octet-stream", + description: + "Binary (base64) dynamic resource fabricated from the {resourceId} variable, which must be an integer.", + }, + async (uri, variables) => { + const resourceId = parseResourceId(uri, variables); + return { + contents: [blobResource(uri, resourceId)], + }; + } + ); +}; diff --git a/src/everything/server/index.ts b/src/everything/server/index.ts new file mode 100644 index 0000000000..2471c6e8c1 --- /dev/null +++ b/src/everything/server/index.ts @@ -0,0 +1,94 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + setSubscriptionHandlers, + stopSimulatedResourceUpdates, +} from "../resources/subscriptions.js"; +import { registerConditionalTools, registerTools } from "../tools/index.js"; +import { registerResources, readInstructions } from "../resources/index.js"; +import { registerPrompts } from "../prompts/index.js"; +import { stopSimulatedLogging } from "./logging.js"; +import { syncRoots } from "./roots.js"; + +// Server Factory response +export type ServerFactoryResponse = { + server: McpServer; + cleanup: (sessionId?: string) => void; +}; + +/** + * Server Factory + * + * This function initializes a `McpServer` with specific capabilities and instructions, + * registers tools, resources, and prompts, and configures resource subscription handlers. + * + * @returns {ServerFactoryResponse} An object containing the server instance, and a `cleanup` + * function for handling server-side cleanup when a session ends. + * + * Properties of the returned object: + * - `server` {Object}: The initialized server instance. + * - `cleanup` {Function}: Function to perform cleanup operations for a closing session. + */ +export const createServer: () => ServerFactoryResponse = () => { + // Read the server instructions + const instructions = readInstructions(); + + // Create the server + const server = new McpServer( + { + name: "mcp-servers/everything", + title: "Everything Reference Server", + version: "2.0.0", + }, + { + capabilities: { + tools: { + listChanged: true, + }, + prompts: { + listChanged: true, + }, + resources: { + subscribe: true, + listChanged: true, + }, + logging: {}, + }, + instructions, + } + ); + + // Register the tools + registerTools(server); + + // Register the resources + registerResources(server); + + // Register the prompts + registerPrompts(server); + + // Set resource subscription handlers + setSubscriptionHandlers(server); + + // Perform post-initialization operations + server.server.oninitialized = async () => { + // Register conditional tools now that client capabilities are known. + // This finishes before the `notifications/initialized` handler finishes. + registerConditionalTools(server); + + // Sync roots if the client supports them. + // This is delayed until after the `notifications/initialized` handler finishes, + // otherwise, the request gets lost. + const sessionId = server.server.transport?.sessionId; + setTimeout(() => syncRoots(server, sessionId), 350); + }; + + // Return the ServerFactoryResponse + return { + server, + cleanup: (sessionId?: string) => { + // Stop any simulated logging or resource updates that may have been initiated. + stopSimulatedLogging(sessionId); + stopSimulatedResourceUpdates(sessionId); + }, + } satisfies ServerFactoryResponse; +}; diff --git a/src/everything/server/logging.ts b/src/everything/server/logging.ts new file mode 100644 index 0000000000..82edea162c --- /dev/null +++ b/src/everything/server/logging.ts @@ -0,0 +1,82 @@ +import { LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +// Map session ID to the interval for sending logging messages to the client +const logsUpdateIntervals: Map = + new Map(); + +/** + * Initiates a simulated logging process by sending random log messages to the client at a + * fixed interval. Each log message contains a random logging level and optional session ID. + * + * @param {McpServer} server - The server instance responsible for handling the logging messages. + * @param {string | undefined} sessionId - An optional identifier for the session. If provided, + * the session ID will be appended to log messages. + */ +export const beginSimulatedLogging = ( + server: McpServer, + sessionId: string | undefined +) => { + const maybeAppendSessionId = sessionId ? ` - SessionId ${sessionId}` : ""; + const messages: { level: LoggingLevel; data: string }[] = [ + { level: "debug", data: `Debug-level message${maybeAppendSessionId}` }, + { level: "info", data: `Info-level message${maybeAppendSessionId}` }, + { level: "notice", data: `Notice-level message${maybeAppendSessionId}` }, + { + level: "warning", + data: `Warning-level message${maybeAppendSessionId}`, + }, + { level: "error", data: `Error-level message${maybeAppendSessionId}` }, + { + level: "critical", + data: `Critical-level message${maybeAppendSessionId}`, + }, + { level: "alert", data: `Alert level-message${maybeAppendSessionId}` }, + { + level: "emergency", + data: `Emergency-level message${maybeAppendSessionId}`, + }, + ]; + + /** + * Send a simulated logging message to the client + */ + const sendSimulatedLoggingMessage = async (sessionId: string | undefined) => { + // By using the `sendLoggingMessage` function to send the message, we + // ensure that the client's chosen logging level will be respected + await server.sendLoggingMessage( + messages[Math.floor(Math.random() * messages.length)], + sessionId + ); + }; + + // Set the interval to send later logging messages to this client + if (!logsUpdateIntervals.has(sessionId)) { + // Send once immediately + sendSimulatedLoggingMessage(sessionId); + + // Send a randomly-leveled log message every 5 seconds + logsUpdateIntervals.set( + sessionId, + setInterval(() => sendSimulatedLoggingMessage(sessionId), 5000) + ); + } +}; + +/** + * Stops the simulated logging process for a given session. + * + * This function halts the periodic logging updates associated with the specified + * session ID by clearing the interval and removing the session's tracking + * reference. Session ID can be undefined for stdio. + * + * @param {string} [sessionId] - The optional unique identifier of the session. + */ +export const stopSimulatedLogging = (sessionId?: string) => { + // Remove active intervals + if (logsUpdateIntervals.has(sessionId)) { + const logsUpdateInterval = logsUpdateIntervals.get(sessionId); + clearInterval(logsUpdateInterval); + logsUpdateIntervals.delete(sessionId); + } +}; diff --git a/src/everything/server/roots.ts b/src/everything/server/roots.ts new file mode 100644 index 0000000000..b3c413561a --- /dev/null +++ b/src/everything/server/roots.ts @@ -0,0 +1,95 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + Root, + RootsListChangedNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +// Track roots by session id +export const roots: Map = new Map< + string | undefined, + Root[] +>(); + +/** + * Get the latest the client roots list for the session. + * + * - Request and cache the roots list for the session if it has not been fetched before. + * - Return the cached roots list for the session if it exists. + * + * When requesting the roots list for a session, it also sets up a `roots/list_changed` + * notification handler. This ensures that updates are automatically fetched and handled + * in real-time. + * + * This function is idempotent. It should only request roots from the client once per session, + * returning the cached version thereafter. + * + * @param {McpServer} server - An instance of the MCP server used to communicate with the client. + * @param {string} [sessionId] - An optional session id used to associate the roots list with a specific client session. + * + * @throws {Error} In case of a failure to request the roots from the client, an error log message is sent. + */ +export const syncRoots = async (server: McpServer, sessionId?: string) => { + const clientCapabilities = server.server.getClientCapabilities() || {}; + const clientSupportsRoots: boolean = clientCapabilities.roots !== undefined; + + // Fetch the roots list for this client + if (clientSupportsRoots) { + // Function to request the updated roots list from the client + const requestRoots = async () => { + try { + // Request the updated roots list from the client + const response = await server.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 + ); + } else { + await server.sendLoggingMessage( + { + level: "info", + logger: "everything-server", + data: "Client returned no roots set", + }, + 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 + ); + } + }; + + // If the roots have not been synced for this client, + // set notification handler and request initial roots + if (!roots.has(sessionId)) { + // Set the list changed notification handler + server.server.setNotificationHandler( + RootsListChangedNotificationSchema, + requestRoots + ); + + // Request the initial roots list immediately + await requestRoots(); + } + + // Return the roots list for this client + return roots.get(sessionId); + } +}; diff --git a/src/everything/sse.ts b/src/everything/sse.ts deleted file mode 100644 index f5b984e9b1..0000000000 --- a/src/everything/sse.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; -import express from "express"; -import { createServer } from "./everything.js"; -import cors from 'cors'; - -console.error('Starting SSE server...'); - -const app = express(); -app.use(cors({ - "origin": "*", // use "*" with caution in production - "methods": "GET,POST", - "preflightContinue": false, - "optionsSuccessStatus": 204, -})); // Enable CORS for all routes so Inspector can connect -const transports: Map = new Map(); - -app.get("/sse", async (req, res) => { - let transport: SSEServerTransport; - const { server, cleanup, startNotificationIntervals } = createServer(); - - if (req?.query?.sessionId) { - const sessionId = (req?.query?.sessionId as string); - transport = transports.get(sessionId) as SSEServerTransport; - console.error("Client Reconnecting? This shouldn't happen; when client has a sessionId, GET /sse should not be called again.", transport.sessionId); - } else { - // Create and store transport for new session - transport = new SSEServerTransport("/message", res); - transports.set(transport.sessionId, transport); - - // Connect server to transport - await server.connect(transport); - console.error("Client Connected: ", transport.sessionId); - - // Start notification intervals after client connects - startNotificationIntervals(transport.sessionId); - - // Handle close of connection - server.onclose = async () => { - console.error("Client Disconnected: ", transport.sessionId); - transports.delete(transport.sessionId); - await cleanup(); - }; - - } - -}); - -app.post("/message", async (req, res) => { - const sessionId = (req?.query?.sessionId as string); - const transport = transports.get(sessionId); - if (transport) { - console.error("Client Message from", sessionId); - await transport.handlePostMessage(req, res); - } else { - console.error(`No transport found for sessionId ${sessionId}`) - } -}); - -const PORT = process.env.PORT || 3001; -app.listen(PORT, () => { - console.error(`Server is running on port ${PORT}`); -}); diff --git a/src/everything/stdio.ts b/src/everything/stdio.ts deleted file mode 100644 index 102af4f104..0000000000 --- a/src/everything/stdio.ts +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env node - -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { createServer } from "./everything.js"; - -console.error('Starting default (STDIO) server...'); - -async function main() { - const transport = new StdioServerTransport(); - const {server, cleanup, startNotificationIntervals} = createServer(); - - // Cleanup when client disconnects - server.onclose = async () => { - await cleanup(); - process.exit(0); - }; - - await server.connect(transport); - startNotificationIntervals(); - - // Cleanup on exit - process.on("SIGINT", async () => { - await server.close(); - }); -} - -main().catch((error) => { - console.error("Server error:", error); - process.exit(1); -}); - diff --git a/src/everything/streamableHttp.ts b/src/everything/streamableHttp.ts deleted file mode 100644 index c5d0eeea65..0000000000 --- a/src/everything/streamableHttp.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js'; -import express, { Request, Response } from "express"; -import { createServer } from "./everything.js"; -import { randomUUID } from 'node:crypto'; -import cors from 'cors'; - -console.error('Starting Streamable HTTP server...'); - -const app = express(); -app.use(cors({ - "origin": "*", // use "*" with caution in production - "methods": "GET,POST,DELETE", - "preflightContinue": false, - "optionsSuccessStatus": 204, - "exposedHeaders": [ - 'mcp-session-id', - 'last-event-id', - 'mcp-protocol-version' - ] -})); // Enable CORS for all routes so Inspector can connect - -const transports: Map = new Map(); - -app.post('/mcp', async (req: Request, res: Response) => { - console.error('Received MCP POST request'); - try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; - - let transport: StreamableHTTPServerTransport; - - if (sessionId && transports.has(sessionId)) { - // Reuse existing transport - transport = transports.get(sessionId)!; - } else if (!sessionId) { - - const { server, cleanup, startNotificationIntervals } = createServer(); - - // New initialization request - const eventStore = new InMemoryEventStore(); - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore, // Enable resumability - onsessioninitialized: (sessionId: string) => { - // Store the transport by session ID when session is initialized - // This avoids race conditions where requests might come in before the session is stored - console.error(`Session initialized with ID: ${sessionId}`); - transports.set(sessionId, transport); - } - }); - - - // Set up onclose handler to clean up transport when closed - server.onclose = async () => { - const sid = transport.sessionId; - if (sid && transports.has(sid)) { - console.error(`Transport closed for session ${sid}, removing from transports map`); - transports.delete(sid); - await cleanup(); - } - }; - - // Connect the transport to the MCP server BEFORE handling the request - // so responses can flow back through the same transport - await server.connect(transport); - - await transport.handleRequest(req, res); - - // Wait until initialize is complete and transport will have a sessionId - startNotificationIntervals(transport.sessionId); - - return; // Already handled - } else { - // Invalid request - no session ID or not initialization request - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided', - }, - id: req?.body?.id, - }); - return; - } - - // Handle the request with existing transport - no need to reconnect - // The existing transport is already connected to the server - await transport.handleRequest(req, res); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32603, - message: 'Internal server error', - }, - id: req?.body?.id, - }); - return; - } - } -}); - -// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) -app.get('/mcp', async (req: Request, res: Response) => { - console.error('Received MCP GET request'); - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId || !transports.has(sessionId)) { - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided', - }, - id: req?.body?.id, - }); - return; - } - - // Check for Last-Event-ID header for resumability - const lastEventId = req.headers['last-event-id'] as string | undefined; - if (lastEventId) { - console.error(`Client reconnecting with Last-Event-ID: ${lastEventId}`); - } else { - console.error(`Establishing new SSE stream for session ${sessionId}`); - } - - const transport = transports.get(sessionId); - await transport!.handleRequest(req, res); -}); - -// Handle DELETE requests for session termination (according to MCP spec) -app.delete('/mcp', async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId || !transports.has(sessionId)) { - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided', - }, - id: req?.body?.id, - }); - return; - } - - console.error(`Received session termination request for session ${sessionId}`); - - try { - const transport = transports.get(sessionId); - await transport!.handleRequest(req, res); - } catch (error) { - console.error('Error handling session termination:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32603, - message: 'Error handling session termination', - }, - id: req?.body?.id, - }); - return; - } - } -}); - -// Start the server -const PORT = process.env.PORT || 3001; -app.listen(PORT, () => { - console.error(`MCP Streamable HTTP Server listening on port ${PORT}`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.error('Shutting down server...'); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.error(`Closing transport for session ${sessionId}`); - await transports.get(sessionId)!.close(); - transports.delete(sessionId); - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - - console.error('Server shutdown complete'); - process.exit(0); -}); diff --git a/src/everything/tools/echo.ts b/src/everything/tools/echo.ts new file mode 100644 index 0000000000..204a2fb49b --- /dev/null +++ b/src/everything/tools/echo.ts @@ -0,0 +1,34 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +// Tool input schema +export const EchoSchema = z.object({ + message: z.string().describe("Message to echo"), +}); + +// Tool configuration +const name = "echo"; +const config = { + title: "Echo Tool", + description: "Echoes back the input string", + inputSchema: EchoSchema, +}; + +/** + * Registers the 'echo' tool. + * + * The registered tool validates input arguments using the EchoSchema and + * returns a response that echoes the message provided in the arguments. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + * @returns {void} + */ +export const registerEchoTool = (server: McpServer) => { + server.registerTool(name, config, async (args): Promise => { + const validatedArgs = EchoSchema.parse(args); + return { + content: [{ type: "text", text: `Echo: ${validatedArgs.message}` }], + }; + }); +}; diff --git a/src/everything/tools/get-annotated-message.ts b/src/everything/tools/get-annotated-message.ts new file mode 100644 index 0000000000..ead0660e8f --- /dev/null +++ b/src/everything/tools/get-annotated-message.ts @@ -0,0 +1,89 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { MCP_TINY_IMAGE } from "./get-tiny-image.js"; + +// Tool input schema +const GetAnnotatedMessageSchema = z.object({ + messageType: z + .enum(["error", "success", "debug"]) + .describe("Type of message to demonstrate different annotation patterns"), + includeImage: z + .boolean() + .default(false) + .describe("Whether to include an example image"), +}); + +// Tool configuration +const name = "get-annotated-message"; +const config = { + title: "Get Annotated Message Tool", + description: + "Demonstrates how annotations can be used to provide metadata about content.", + inputSchema: GetAnnotatedMessageSchema, +}; + +/** + * Registers the 'get-annotated-message' tool. + * + * The registered tool generates and sends messages with specific types, such as error, + * success, or debug, carrying associated annotations like priority level and intended + * audience. + * + * The response will have annotations and optionally contain an annotated image. + * + * @function + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerGetAnnotatedMessageTool = (server: McpServer) => { + server.registerTool(name, config, async (args): Promise => { + const { messageType, includeImage } = GetAnnotatedMessageSchema.parse(args); + + const content: CallToolResult["content"] = []; + + // Main message with different priorities/audiences based on type + if (messageType === "error") { + content.push({ + type: "text", + text: "Error: Operation failed", + annotations: { + priority: 1.0, // Errors are highest priority + audience: ["user", "assistant"], // Both need to know about errors + }, + }); + } else if (messageType === "success") { + content.push({ + type: "text", + text: "Operation completed successfully", + annotations: { + priority: 0.7, // Success messages are important but not critical + audience: ["user"], // Success mainly for user consumption + }, + }); + } else if (messageType === "debug") { + content.push({ + type: "text", + text: "Debug: Cache hit ratio 0.95, latency 150ms", + annotations: { + priority: 0.3, // Debug info is low priority + audience: ["assistant"], // Technical details for assistant + }, + }); + } + + // Optional image with its own annotations + if (includeImage) { + content.push({ + type: "image", + data: MCP_TINY_IMAGE, + mimeType: "image/png", + annotations: { + priority: 0.5, + audience: ["user"], // Images primarily for user visualization + }, + }); + } + + return { content }; + }); +}; diff --git a/src/everything/tools/get-env.ts b/src/everything/tools/get-env.ts new file mode 100644 index 0000000000..0adbf5a14d --- /dev/null +++ b/src/everything/tools/get-env.ts @@ -0,0 +1,33 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +// Tool configuration +const name = "get-env"; +const config = { + title: "Print Environment Tool", + description: + "Returns all environment variables, helpful for debugging MCP server configuration", + inputSchema: {}, +}; + +/** + * Registers the 'get-env' tool. + * + * The registered tool Retrieves and returns the environment variables + * of the current process as a JSON-formatted string encapsulated in a text response. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + * @returns {void} + */ +export const registerGetEnvTool = (server: McpServer) => { + server.registerTool(name, config, async (args): Promise => { + return { + content: [ + { + type: "text", + text: JSON.stringify(process.env, null, 2), + }, + ], + }; + }); +}; diff --git a/src/everything/tools/get-resource-links.ts b/src/everything/tools/get-resource-links.ts new file mode 100644 index 0000000000..b1fc627e20 --- /dev/null +++ b/src/everything/tools/get-resource-links.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + textResource, + textResourceUri, + blobResourceUri, + blobResource, +} from "../resources/templates.js"; + +// Tool input schema +const GetResourceLinksSchema = z.object({ + count: z + .number() + .min(1) + .max(10) + .default(3) + .describe("Number of resource links to return (1-10)"), +}); + +// Tool configuration +const name = "get-resource-links"; +const config = { + title: "Get Resource Links Tool", + description: + "Returns up to ten resource links that reference different types of resources", + inputSchema: GetResourceLinksSchema, +}; + +/** + * Registers the 'get-resource-reference' tool. + * + * The registered tool retrieves a specified number of resource links and their metadata. + * Resource links are dynamically generated as either text or binary blob resources, + * based on their ID being even or odd. + + * The response contains a "text" introductory block and multiple "resource_link" blocks. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerGetResourceLinksTool = (server: McpServer) => { + server.registerTool(name, config, async (args): Promise => { + const { count } = GetResourceLinksSchema.parse(args); + + // Add intro text content block + const content: CallToolResult["content"] = []; + content.push({ + type: "text", + text: `Here are ${count} resource links to resources available in this server:`, + }); + + // Create resource link content blocks + for (let resourceId = 1; resourceId <= count; resourceId++) { + // Get resource uri for text or blob resource based on odd/even resourceId + const isOdd = resourceId % 2 === 0; + const uri = isOdd + ? textResourceUri(resourceId) + : blobResourceUri(resourceId); + + // Get resource based on the resource type + const resource = isOdd + ? textResource(uri, resourceId) + : blobResource(uri, resourceId); + + content.push({ + type: "resource_link", + uri: resource.uri, + name: `${isOdd ? "Text" : "Blob"} Resource ${resourceId}`, + description: `Resource ${resourceId}: ${ + resource.mimeType === "text/plain" + ? "plaintext resource" + : "binary blob resource" + }`, + mimeType: resource.mimeType, + }); + } + + return { content }; + }); +}; diff --git a/src/everything/tools/get-resource-reference.ts b/src/everything/tools/get-resource-reference.ts new file mode 100644 index 0000000000..d3dc5d3ecb --- /dev/null +++ b/src/everything/tools/get-resource-reference.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + textResource, + textResourceUri, + blobResourceUri, + blobResource, + RESOURCE_TYPE_BLOB, + RESOURCE_TYPE_TEXT, + RESOURCE_TYPES, +} from "../resources/templates.js"; + +// Tool input schema +const GetResourceReferenceSchema = z.object({ + resourceType: z + .enum([RESOURCE_TYPE_TEXT, RESOURCE_TYPE_BLOB]) + .default(RESOURCE_TYPE_TEXT), + resourceId: z + .number() + .default(1) + .describe("ID of the text resource to fetch"), +}); + +// Tool configuration +const name = "get-resource-reference"; +const config = { + title: "Get Resource Reference Tool", + description: "Returns a resource reference that can be used by MCP clients", + inputSchema: GetResourceReferenceSchema, +}; + +/** + * Registers the 'get-resource-reference' tool. + * + * The registered tool validates and processes arguments for retrieving a resource + * reference. Supported resource types include predefined `RESOURCE_TYPE_TEXT` and + * `RESOURCE_TYPE_BLOB`. The retrieved resource's reference will include the resource + * ID, type, and its associated URI. + * + * The tool performs the following operations: + * 1. Validates the `resourceType` argument to ensure it matches a supported type. + * 2. Validates the `resourceId` argument to ensure it is a finite positive integer. + * 3. Constructs a URI for the resource based on its type (text or blob). + * 4. Retrieves the resource and returns it in a content block. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerGetResourceReferenceTool = (server: McpServer) => { + server.registerTool(name, config, async (args): Promise => { + // Validate resource type argument + const { resourceType } = args; + if (!RESOURCE_TYPES.includes(resourceType)) { + throw new Error( + `Invalid resourceType: ${args?.resourceType}. Must be ${RESOURCE_TYPE_TEXT} or ${RESOURCE_TYPE_BLOB}.` + ); + } + + // Validate resourceId argument + const resourceId = Number(args?.resourceId); + if ( + !Number.isFinite(resourceId) || + !Number.isInteger(resourceId) || + resourceId < 1 + ) { + throw new Error( + `Invalid resourceId: ${args?.resourceId}. Must be a finite positive integer.` + ); + } + + // Get resource based on the resource type + const uri = + resourceType === RESOURCE_TYPE_TEXT + ? textResourceUri(resourceId) + : blobResourceUri(resourceId); + const resource = + resourceType === RESOURCE_TYPE_TEXT + ? textResource(uri, resourceId) + : blobResource(uri, resourceId); + + return { + content: [ + { + type: "text", + text: `Returning resource reference for Resource ${resourceId}:`, + }, + { + type: "resource", + resource: resource, + }, + { + type: "text", + text: `You can access this resource using the URI: ${resource.uri}`, + }, + ], + }; + }); +}; diff --git a/src/everything/tools/get-roots-list.ts b/src/everything/tools/get-roots-list.ts new file mode 100644 index 0000000000..62363da26a --- /dev/null +++ b/src/everything/tools/get-roots-list.ts @@ -0,0 +1,92 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { syncRoots } from "../server/roots.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. + * + * If the client does not support the roots capability, the tool is not registered. + * + * The registered tool interacts with the MCP roots capability, which enables the server to access + * information about the client's workspace directories or file system roots. + * + * When supported, the server automatically retrieves and formats the current list of roots from the + * client upon connection and whenever the client sends a `roots/list_changed` notification. + * + * Therefore, this tool displays the roots that the server currently knows about for the connected + * client. If for some reason the server never got the initial roots list, the tool will request the + * list from the client again. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerGetRootsListTool = (server: McpServer) => { + // Does client support roots? + const clientCapabilities = server.server.getClientCapabilities() || {}; + const clientSupportsRoots: boolean = clientCapabilities.roots !== undefined; + + // If so, register tool + if (clientSupportsRoots) { + server.registerTool( + name, + config, + async (args, extra): Promise => { + // Get the current rootsFetch the current roots list from the client if need be + const currentRoots = await syncRoots(server, extra.sessionId); + + // Respond if client supports roots but doesn't have any configured + if ( + clientSupportsRoots && + (!currentRoots || 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", + }, + ], + }; + } + + // Create formatted response if there is a list of roots + const rootsList = currentRoots + ? currentRoots + .map((root, index) => { + return `${index + 1}. ${root.name || "Unnamed Root"}\n URI: ${ + root.uri + }`; + }) + .join("\n\n") + : "No roots found"; + + 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/get-structured-content.ts b/src/everything/tools/get-structured-content.ts new file mode 100644 index 0000000000..83c98c0ab6 --- /dev/null +++ b/src/everything/tools/get-structured-content.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + CallToolResult, + ContentBlock, +} from "@modelcontextprotocol/sdk/types.js"; + +// Tool input schema +const GetStructuredContentInputSchema = { + location: z + .enum(["New York", "Chicago", "Los Angeles"]) + .describe("Choose city"), +}; + +// Tool output schema +const GetStructuredContentOutputSchema = z.object({ + temperature: z.number().describe("Temperature in celsius"), + conditions: z.string().describe("Weather conditions description"), + humidity: z.number().describe("Humidity percentage"), +}); + +// Tool configuration +const name = "get-structured-content"; +const config = { + title: "Get Structured Content Tool", + description: + "Returns structured content along with an output schema for client data validation", + inputSchema: GetStructuredContentInputSchema, + outputSchema: GetStructuredContentOutputSchema, +}; + +/** + * Registers the 'get-structured-content' tool. + * + * The registered tool processes incoming arguments using a predefined input schema, + * generates structured content with weather information including temperature, + * conditions, and humidity, and returns both backward-compatible content blocks + * and structured content in the response. + * + * The response contains: + * - `content`: An array of content blocks, presented as JSON stringified objects. + * - `structuredContent`: A JSON structured representation of the weather data. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerGetStructuredContentTool = (server: McpServer) => { + server.registerTool(name, config, async (args): Promise => { + // Get simulated weather for the chosen city + let weather; + switch (args.location) { + case "New York": + weather = { + temperature: 33, + conditions: "Cloudy", + humidity: 82, + }; + break; + + case "Chicago": + weather = { + temperature: 36, + conditions: "Light rain / drizzle", + humidity: 82, + }; + break; + + case "Los Angeles": + weather = { + temperature: 73, + conditions: "Sunny / Clear", + humidity: 48, + }; + break; + } + + const backwardCompatibleContentBlock: ContentBlock = { + type: "text", + text: JSON.stringify(weather), + }; + + return { + content: [backwardCompatibleContentBlock], + structuredContent: weather, + }; + }); +}; diff --git a/src/everything/tools/get-sum.ts b/src/everything/tools/get-sum.ts new file mode 100644 index 0000000000..522043c88f --- /dev/null +++ b/src/everything/tools/get-sum.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +// Tool input schema +const GetSumSchema = z.object({ + a: z.number().describe("First number"), + b: z.number().describe("Second number"), +}); + +// Tool configuration +const name = "get-sum"; +const config = { + title: "Get Sum Tool", + description: "Returns the sum of two numbers", + inputSchema: GetSumSchema, +}; + +/** + * Registers the 'get-sum' tool. + ** + * The registered tool processes input arguments, validates them using a predefined schema, + * calculates the sum of two numeric values, and returns the result in a content block. + * + * Expects input arguments to conform to a specific schema that includes two numeric properties, `a` and `b`. + * Validation is performed to ensure the input adheres to the expected structure before calculating the sum. + * + * The result is returned as a Promise resolving to an object containing the computed sum in a text format. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerGetSumTool = (server: McpServer) => { + server.registerTool(name, config, async (args): Promise => { + const validatedArgs = GetSumSchema.parse(args); + const sum = validatedArgs.a + validatedArgs.b; + return { + content: [ + { + type: "text", + text: `The sum of ${validatedArgs.a} and ${validatedArgs.b} is ${sum}.`, + }, + ], + }; + }); +}; diff --git a/src/everything/tools/get-tiny-image.ts b/src/everything/tools/get-tiny-image.ts new file mode 100644 index 0000000000..720707d0ce --- /dev/null +++ b/src/everything/tools/get-tiny-image.ts @@ -0,0 +1,47 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +// A tiny encoded MCP logo image +export const MCP_TINY_IMAGE = + "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"; + +// Tool configuration +const name = "get-tiny-image"; +const config = { + title: "Get Tiny Image Tool", + description: "Returns a tiny MCP logo image.", + inputSchema: {}, +}; + +/** + * Registers the "get-tiny-image" tool. + * + * The registered tool returns a response containing a small image alongside some + * descriptive text. + * + * The response structure includes textual content before and after the image. + * The image is served as a PNG data type and represents the default MCP tiny image. + * + * @param server - The McpServer instance where the tool will be registered. + */ +export const registerGetTinyImageTool = (server: McpServer) => { + server.registerTool(name, config, async (args): Promise => { + return { + content: [ + { + type: "text", + text: "Here's the image you requested:", + }, + { + type: "image", + data: MCP_TINY_IMAGE, + mimeType: "image/png", + }, + { + type: "text", + text: "The image above is the MCP logo.", + }, + ], + }; + }); +}; diff --git a/src/everything/tools/gzip-file-as-resource.ts b/src/everything/tools/gzip-file-as-resource.ts new file mode 100644 index 0000000000..608fcf4a0d --- /dev/null +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -0,0 +1,243 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult, Resource } from "@modelcontextprotocol/sdk/types.js"; +import { gzipSync } from "node:zlib"; +import { + getSessionResourceURI, + registerSessionResource, +} from "../resources/session.js"; + +// Maximum input file size - 10 MB default +const GZIP_MAX_FETCH_SIZE = Number( + process.env.GZIP_MAX_FETCH_SIZE ?? String(10 * 1024 * 1024) +); + +// Maximum fetch time - 30 seconds default. +const GZIP_MAX_FETCH_TIME_MILLIS = Number( + process.env.GZIP_MAX_FETCH_TIME_MILLIS ?? String(30 * 1000) +); + +// Comma-separated list of allowed domains. Empty means all domains are allowed. +const GZIP_ALLOWED_DOMAINS = (process.env.GZIP_ALLOWED_DOMAINS ?? "") + .split(",") + .map((d) => d.trim().toLowerCase()) + .filter((d) => d.length > 0); + +// Tool input schema +const GZipFileAsResourceSchema = z.object({ + name: z.string().describe("Name of the output file").default("README.md.gz"), + data: z + .string() + .url() + .describe("URL or data URI of the file content to compress") + .default( + "https://raw.githubusercontent.com/modelcontextprotocol/servers/refs/heads/main/README.md" + ), + outputType: z + .enum(["resourceLink", "resource"]) + .default("resourceLink") + .describe( + "How the resulting gzipped file should be returned. 'resourceLink' returns a link to a resource that can be read later, 'resource' returns a full resource object." + ), +}); + +// Tool configuration +const name = "gzip-file-as-resource"; +const config = { + title: "GZip File as Resource Tool", + description: + "Compresses a single file using gzip compression. Depending upon the selected output type, returns either the compressed data as a gzipped resource or a resource link, allowing it to be downloaded in a subsequent request during the current session.", + inputSchema: GZipFileAsResourceSchema, +}; + +/** + * Registers the `gzip-file-as-resource` tool. + * + * The registered tool compresses input data using gzip, and makes the resulting file accessible + * as a resource for the duration of the session. + * + * The tool supports two output types: + * - "resource": Returns the resource directly, including its URI, MIME type, and base64-encoded content. + * - "resourceLink": Returns a link to access the resource later. + * + * If an unrecognized `outputType` is provided, the tool throws an error. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + * @throws {Error} Throws an error if an unknown output type is specified. + */ +export const registerGZipFileAsResourceTool = (server: McpServer) => { + server.registerTool(name, config, async (args): Promise => { + const { + name, + data: dataUri, + outputType, + } = GZipFileAsResourceSchema.parse(args); + + // Validate data uri + const url = validateDataURI(dataUri); + + // Fetch the data + const response = await fetchSafely(url, { + maxBytes: GZIP_MAX_FETCH_SIZE, + timeoutMillis: GZIP_MAX_FETCH_TIME_MILLIS, + }); + + // Compress the data using gzip + const inputBuffer = Buffer.from(response); + const compressedBuffer = gzipSync(inputBuffer); + + // Create resource + const uri = getSessionResourceURI(name); + const blob = compressedBuffer.toString("base64"); + const mimeType = "application/gzip"; + const resource = { uri, name, mimeType }; + + // Register resource, get resource link in return + const resourceLink = registerSessionResource( + server, + resource, + "blob", + blob + ); + + // Return the resource or a resource link that can be used to access this resource later + if (outputType === "resource") { + return { + content: [ + { + type: "resource", + resource: { uri, mimeType, blob }, + }, + ], + }; + } else if (outputType === "resourceLink") { + return { + content: [resourceLink], + }; + } else { + throw new Error(`Unknown outputType: ${outputType}`); + } + }); +}; + +/** + * Validates a given data URI to ensure it follows the appropriate protocols and rules. + * + * @param {string} dataUri - The data URI to validate. Must be an HTTP, HTTPS, or data protocol URL. If a domain is provided, it must match the allowed domains list if applicable. + * @return {URL} The validated and parsed URL object. + * @throws {Error} If the data URI does not use a supported protocol or does not meet allowed domains criteria. + */ +function validateDataURI(dataUri: string): URL { + // Validate Inputs + const url = new URL(dataUri); + try { + if ( + url.protocol !== "http:" && + url.protocol !== "https:" && + url.protocol !== "data:" + ) { + throw new Error( + `Unsupported URL protocol for ${dataUri}. Only http, https, and data URLs are supported.` + ); + } + if ( + GZIP_ALLOWED_DOMAINS.length > 0 && + (url.protocol === "http:" || url.protocol === "https:") + ) { + const domain = url.hostname; + const domainAllowed = GZIP_ALLOWED_DOMAINS.some((allowedDomain) => { + return domain === allowedDomain || domain.endsWith(`.${allowedDomain}`); + }); + if (!domainAllowed) { + throw new Error(`Domain ${domain} is not in the allowed domains list.`); + } + } + } catch (error) { + throw new Error( + `Error processing file ${dataUri}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + return url; +} + +/** + * Fetches data safely from a given URL while ensuring constraints on maximum byte size and timeout duration. + * + * @param {URL} url The URL to fetch data from. + * @param {Object} options An object containing options for the fetch operation. + * @param {number} options.maxBytes The maximum allowed size (in bytes) of the response. If the response exceeds this size, the operation will be aborted. + * @param {number} options.timeoutMillis The timeout duration (in milliseconds) for the fetch operation. If the fetch takes longer, it will be aborted. + * @return {Promise} A promise that resolves with the response as an ArrayBuffer if successful. + * @throws {Error} Throws an error if the response size exceeds the defined limit, the fetch times out, or the response is otherwise invalid. + */ +async function fetchSafely( + url: URL, + { maxBytes, timeoutMillis }: { maxBytes: number; timeoutMillis: number } +): Promise { + const controller = new AbortController(); + const timeout = setTimeout( + () => + controller.abort( + `Fetching ${url} took more than ${timeoutMillis} ms and was aborted.` + ), + timeoutMillis + ); + + try { + // Fetch the data + const response = await fetch(url, { signal: controller.signal }); + if (!response.body) { + throw new Error("No response body"); + } + + // Note: we can't trust the Content-Length header: a malicious or clumsy server could return much more data than advertised. + // We check it here for early bail-out, but we still need to monitor actual bytes read below. + const contentLengthHeader = response.headers.get("content-length"); + if (contentLengthHeader != null) { + const contentLength = parseInt(contentLengthHeader, 10); + if (contentLength > maxBytes) { + throw new Error( + `Content-Length for ${url} exceeds max of ${maxBytes}: ${contentLength}` + ); + } + } + + // Read the fetched data from the response body + const reader = response.body.getReader(); + const chunks = []; + let totalSize = 0; + + // Read chunks until done + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + totalSize += value.length; + + if (totalSize > maxBytes) { + reader.cancel(); + throw new Error(`Response from ${url} exceeds ${maxBytes} bytes`); + } + + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + // Combine chunks into a single buffer + const buffer = new Uint8Array(totalSize); + let offset = 0; + for (const chunk of chunks) { + buffer.set(chunk, offset); + offset += chunk.length; + } + + return buffer.buffer; + } finally { + clearTimeout(timeout); + } +} diff --git a/src/everything/tools/index.ts b/src/everything/tools/index.ts new file mode 100644 index 0000000000..d3bd2aaff7 --- /dev/null +++ b/src/everything/tools/index.ts @@ -0,0 +1,45 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerGetAnnotatedMessageTool } from "./get-annotated-message.js"; +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"; +import { registerGZipFileAsResourceTool } from "./gzip-file-as-resource.js"; +import { registerToggleSimulatedLoggingTool } from "./toggle-simulated-logging.js"; +import { registerToggleSubscriberUpdatesTool } from "./toggle-subscriber-updates.js"; +import { registerTriggerElicitationRequestTool } from "./trigger-elicitation-request.js"; +import { registerTriggerLongRunningOperationTool } from "./trigger-long-running-operation.js"; +import { registerTriggerSamplingRequestTool } from "./trigger-sampling-request.js"; + +/** + * Register the tools with the MCP server. + * @param server + */ +export const registerTools = (server: McpServer) => { + registerEchoTool(server); + registerGetAnnotatedMessageTool(server); + registerGetEnvTool(server); + registerGetResourceLinksTool(server); + registerGetResourceReferenceTool(server); + registerGetStructuredContentTool(server); + registerGetSumTool(server); + registerGetTinyImageTool(server); + registerGZipFileAsResourceTool(server); + registerToggleSimulatedLoggingTool(server); + registerToggleSubscriberUpdatesTool(server); + registerTriggerLongRunningOperationTool(server); +}; + +/** + * Register the tools that are conditional upon client capabilities. + * These must be registered conditionally, after initialization. + */ +export const registerConditionalTools = (server: McpServer) => { + registerGetRootsListTool(server); + registerTriggerElicitationRequestTool(server); + registerTriggerSamplingRequestTool(server); +}; diff --git a/src/everything/tools/toggle-simulated-logging.ts b/src/everything/tools/toggle-simulated-logging.ts new file mode 100644 index 0000000000..4941ed77d0 --- /dev/null +++ b/src/everything/tools/toggle-simulated-logging.ts @@ -0,0 +1,54 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + beginSimulatedLogging, + stopSimulatedLogging, +} from "../server/logging.js"; + +// Tool configuration +const name = "toggle-simulated-logging"; +const config = { + title: "Toggle Simulated Logging", + description: "Toggles simulated, random-leveled logging on or off.", + inputSchema: {}, +}; + +// Track enabled clients by session id +const clients: Set = new Set(); + +/** + * Registers the `toggle-simulated-logging` tool. + * + * The registered tool enables or disables the sending of periodic, random-leveled + * logging messages the connected client. + * + * When invoked, it either starts or stops simulated logging based on the session's + * current state. If logging for the specified session is active, it will be stopped; + * if it is inactive, logging will be started. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerToggleSimulatedLoggingTool = (server: McpServer) => { + server.registerTool( + name, + config, + async (_args, extra): Promise => { + const sessionId = extra?.sessionId; + + let response: string; + if (clients.has(sessionId)) { + stopSimulatedLogging(sessionId); + clients.delete(sessionId); + response = `Stopped simulated logging for session ${sessionId}`; + } else { + beginSimulatedLogging(server, sessionId); + clients.add(sessionId); + response = `Started simulated, random-leveled logging for session ${sessionId} at a 5 second pace. Client's selected logging level will be respected. If an interval elapses and the message to be sent is below the selected level, it will not be sent. Thus at higher chosen logging levels, messages should arrive further apart. `; + } + + return { + content: [{ type: "text", text: `${response}` }], + }; + } + ); +}; diff --git a/src/everything/tools/toggle-subscriber-updates.ts b/src/everything/tools/toggle-subscriber-updates.ts new file mode 100644 index 0000000000..03b949e232 --- /dev/null +++ b/src/everything/tools/toggle-subscriber-updates.ts @@ -0,0 +1,57 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + beginSimulatedResourceUpdates, + stopSimulatedResourceUpdates, +} from "../resources/subscriptions.js"; + +// Tool configuration +const name = "toggle-subscriber-updates"; +const config = { + title: "Toggle Subscriber Updates", + description: "Toggles simulated resource subscription updates on or off.", + inputSchema: {}, +}; + +// Track enabled clients by session id +const clients: Set = new Set(); + +/** + * Registers the `toggle-subscriber-updates` tool. + * + * The registered tool enables or disables the sending of periodic, simulated resource + * update messages the connected client for any subscriptions they have made. + * + * When invoked, it either starts or stops simulated resource updates based on the session's + * current state. If simulated updates for the specified session is active, it will be stopped; + * if it is inactive, simulated updates will be started. + * + * The response provides feedback indicating whether simulated updates were started or stopped, + * including the session ID. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerToggleSubscriberUpdatesTool = (server: McpServer) => { + server.registerTool( + name, + config, + async (_args, extra): Promise => { + const sessionId = extra?.sessionId; + + let response: string; + if (clients.has(sessionId)) { + stopSimulatedResourceUpdates(sessionId); + clients.delete(sessionId); + response = `Stopped simulated resource updates for session ${sessionId}`; + } else { + beginSimulatedResourceUpdates(server, sessionId); + clients.add(sessionId); + response = `Started simulated resource updated notifications for session ${sessionId} at a 5 second pace. Client will receive updates for any resources the it is subscribed to.`; + } + + return { + content: [{ type: "text", text: `${response}` }], + }; + } + ); +}; diff --git a/src/everything/tools/trigger-elicitation-request.ts b/src/everything/tools/trigger-elicitation-request.ts new file mode 100644 index 0000000000..6281c87d7f --- /dev/null +++ b/src/everything/tools/trigger-elicitation-request.ts @@ -0,0 +1,227 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ElicitResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +// Tool configuration +const name = "trigger-elicitation-request"; +const config = { + title: "Trigger Elicitation Request Tool", + description: "Trigger a Request from the Server for User Elicitation", + inputSchema: {}, +}; + +/** + * Registers the 'trigger-elicitation-request' tool. + * + * If the client does not support the elicitation capability, the tool is not registered. + * + * The registered tool sends an elicitation request for the user to provide information + * based on a pre-defined schema of fields including text inputs, booleans, numbers, + * email, dates, enums of various types, etc. It uses validation and handles multiple + * possible outcomes from the user's response, such as acceptance with content, decline, + * or cancellation of the dialog. The process also ensures parsing and validating + * the elicitation input arguments at runtime. + * + * The elicitation dialog response is returned, formatted into a structured result, + * which contains both user-submitted input data (if provided) and debugging information, + * including raw results. + * + * @param {McpServer} server - TThe McpServer instance where the tool will be registered. + */ +export const registerTriggerElicitationRequestTool = (server: McpServer) => { + // Does the client support elicitation? + const clientCapabilities = server.server.getClientCapabilities() || {}; + const clientSupportsElicitation: boolean = + clientCapabilities.elicitation !== undefined; + + // If so, register tool + if (clientSupportsElicitation) { + server.registerTool( + name, + config, + async (args, extra): Promise => { + const elicitationResult = await extra.sendRequest( + { + method: "elicitation/create", + params: { + message: "Please provide inputs for the following fields:", + requestedSchema: { + type: "object", + properties: { + name: { + title: "String", + type: "string", + description: "Your full, legal name", + }, + check: { + 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.", + }, + email: { + 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", + }, + birthdate: { + 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)", + minimum: 1, + maximum: 100, + default: 42, + }, + number: { + 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", + }, + untitledMultipleSelectEnum: { + 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"], + }, + titledSingleSelectEnum: { + 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" }, + ], + default: "hero-1", + }, + titledMultipleSelectEnum: { + 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" }, + ], + }, + 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", + }, + }, + required: ["name"], + }, + }, + }, + ElicitResultSchema, + { timeout: 10 * 60 * 1000 /* 10 minutes */ } + ); + + // Handle different response actions + const content: CallToolResult["content"] = []; + + if ( + elicitationResult.action === "accept" && + elicitationResult.content + ) { + content.push({ + type: "text", + text: `✅ User provided the requested information!`, + }); + + // Only access elicitationResult.content when action is accept + const userData = elicitationResult.content; + const lines = []; + if (userData.name) lines.push(`- Name: ${userData.name}`); + if (userData.check !== undefined) + lines.push(`- Agreed to terms: ${userData.check}`); + if (userData.color) lines.push(`- Favorite Color: ${userData.color}`); + if (userData.email) lines.push(`- Email: ${userData.email}`); + if (userData.homepage) lines.push(`- Homepage: ${userData.homepage}`); + if (userData.birthdate) + lines.push(`- Birthdate: ${userData.birthdate}`); + if (userData.integer !== undefined) + lines.push(`- Favorite Integer: ${userData.integer}`); + if (userData.number !== undefined) + lines.push(`- Favorite Number: ${userData.number}`); + if (userData.petType) lines.push(`- Pet Type: ${userData.petType}`); + + content.push({ + type: "text", + text: `User inputs:\n${lines.join("\n")}`, + }); + } else if (elicitationResult.action === "decline") { + content.push({ + type: "text", + text: `❌ User declined to provide the requested information.`, + }); + } else if (elicitationResult.action === "cancel") { + content.push({ + type: "text", + text: `⚠️ User cancelled the elicitation dialog.`, + }); + } + + // Include raw result for debugging + content.push({ + type: "text", + text: `\nRaw result: ${JSON.stringify(elicitationResult, null, 2)}`, + }); + + return { content }; + } + ); + } +}; diff --git a/src/everything/tools/trigger-long-running-operation.ts b/src/everything/tools/trigger-long-running-operation.ts new file mode 100644 index 0000000000..8af45ce60b --- /dev/null +++ b/src/everything/tools/trigger-long-running-operation.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +// Tool input schema +const TriggerLongRunningOperationSchema = z.object({ + duration: z + .number() + .default(10) + .describe("Duration of the operation in seconds"), + steps: z.number().default(5).describe("Number of steps in the operation"), +}); + +// Tool configuration +const name = "trigger-long-running-operation"; +const config = { + title: "Trigger Long Running Operation Tool", + description: "Demonstrates a long running operation with progress updates.", + inputSchema: TriggerLongRunningOperationSchema, +}; + +/** + * Registers the 'trigger-tong-running-operation' tool. + * + * The registered tool starts a long-running operation defined by a specific duration and + * number of steps. + * + * Progress notifications are sent back to the client at each step if a `progressToken` + * is provided in the metadata. + * + * At the end of the operation, the tool returns a message indicating the completion of the + * operation, including the total duration and steps. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerTriggerLongRunningOperationTool = (server: McpServer) => { + server.registerTool( + name, + config, + async (args, extra): Promise => { + const validatedArgs = TriggerLongRunningOperationSchema.parse(args); + const { duration, steps } = validatedArgs; + const stepDuration = duration / steps; + const progressToken = extra._meta?.progressToken; + + for (let i = 1; i < steps + 1; i++) { + await new Promise((resolve) => + setTimeout(resolve, stepDuration * 1000) + ); + + if (progressToken !== undefined) { + await server.server.notification( + { + method: "notifications/progress", + params: { + progress: i, + total: steps, + progressToken, + }, + }, + { relatedRequestId: extra.requestId } + ); + } + } + + return { + content: [ + { + type: "text", + text: `Long running operation completed. Duration: ${duration} seconds, Steps: ${steps}.`, + }, + ], + }; + } + ); +}; diff --git a/src/everything/tools/trigger-sampling-request.ts b/src/everything/tools/trigger-sampling-request.ts new file mode 100644 index 0000000000..5785f5210f --- /dev/null +++ b/src/everything/tools/trigger-sampling-request.ts @@ -0,0 +1,91 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + CallToolResult, + CreateMessageRequest, + CreateMessageResultSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +// Tool input schema +const TriggerSamplingRequestSchema = z.object({ + prompt: z.string().describe("The prompt to send to the LLM"), + maxTokens: z + .number() + .default(100) + .describe("Maximum number of tokens to generate"), +}); + +// Tool configuration +const name = "trigger-sampling-request"; +const config = { + title: "Trigger Sampling Request Tool", + description: "Trigger a Request from the Server for LLM Sampling", + inputSchema: TriggerSamplingRequestSchema, +}; + +/** + * Registers the 'trigger-sampling-request' tool. + * + * If the client does not support the sampling capability, the tool is not registered. + * + * The registered tool performs the following operations: + * - Validates incoming arguments using `TriggerSamplingRequestSchema`. + * - Constructs a `sampling/createMessage` request object using provided prompt and maximum tokens. + * - Sends the request to the server for sampling. + * - Formats and returns the sampling result content to the client. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerTriggerSamplingRequestTool = (server: McpServer) => { + // Does the client support sampling? + const clientCapabilities = server.server.getClientCapabilities() || {}; + const clientSupportsSampling: boolean = + clientCapabilities.sampling !== undefined; + + // If so, register tool + if (clientSupportsSampling) { + server.registerTool( + name, + config, + async (args, extra): Promise => { + const validatedArgs = TriggerSamplingRequestSchema.parse(args); + const { prompt, maxTokens } = validatedArgs; + + // Create the sampling request + const request: CreateMessageRequest = { + method: "sampling/createMessage", + params: { + messages: [ + { + role: "user", + content: { + type: "text", + text: `Resource ${name} context: ${prompt}`, + }, + }, + ], + systemPrompt: "You are a helpful test server.", + maxTokens, + temperature: 0.7, + }, + }; + + // Send the sampling request to the client + const result = await extra.sendRequest( + request, + CreateMessageResultSchema + ); + + // Return the result to the client + return { + content: [ + { + type: "text", + text: `LLM sampling result: \n${JSON.stringify(result, null, 2)}`, + }, + ], + }; + } + ); + } +}; diff --git a/src/everything/transports/sse.ts b/src/everything/transports/sse.ts new file mode 100644 index 0000000000..2406db7cfa --- /dev/null +++ b/src/everything/transports/sse.ts @@ -0,0 +1,77 @@ +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import express from "express"; +import { createServer } from "../server/index.js"; +import cors from "cors"; + +console.error("Starting SSE server..."); + +// Express app with permissive CORS for testing with Inspector direct connect mode +const app = express(); +app.use( + cors({ + origin: "*", // use "*" with caution in production + methods: "GET,POST", + preflightContinue: false, + optionsSuccessStatus: 204, + }) +); + +// Map sessionId to transport for each client +const transports: Map = new Map< + string, + SSEServerTransport +>(); + +// Handle GET requests for new SSE streams +app.get("/sse", async (req, res) => { + let transport: SSEServerTransport; + const { server, cleanup } = createServer(); + + // Session Id should not exist for GET /sse requests + if (req?.query?.sessionId) { + const sessionId = req?.query?.sessionId as string; + transport = transports.get(sessionId) as SSEServerTransport; + console.error( + "Client Reconnecting? This shouldn't happen; when client has a sessionId, GET /sse should not be called again.", + transport.sessionId + ); + } else { + // Create and store transport for the new session + transport = new SSEServerTransport("/message", res); + transports.set(transport.sessionId, transport); + + // Connect server to transport + await server.connect(transport); + const sessionId = transport.sessionId; + console.error("Client Connected: ", sessionId); + + // Handle close of connection + server.server.onclose = async () => { + const sessionId = transport.sessionId; + console.error("Client Disconnected: ", sessionId); + transports.delete(sessionId); + cleanup(sessionId); + }; + } +}); + +// Handle POST requests for client messages +app.post("/message", async (req, res) => { + // Session Id should exist for POST /message requests + const sessionId = req?.query?.sessionId as string; + + // Get the transport for this session and use it to handle the request + const transport = transports.get(sessionId); + if (transport) { + console.error("Client Message from", sessionId); + await transport.handlePostMessage(req, res); + } else { + console.error(`No transport found for sessionId ${sessionId}`); + } +}); + +// Start the express server +const PORT = process.env.PORT || 3001; +app.listen(PORT, () => { + console.error(`Server is running on port ${PORT}`); +}); diff --git a/src/everything/transports/stdio.ts b/src/everything/transports/stdio.ts new file mode 100644 index 0000000000..3e653bcf4d --- /dev/null +++ b/src/everything/transports/stdio.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createServer } from "../server/index.js"; + +console.error("Starting default (STDIO) server..."); + +/** + * The main method + * - Initializes the StdioServerTransport, sets up the server, + * - Handles cleanup on process exit. + * + * @return {Promise} A promise that resolves when the main function has executed and the process exits. + */ +async function main(): Promise { + const transport = new StdioServerTransport(); + const { server, cleanup } = createServer(); + + // Connect transport to server + await server.connect(transport); + + // Cleanup on exit + process.on("SIGINT", async () => { + await server.close(); + cleanup(); + process.exit(0); + }); +} + +main().catch((error) => { + console.error("Server error:", error); + process.exit(1); +}); diff --git a/src/everything/transports/streamableHttp.ts b/src/everything/transports/streamableHttp.ts new file mode 100644 index 0000000000..13ed250776 --- /dev/null +++ b/src/everything/transports/streamableHttp.ts @@ -0,0 +1,209 @@ +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js"; +import express, { Request, Response } from "express"; +import { createServer } from "../server/index.js"; +import { randomUUID } from "node:crypto"; +import cors from "cors"; + +console.log("Starting Streamable HTTP server..."); + +// Express app with permissive CORS for testing with Inspector direct connect mode +const app = express(); +app.use( + cors({ + origin: "*", // use "*" with caution in production + methods: "GET,POST,DELETE", + preflightContinue: false, + optionsSuccessStatus: 204, + exposedHeaders: ["mcp-session-id", "last-event-id", "mcp-protocol-version"], + }) +); + +// Map sessionId to server transport for each client +const transports: Map = new Map< + string, + StreamableHTTPServerTransport +>(); + +// Handle POST requests for client messages +app.post("/mcp", async (req: Request, res: Response) => { + console.log("Received MCP POST request"); + try { + // Check for existing session ID + const sessionId = req.headers["mcp-session-id"] as string | undefined; + + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports.has(sessionId)) { + // Reuse existing transport + transport = transports.get(sessionId)!; + } else if (!sessionId) { + const { server, cleanup } = createServer(); + + // New initialization request + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability + onsessioninitialized: (sessionId: string) => { + // Store the transport by session ID when a session is initialized + // 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); + }, + }); + + // Set up onclose handler to clean up transport when closed + server.server.onclose = async () => { + const sid = transport.sessionId; + if (sid && transports.has(sid)) { + console.log( + `Transport closed for session ${sid}, removing from transports map` + ); + transports.delete(sid); + cleanup(sid); + } + }; + + // Connect the transport to the MCP server BEFORE handling the request + // so responses can flow back through the same transport + await server.connect(transport); + await transport.handleRequest(req, res); + return; + } else { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: No valid session ID provided", + }, + id: req?.body?.id, + }); + return; + } + + // Handle the request with existing transport - no need to reconnect + // The existing transport is already connected to the server + await transport.handleRequest(req, res); + } catch (error) { + console.log("Error handling MCP request:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { + code: -32603, + message: "Internal server error", + }, + id: req?.body?.id, + }); + return; + } + } +}); + +// Handle GET requests for SSE streams +app.get("/mcp", async (req: Request, res: Response) => { + console.log("Received MCP GET request"); + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports.has(sessionId)) { + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: No valid session ID provided", + }, + id: req?.body?.id, + }); + return; + } + + // Check for Last-Event-ID header for resumability + const lastEventId = req.headers["last-event-id"] as string | undefined; + if (lastEventId) { + console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); + } else { + console.log(`Establishing new SSE stream for session ${sessionId}`); + } + + const transport = transports.get(sessionId); + await transport!.handleRequest(req, res); +}); + +// Handle DELETE requests for session termination +app.delete("/mcp", async (req: Request, res: Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports.has(sessionId)) { + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: No valid session ID provided", + }, + id: req?.body?.id, + }); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports.get(sessionId); + await transport!.handleRequest(req, res); + } catch (error) { + console.log("Error handling session termination:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { + code: -32603, + message: "Error handling session termination", + }, + id: req?.body?.id, + }); + return; + } + } +}); + +// Start the server +const PORT = process.env.PORT || 3001; +const server = app.listen(PORT, () => { + console.error(`MCP Streamable HTTP Server listening on port ${PORT}`); +}); + +// Handle server errors +server.on("error", (err: unknown) => { + const code = + typeof err === "object" && err !== null && "code" in err + ? (err as { code?: unknown }).code + : undefined; + if (code === "EADDRINUSE") { + console.error( + `Failed to start: Port ${PORT} is already in use. Set PORT to a free port or stop the conflicting process.` + ); + } else { + console.error("HTTP server encountered an error while starting:", err); + } + // Ensure a non-zero exit so npm reports the failure instead of silently exiting + process.exit(1); +}); + +// Handle server shutdown +process.on("SIGINT", async () => { + console.log("Shutting down server..."); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports.get(sessionId)!.close(); + transports.delete(sessionId); + } catch (error) { + console.log(`Error closing transport for session ${sessionId}:`, error); + } + } + + console.log("Server shutdown complete"); + process.exit(0); +}); diff --git a/src/everything/tsconfig.json b/src/everything/tsconfig.json index ec5da15825..829d52d66b 100644 --- a/src/everything/tsconfig.json +++ b/src/everything/tsconfig.json @@ -4,7 +4,5 @@ "outDir": "./dist", "rootDir": "." }, - "include": [ - "./**/*.ts" - ] + "include": ["./**/*.ts"] }