From 0d01c5fb0774b3226e8e0f8f4c3b77160b082331 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 8 Oct 2025 18:39:45 +0100 Subject: [PATCH 1/9] Add zip tool to MCP server (demonstrates consuming and producing URIs, incl. data URIs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change ports the ZIP tool from PR #2830 in the modelcontextprotocol/servers repository to the example-remote-server codebase. The zip tool: - Takes a mapping of filenames to URLs (which can be data URIs) - Fetches files from those URLs - Compresses them into a zip archive - Returns the zip as a data URI resource link This demonstrates best practices for handling URIs in MCP tools, including both consuming input URIs and producing output data URIs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- mcp-server/package.json | 1 + mcp-server/src/services/mcp.ts | 51 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/mcp-server/package.json b/mcp-server/package.json index c64e659..37036c1 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -21,6 +21,7 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "express-rate-limit": "^8.0.1", + "jszip": "^3.10.1", "raw-body": "^3.0.0" }, "devDependencies": { diff --git a/mcp-server/src/services/mcp.ts b/mcp-server/src/services/mcp.ts index 39192b5..5c740b0 100644 --- a/mcp-server/src/services/mcp.ts +++ b/mcp-server/src/services/mcp.ts @@ -19,6 +19,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; +import JSZip from "jszip"; type ToolInput = { type: "object"; @@ -79,6 +80,12 @@ const GetResourceReferenceSchema = z.object({ .describe("ID of the resource to reference (1-100)"), }); +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", @@ -87,6 +94,7 @@ enum ToolName { GET_TINY_IMAGE = "getTinyImage", ANNOTATED_MESSAGE = "annotatedMessage", GET_RESOURCE_REFERENCE = "getResourceReference", + ZIP_RESOURCES = "zip", } enum PromptName { @@ -446,6 +454,12 @@ export const createMcpServer = (): McpServerWrapper => { "Returns a resource reference that can be used by MCP clients", inputSchema: zodToJsonSchema(GetResourceReferenceSchema) as ToolInput, }, + { + 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, + }, ]; return { tools }; @@ -624,6 +638,43 @@ export const createMcpServer = (): McpServerWrapper => { return { content }; } + 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, + }, + ], + }; + } + throw new Error(`Unknown tool: ${name}`); }); From b7c14285400ba4e22695076f60a561fc55657d5a Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 8 Oct 2025 19:54:48 +0100 Subject: [PATCH 2/9] Add outputType parameter to zip tool (inlined / resource link / resource) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change ports the enhancements from PR #2831 in the modelcontextprotocol/servers repository to the example-remote-server codebase. The zip tool now supports three output formats via the 'outputType' parameter: 1. 'inlinedResourceLink' (default) - Returns a resource_link with a data URI (the original behavior, most efficient for small files) 2. 'resourceLink' - Returns a link to a resource stored in a transient map, allowing clients to read the resource later via ReadResource requests 3. 'resource' - Returns the full resource object directly inline This demonstrates best practices for handling multiple output formats and managing transient resources that can be read after the tool completes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- mcp-server/src/services/mcp.ts | 69 +++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/mcp-server/src/services/mcp.ts b/mcp-server/src/services/mcp.ts index 5c740b0..f8cc70a 100644 --- a/mcp-server/src/services/mcp.ts +++ b/mcp-server/src/services/mcp.ts @@ -84,6 +84,12 @@ 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"), + outputType: z + .enum(["resourceLink", "inlinedResourceLink", "resource"]) + .default("inlinedResourceLink") + .describe( + "How the resulting zip file should be returned. 'resourceLink' returns a link to a resource that can be read later, 'inlinedResourceLink' returns a resource_link with a data URI, and 'resource' returns a full resource object." + ), }); enum ToolName { @@ -126,6 +132,7 @@ export const createMcpServer = (): McpServerWrapper => { ); const subscriptions: Set = new Set(); + const transientResources: Map = new Map(); // Set up update interval for subscribed resources const subsUpdateInterval = setInterval(() => { @@ -269,6 +276,12 @@ export const createMcpServer = (): McpServerWrapper => { server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; + if (transientResources.has(uri)) { + return { + contents: [transientResources.get(uri)!], + }; + } + if (uri.startsWith("test://static/resource/")) { const index = parseInt(uri.split("/").pop() ?? "", 10) - 1; if (index >= 0 && index < ALL_RESOURCES.length) { @@ -639,7 +652,7 @@ export const createMcpServer = (): McpServerWrapper => { } if (name === ToolName.ZIP_RESOURCES) { - const { files } = ZipResourcesInputSchema.parse(args); + const { files, outputType } = ZipResourcesInputSchema.parse(args); const zip = new JSZip(); @@ -660,19 +673,49 @@ export const createMcpServer = (): McpServerWrapper => { } } - const uri = `data:application/zip;base64,${await zip.generateAsync({ - type: "base64", - })}`; + const blob = await zip.generateAsync({ type: "base64" }); + const mimeType = "application/zip"; - return { - content: [ - { - type: "resource_link", - mimeType: "application/zip", - uri, - }, - ], - }; + if (outputType === "inlinedResourceLink") { + const uri = `data:${mimeType};base64,${blob}`; + return { + content: [ + { + type: "resource_link", + mimeType, + uri, + }, + ], + }; + } else { + const name = `out_${Date.now()}.zip`; + const uri = `resource://${name}`; + const resource: Resource = { uri, name, mimeType, blob }; + + if (outputType === "resource") { + return { + content: [ + { + type: "resource", + resource, + }, + ], + }; + } else if (outputType === "resourceLink") { + transientResources.set(uri, resource); + return { + content: [ + { + type: "resource_link", + mimeType, + uri, + }, + ], + }; + } else { + throw new Error(`Unknown outputType: ${outputType}`); + } + } } throw new Error(`Unknown tool: ${name}`); From cf64b569a1d6269144a09520e7a8b62ee87aa40c Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 9 Oct 2025 16:05:44 +0100 Subject: [PATCH 3/9] Improve zip tool description to highlight multiple output format support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- mcp-server/src/services/mcp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp-server/src/services/mcp.ts b/mcp-server/src/services/mcp.ts index f8cc70a..434823f 100644 --- a/mcp-server/src/services/mcp.ts +++ b/mcp-server/src/services/mcp.ts @@ -470,7 +470,7 @@ export const createMcpServer = (): McpServerWrapper => { { 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", + "Compresses the provided resource files (mapping of name to URI, which can be a data URI) to a zip file. Supports multiple output formats: inlined data URI (default), resource link, or full resource object", inputSchema: zodToJsonSchema(ZipResourcesInputSchema) as ToolInput, }, ]; From 13814ba79d7c2b3a2111b8dba641e7daaa415ee3 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 9 Oct 2025 16:17:47 +0100 Subject: [PATCH 4/9] remove inlinedResourceLink --- mcp-server/src/services/mcp.ts | 66 ++++++++++++---------------------- 1 file changed, 22 insertions(+), 44 deletions(-) diff --git a/mcp-server/src/services/mcp.ts b/mcp-server/src/services/mcp.ts index 434823f..59b7c41 100644 --- a/mcp-server/src/services/mcp.ts +++ b/mcp-server/src/services/mcp.ts @@ -84,12 +84,10 @@ 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"), - outputType: z - .enum(["resourceLink", "inlinedResourceLink", "resource"]) - .default("inlinedResourceLink") - .describe( - "How the resulting zip file should be returned. 'resourceLink' returns a link to a resource that can be read later, 'inlinedResourceLink' returns a resource_link with a data URI, and 'resource' returns a full resource object." - ), + outputType: z.enum([ + 'resourceLink', + 'resource' + ]).default('resource').describe("How the resulting zip file should be returned. 'resourceLink' returns a linked to a resource that can be read later, 'resource' returns a full resource object."), }); enum ToolName { @@ -653,7 +651,6 @@ export const createMcpServer = (): McpServerWrapper => { if (name === ToolName.ZIP_RESOURCES) { const { files, outputType } = ZipResourcesInputSchema.parse(args); - const zip = new JSZip(); for (const [fileName, fileUrl] of Object.entries(files)) { @@ -675,46 +672,27 @@ export const createMcpServer = (): McpServerWrapper => { const blob = await zip.generateAsync({ type: "base64" }); const mimeType = "application/zip"; - - if (outputType === "inlinedResourceLink") { - const uri = `data:${mimeType};base64,${blob}`; + const name = `out_${Date.now()}.zip`; + const uri = `resource://${name}`; + const resource: Resource = { uri, name, mimeType, blob }; + if (outputType === "resource") { return { - content: [ - { - type: "resource_link", - mimeType, - uri, - }, - ], + content: [{ + type: "resource", + resource + }] + }; + } else if (outputType === 'resourceLink') { + transientResources.set(uri, resource); + return { + content: [{ + type: "resource_link", + mimeType, + uri + }] }; } else { - const name = `out_${Date.now()}.zip`; - const uri = `resource://${name}`; - const resource: Resource = { uri, name, mimeType, blob }; - - if (outputType === "resource") { - return { - content: [ - { - type: "resource", - resource, - }, - ], - }; - } else if (outputType === "resourceLink") { - transientResources.set(uri, resource); - return { - content: [ - { - type: "resource_link", - mimeType, - uri, - }, - ], - }; - } else { - throw new Error(`Unknown outputType: ${outputType}`); - } + throw new Error(`Unknown outputType: ${outputType}`); } } From 1937bff41406656908f3888f265a78c026c3ec60 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 14 Oct 2025 17:57:56 +0100 Subject: [PATCH 5/9] limit uris to http(s): & data: --- mcp-server/src/services/mcp.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mcp-server/src/services/mcp.ts b/mcp-server/src/services/mcp.ts index 59b7c41..2f05e0b 100644 --- a/mcp-server/src/services/mcp.ts +++ b/mcp-server/src/services/mcp.ts @@ -653,8 +653,12 @@ export const createMcpServer = (): McpServerWrapper => { const { files, outputType } = ZipResourcesInputSchema.parse(args); const zip = new JSZip(); - for (const [fileName, fileUrl] of Object.entries(files)) { + for (const [fileName, fileUrlString] of Object.entries(files)) { try { + const fileUrl = new URL(fileUrlString); + if (fileUrl.protocol !== 'http:' && fileUrl.protocol !== 'https:' && fileUrl.protocol !== 'data:') { + throw new Error(`Unsupported URL protocol for ${fileUrlString}. Only http, https, and data URLs are supported.`); + } const response = await fetch(fileUrl); if (!response.ok) { throw new Error( From a948ced54a9792d9f5ee095602347ce22ed45c2c Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 14 Oct 2025 18:13:40 +0100 Subject: [PATCH 6/9] fix code --- mcp-server/src/services/mcp.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mcp-server/src/services/mcp.ts b/mcp-server/src/services/mcp.ts index 2f05e0b..ae95e37 100644 --- a/mcp-server/src/services/mcp.ts +++ b/mcp-server/src/services/mcp.ts @@ -653,23 +653,23 @@ export const createMcpServer = (): McpServerWrapper => { const { files, outputType } = ZipResourcesInputSchema.parse(args); const zip = new JSZip(); - for (const [fileName, fileUrlString] of Object.entries(files)) { + for (const [fileName, urlString] of Object.entries(files)) { try { - const fileUrl = new URL(fileUrlString); - if (fileUrl.protocol !== 'http:' && fileUrl.protocol !== 'https:' && fileUrl.protocol !== 'data:') { - throw new Error(`Unsupported URL protocol for ${fileUrlString}. Only http, https, and data URLs are supported.`); + const url = new URL(urlString); + if (url.protocol !== 'http:' && url.protocol !== 'https:' && url.protocol !== 'data:') { + throw new Error(`Unsupported URL protocol for ${urlString}. Only http, https, and data URLs are supported.`); } - const response = await fetch(fileUrl); + const response = await fetch(url); if (!response.ok) { throw new Error( - `Failed to fetch ${fileUrl}: ${response.statusText}` + `Failed to fetch ${url}: ${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)}` + `Error fetching file ${urlString}: ${error instanceof Error ? error.message : String(error)}` ); } } From 1910aa184c32f862c4db1eac608e5409d074e639 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 14 Oct 2025 20:30:08 +0100 Subject: [PATCH 7/9] zip: add fetch limits (size + time), simplify resource handling --- mcp-server/src/services/mcp.ts | 99 +++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 19 deletions(-) diff --git a/mcp-server/src/services/mcp.ts b/mcp-server/src/services/mcp.ts index ae95e37..eae5dec 100644 --- a/mcp-server/src/services/mcp.ts +++ b/mcp-server/src/services/mcp.ts @@ -130,7 +130,6 @@ export const createMcpServer = (): McpServerWrapper => { ); const subscriptions: Set = new Set(); - const transientResources: Map = new Map(); // Set up update interval for subscribed resources const subsUpdateInterval = setInterval(() => { @@ -274,12 +273,6 @@ export const createMcpServer = (): McpServerWrapper => { server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; - if (transientResources.has(uri)) { - return { - contents: [transientResources.get(uri)!], - }; - } - if (uri.startsWith("test://static/resource/")) { const index = parseInt(uri.split("/").pop() ?? "", 10) - 1; if (index >= 0 && index < ALL_RESOURCES.length) { @@ -650,23 +643,33 @@ export const createMcpServer = (): McpServerWrapper => { } if (name === ToolName.ZIP_RESOURCES) { + const MAX_ZIP_FETCH_SIZE = Number(process.env.MAX_ZIP_FETCH_SIZE ?? String(10 * 1024 * 1024)); // 10 MB default + const MAX_ZIP_FETCH_TIME_MILLIS = Number(process.env.MAX_ZIP_FETCH_TIME_MILLIS ?? String(30 * 1000)); // 30 seconds default. + const { files, outputType } = ZipResourcesInputSchema.parse(args); const zip = new JSZip(); + let remainingUploadBytes = MAX_ZIP_FETCH_SIZE; + const uploadEndTime = Date.now() + MAX_ZIP_FETCH_TIME_MILLIS; + for (const [fileName, urlString] of Object.entries(files)) { try { + if (remainingUploadBytes <= 0) { + throw new Error(`Max upload size of ${MAX_ZIP_FETCH_SIZE} bytes exceeded`); + } + const url = new URL(urlString); if (url.protocol !== 'http:' && url.protocol !== 'https:' && url.protocol !== 'data:') { throw new Error(`Unsupported URL protocol for ${urlString}. Only http, https, and data URLs are supported.`); } - const response = await fetch(url); - if (!response.ok) { - throw new Error( - `Failed to fetch ${url}: ${response.statusText}` - ); - } - const arrayBuffer = await response.arrayBuffer(); - zip.file(fileName, arrayBuffer); + + const response = await fetchSafely(url, { + maxBytes: remainingUploadBytes, + timeoutMillis: uploadEndTime - Date.now() + }); + remainingUploadBytes -= response.byteLength; + + zip.file(fileName, response); } catch (error) { throw new Error( `Error fetching file ${urlString}: ${error instanceof Error ? error.message : String(error)}` @@ -677,9 +680,9 @@ export const createMcpServer = (): McpServerWrapper => { const blob = await zip.generateAsync({ type: "base64" }); const mimeType = "application/zip"; const name = `out_${Date.now()}.zip`; - const uri = `resource://${name}`; - const resource: Resource = { uri, name, mimeType, blob }; - if (outputType === "resource") { + const uri = `test://static/resource/${ALL_RESOURCES.length + 1}`; + const resource = {uri, name, mimeType, blob}; + if (outputType === 'resource') { return { content: [{ type: "resource", @@ -687,7 +690,7 @@ export const createMcpServer = (): McpServerWrapper => { }] }; } else if (outputType === 'resourceLink') { - transientResources.set(uri, resource); + ALL_RESOURCES.push(resource); return { content: [{ type: "resource_link", @@ -758,5 +761,63 @@ export const createMcpServer = (): McpServerWrapper => { return { server, cleanup }; }; +/** + * Fetch a URL with limits on maximum bytes and timeout, to avoid getting overwhelmed by large or slow responses. + */ +async function fetchSafely(url: URL, {maxBytes, timeoutMillis}: {maxBytes: number, timeoutMillis: number}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(`Fetching ${url} took more than ${timeoutMillis} ms and was aborted.`), timeoutMillis); + + try { + 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}`); + } + } + + const reader = response.body.getReader(); + const chunks = []; + let totalSize = 0; + + 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(); + } + + 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); + } +} + const MCP_TINY_IMAGE = "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"; From 07709c9e649d0e581c3587ca940a5853eeb6c142 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 14 Oct 2025 21:00:31 +0100 Subject: [PATCH 8/9] basic SSRF protection / ZIP_ALLOWED_DOMAINS --- mcp-server/src/services/mcp.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/mcp-server/src/services/mcp.ts b/mcp-server/src/services/mcp.ts index eae5dec..9ff60b0 100644 --- a/mcp-server/src/services/mcp.ts +++ b/mcp-server/src/services/mcp.ts @@ -643,25 +643,36 @@ export const createMcpServer = (): McpServerWrapper => { } if (name === ToolName.ZIP_RESOURCES) { - const MAX_ZIP_FETCH_SIZE = Number(process.env.MAX_ZIP_FETCH_SIZE ?? String(10 * 1024 * 1024)); // 10 MB default - const MAX_ZIP_FETCH_TIME_MILLIS = Number(process.env.MAX_ZIP_FETCH_TIME_MILLIS ?? String(30 * 1000)); // 30 seconds default. + const ZIP_MAX_FETCH_SIZE = Number(process.env.ZIP_MAX_FETCH_SIZE ?? String(10 * 1024 * 1024)); // 10 MB default + const ZIP_MAX_FETCH_TIME_MILLIS = Number(process.env.ZIP_MAX_FETCH_TIME_MILLIS ?? String(30 * 1000)); // 30 seconds default. + // Comma-separated list of allowed domains. Empty means all domains are allowed. + const ZIP_ALLOWED_DOMAINS = (process.env.ZIP_ALLOWED_DOMAINS ?? "raw.githubusercontent.com").split(",").map(d => d.trim().toLowerCase()).filter(d => d.length > 0); const { files, outputType } = ZipResourcesInputSchema.parse(args); const zip = new JSZip(); - let remainingUploadBytes = MAX_ZIP_FETCH_SIZE; - const uploadEndTime = Date.now() + MAX_ZIP_FETCH_TIME_MILLIS; + let remainingUploadBytes = ZIP_MAX_FETCH_SIZE; + const uploadEndTime = Date.now() + ZIP_MAX_FETCH_TIME_MILLIS; for (const [fileName, urlString] of Object.entries(files)) { try { if (remainingUploadBytes <= 0) { - throw new Error(`Max upload size of ${MAX_ZIP_FETCH_SIZE} bytes exceeded`); + throw new Error(`Max upload size of ${ZIP_MAX_FETCH_SIZE} bytes exceeded`); } const url = new URL(urlString); if (url.protocol !== 'http:' && url.protocol !== 'https:' && url.protocol !== 'data:') { throw new Error(`Unsupported URL protocol for ${urlString}. Only http, https, and data URLs are supported.`); } + if (ZIP_ALLOWED_DOMAINS.length > 0 && (url.protocol === 'http:' || url.protocol === 'https:')) { + const domain = url.hostname; + const domainAllowed = ZIP_ALLOWED_DOMAINS.some(allowedDomain => { + return domain === allowedDomain || domain.endsWith(`.${allowedDomain}`); + }); + if (!domainAllowed) { + throw new Error(`Domain ${domain} is not in the allowed domains list.`); + } + } const response = await fetchSafely(url, { maxBytes: remainingUploadBytes, From 4e7364609968d9f3bdbe51c2c1330f11e4673774 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 14 Oct 2025 21:17:53 +0100 Subject: [PATCH 9/9] gzip w/ no deps --- mcp-server/package.json | 1 - mcp-server/src/services/mcp.ts | 133 +++++++++++++++------------------ 2 files changed, 61 insertions(+), 73 deletions(-) diff --git a/mcp-server/package.json b/mcp-server/package.json index 37036c1..c64e659 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -21,7 +21,6 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "express-rate-limit": "^8.0.1", - "jszip": "^3.10.1", "raw-body": "^3.0.0" }, "devDependencies": { diff --git a/mcp-server/src/services/mcp.ts b/mcp-server/src/services/mcp.ts index 9ff60b0..812062e 100644 --- a/mcp-server/src/services/mcp.ts +++ b/mcp-server/src/services/mcp.ts @@ -19,7 +19,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; -import JSZip from "jszip"; +import { gzipSync } from "node:zlib"; type ToolInput = { type: "object"; @@ -80,14 +80,13 @@ const GetResourceReferenceSchema = z.object({ .describe("ID of the resource to reference (1-100)"), }); -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"), +const GzipInputSchema = 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('resource').describe("How the resulting zip file should be returned. 'resourceLink' returns a linked to a resource that can be read later, 'resource' returns a full resource object."), + ]).default('resource').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."), }); enum ToolName { @@ -98,7 +97,7 @@ enum ToolName { GET_TINY_IMAGE = "getTinyImage", ANNOTATED_MESSAGE = "annotatedMessage", GET_RESOURCE_REFERENCE = "getResourceReference", - ZIP_RESOURCES = "zip", + GZIP = "gzip", } enum PromptName { @@ -459,10 +458,9 @@ export const createMcpServer = (): McpServerWrapper => { inputSchema: zodToJsonSchema(GetResourceReferenceSchema) as ToolInput, }, { - 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. Supports multiple output formats: inlined data URI (default), resource link, or full resource object", - inputSchema: zodToJsonSchema(ZipResourcesInputSchema) as ToolInput, + name: ToolName.GZIP, + description: "Compresses a single file using gzip compression. Takes a file name and data URI, returns the compressed data as a gzipped resource.", + inputSchema: zodToJsonSchema(GzipInputSchema) as ToolInput, }, ]; @@ -642,75 +640,66 @@ export const createMcpServer = (): McpServerWrapper => { return { content }; } - if (name === ToolName.ZIP_RESOURCES) { - const ZIP_MAX_FETCH_SIZE = Number(process.env.ZIP_MAX_FETCH_SIZE ?? String(10 * 1024 * 1024)); // 10 MB default - const ZIP_MAX_FETCH_TIME_MILLIS = Number(process.env.ZIP_MAX_FETCH_TIME_MILLIS ?? String(30 * 1000)); // 30 seconds default. + if (name === ToolName.GZIP) { + const GZIP_MAX_FETCH_SIZE = Number(process.env.GZIP_MAX_FETCH_SIZE ?? String(10 * 1024 * 1024)); // 10 MB default + const GZIP_MAX_FETCH_TIME_MILLIS = Number(process.env.GZIP_MAX_FETCH_TIME_MILLIS ?? String(30 * 1000)); // 30 seconds default. // Comma-separated list of allowed domains. Empty means all domains are allowed. - const ZIP_ALLOWED_DOMAINS = (process.env.ZIP_ALLOWED_DOMAINS ?? "raw.githubusercontent.com").split(",").map(d => d.trim().toLowerCase()).filter(d => d.length > 0); - - const { files, outputType } = ZipResourcesInputSchema.parse(args); - const zip = new JSZip(); + const GZIP_ALLOWED_DOMAINS = (process.env.GZIP_ALLOWED_DOMAINS ?? "raw.githubusercontent.com").split(",").map(d => d.trim().toLowerCase()).filter(d => d.length > 0); - let remainingUploadBytes = ZIP_MAX_FETCH_SIZE; - const uploadEndTime = Date.now() + ZIP_MAX_FETCH_TIME_MILLIS; + const { name, data: dataUri, outputType } = GzipInputSchema.parse(args); - for (const [fileName, urlString] of Object.entries(files)) { - try { - if (remainingUploadBytes <= 0) { - throw new Error(`Max upload size of ${ZIP_MAX_FETCH_SIZE} bytes exceeded`); + try { + const url = new URL(dataUri); + 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.`); } + } - const url = new URL(urlString); - if (url.protocol !== 'http:' && url.protocol !== 'https:' && url.protocol !== 'data:') { - throw new Error(`Unsupported URL protocol for ${urlString}. Only http, https, and data URLs are supported.`); - } - if (ZIP_ALLOWED_DOMAINS.length > 0 && (url.protocol === 'http:' || url.protocol === 'https:')) { - const domain = url.hostname; - const domainAllowed = ZIP_ALLOWED_DOMAINS.some(allowedDomain => { - return domain === allowedDomain || domain.endsWith(`.${allowedDomain}`); - }); - if (!domainAllowed) { - throw new Error(`Domain ${domain} is not in the allowed domains list.`); - } - } + const response = await fetchSafely(url, { + maxBytes: GZIP_MAX_FETCH_SIZE, + timeoutMillis: GZIP_MAX_FETCH_TIME_MILLIS + }); - const response = await fetchSafely(url, { - maxBytes: remainingUploadBytes, - timeoutMillis: uploadEndTime - Date.now() - }); - remainingUploadBytes -= response.byteLength; + // Compress the data using gzip + const inputBuffer = Buffer.from(response); + const compressedBuffer = gzipSync(inputBuffer); + const blob = compressedBuffer.toString("base64"); - zip.file(fileName, response); - } catch (error) { - throw new Error( - `Error fetching file ${urlString}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } + const mimeType = "application/gzip"; + const uri = `test://static/resource/${ALL_RESOURCES.length + 1}`; + const resource = {uri, name, mimeType, blob}; - const blob = await zip.generateAsync({ type: "base64" }); - const mimeType = "application/zip"; - const name = `out_${Date.now()}.zip`; - const uri = `test://static/resource/${ALL_RESOURCES.length + 1}`; - const resource = {uri, name, mimeType, blob}; - if (outputType === 'resource') { - return { - content: [{ - type: "resource", - resource - }] - }; - } else if (outputType === 'resourceLink') { - ALL_RESOURCES.push(resource); - return { - content: [{ - type: "resource_link", - mimeType, - uri - }] - }; - } else { - throw new Error(`Unknown outputType: ${outputType}`); + if (outputType === 'resource') { + return { + content: [{ + type: "resource", + resource + }] + }; + } else if (outputType === 'resourceLink') { + ALL_RESOURCES.push(resource); + return { + content: [{ + type: "resource_link", + mimeType, + uri + }] + }; + } else { + throw new Error(`Unknown outputType: ${outputType}`); + } + } catch (error) { + throw new Error( + `Error processing file ${dataUri}: ${error instanceof Error ? error.message : String(error)}` + ); } }