Skip to content
1 change: 1 addition & 0 deletions mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
72 changes: 72 additions & 0 deletions mcp-server/src/services/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can do this with node:zlib (https://nodejs.org/api/zlib.html) so you don't need to add another dependency

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, switched from zip + multiple files to gzip of single file, no deps


type ToolInput = {
type: "object";
Expand Down Expand Up @@ -79,6 +80,16 @@ 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"),
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 {
ECHO = "echo",
ADD = "add",
Expand All @@ -87,6 +98,7 @@ enum ToolName {
GET_TINY_IMAGE = "getTinyImage",
ANNOTATED_MESSAGE = "annotatedMessage",
GET_RESOURCE_REFERENCE = "getResourceReference",
ZIP_RESOURCES = "zip",
}

enum PromptName {
Expand Down Expand Up @@ -118,6 +130,7 @@ export const createMcpServer = (): McpServerWrapper => {
);

const subscriptions: Set<string> = new Set();
const transientResources: Map<string, Resource> = new Map();

// Set up update interval for subscribed resources
const subsUpdateInterval = setInterval(() => {
Expand Down Expand Up @@ -261,6 +274,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) {
Expand Down Expand Up @@ -446,6 +465,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. Supports multiple output formats: inlined data URI (default), resource link, or full resource object",
inputSchema: zodToJsonSchema(ZipResourcesInputSchema) as ToolInput,
},
];

return { tools };
Expand Down Expand Up @@ -624,6 +649,53 @@ export const createMcpServer = (): McpServerWrapper => {
return { content };
}

if (name === ToolName.ZIP_RESOURCES) {
Copy link
Member

@domdomegg domdomegg Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for later: at some point we should migrate this whole server to the newer nicer API

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i.e. server.registerTool, rather than server.setRequestHandler(CallToolRequestSchema, ...)

const { files, outputType } = ZipResourcesInputSchema.parse(args);
const zip = new JSZip();

for (const [fileName, fileUrl] of Object.entries(files)) {
try {
const response = await fetch(fileUrl);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we might want to screen this to avoid SSRF attacks.

Copy link
Contributor Author

@ochafik ochafik Oct 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point, thanks! Added ZIP_ALLOWED_DOMAINS that defaults to just raw.githubusercontent.com

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to be careful handling the result / set some limit to avoid DoS attacks. E.g. getting us to download a 2GB file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added limits on total fetch size + time

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 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") {
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}`);
});

Expand Down
Loading