diff --git a/src/everything/docs/architecture.md b/src/everything/docs/architecture.md index eb815648..b2e5fb71 100644 --- a/src/everything/docs/architecture.md +++ b/src/everything/docs/architecture.md @@ -31,6 +31,7 @@ src/everything ├── resources │ ├── index.ts │ ├── files.ts +│ ├── session.ts │ ├── subscriptions.ts │ └── templates.ts ├── server @@ -51,6 +52,7 @@ src/everything │ ├── get-resource-reference.ts │ ├── get-structured-content.ts │ ├── get-sum.ts +│ ├── gzip-file-as-resource.ts │ ├── long-running-operation.ts │ ├── get-sampling-request.ts │ ├── toggle-logging.ts @@ -104,6 +106,15 @@ At `src/everything`: - 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. + - 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) - get-sampling-request.ts - Registers a `sampling-request` tool that sends a `sampling/createMessage` request to the client/LLM and returns the sampling result. - get-structured-content.ts @@ -198,6 +209,7 @@ At `src/everything`: - `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`. + - `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-sampling-request` (tools/get-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. - `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. @@ -217,7 +229,8 @@ At `src/everything`: - 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 Docs: `demo://resource/static/document/` (serves files from `src/everything/docs/` as static file-based resources) + - 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 @@ -256,6 +269,14 @@ At `src/everything`: - Design note: Each client session has its own `McpServer` instance; periodic checks run per session and invoke `server.notification(...)` on that instance, so messages are delivered only to the intended client. +## Session‑scoped Resources – How It Works + +- 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` gzips 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 – How It Works - Module: `server/logging.ts` diff --git a/src/everything/resources/session.ts b/src/everything/resources/session.ts new file mode 100644 index 00000000..f4e16d3b --- /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/tools/gzip-file-as-resource.ts b/src/everything/tools/gzip-file-as-resource.ts new file mode 100644 index 00000000..4396b6a0 --- /dev/null +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -0,0 +1,228 @@ +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, +}; + +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 index 627075ff..7ad57eb1 100644 --- a/src/everything/tools/index.ts +++ b/src/everything/tools/index.ts @@ -8,6 +8,7 @@ import { registerGetSamplingRequestTool } from "./get-sampling-request.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 { registerLongRunningOperationTool } from "./long-running-operation.js"; import { registerToggleLoggingTool } from "./toggle-logging.js"; import { registerToggleSubscriberUpdatesTool } from "./toggle-subscriber-updates.js"; @@ -26,6 +27,7 @@ export const registerTools = (server: McpServer) => { registerGetStructuredContentTool(server); registerGetSumTool(server); registerGetTinyImageTool(server); + registerGZipFileAsResourceTool(server); registerLongRunningOperationTool(server); registerToggleLoggingTool(server); registerToggleSubscriberUpdatesTool(server);