Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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


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
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.

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