@modelcontextprotocol/server-everything
Version:
MCP server that exercises all the features of the MCP protocol
365 lines (364 loc) • 17.7 kB
JavaScript
// The `gzip-file-as-resource` tool: fetches a file, gzips it, and returns the
// result as an embedded resource or as a link to a resource the client can
// read later.
//
// Where that later resource lives depends on the protocol era (#4852). For a
// 2025-era client it is registered on the server instance serving the
// client's connection or session (resources/session.ts), and lasts as long as
// that does. The 2026-07-28 revision has no sessions, and on its HTTP path
// each request gets a fresh instance, so for a 2026-07-28 client the resource
// is stored under a URI with a server-minted id that every instance can serve
// (resources/minted.ts), and expires after ten minutes.
//
// The tool fetches whatever URL a model hands it, so it refuses non-public
// destinations by default (#5130), as the fetch server does: before every
// request, the caller's URL and each redirect hop alike, the host is checked
// against GZIP_ALLOWED_DOMAINS (a name filter, when set) and then resolved,
// and the request is refused if any address it resolves to is loopback,
// private, link-local (cloud metadata) or otherwise not public, unless
// GZIP_ALLOW_PRIVATE_IPS opts in. Redirects are followed by hand, so that each
// hop passes the same check, up to MAX_REDIRECTS of them.
import { z } from "zod";
import { lookup } from "node:dns/promises";
import { BlockList } from "node:net";
import { gzipSync } from "node:zlib";
import { mintBlobResource } from "../resources/minted.js";
import { getSessionResourceURI, registerSessionResource, } from "../resources/session.js";
import { RESOURCE_ICONS, TOOL_ICONS } from "../server/icons.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, matched by name on every request
// (redirect hops included). Empty means any name is allowed. It filters names,
// not addresses: a listed name that resolves to a private address is still
// refused unless GZIP_ALLOW_PRIVATE_IPS is set.
const GZIP_ALLOWED_DOMAINS = (process.env.GZIP_ALLOWED_DOMAINS ?? "")
.split(",")
.map((d) => d.trim().toLowerCase())
.filter((d) => d.length > 0);
// Opt in to fetching from loopback, private, link-local and other non-public
// addresses (1, true, yes or on). Off by default.
const GZIP_ALLOW_PRIVATE_IPS = /^(1|true|yes|on)$/i.test((process.env.GZIP_ALLOW_PRIVATE_IPS ?? "").trim());
// Redirects followed before a fetch is abandoned.
const MAX_REDIRECTS = 5;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
// Addresses that are not public destinations: the IANA special-purpose ranges
// the fetch server refuses (src/fetch's _NON_PUBLIC_NETWORKS), plus
// multicast. BlockList judges an IPv4-mapped IPv6 address (::ffff:a.b.c.d) by
// the IPv4 rules, so those need no ranges of their own. The well-known NAT64
// prefix (64:ff9b::/96) is not unwrapped and so passes as public.
const NON_PUBLIC_ADDRESSES = new BlockList();
for (const [network, prefix] of [
["0.0.0.0", 8], // "this network", including the unspecified address
["10.0.0.0", 8], // RFC 1918
["100.64.0.0", 10], // CGNAT shared space (Alibaba Cloud metadata)
["127.0.0.0", 8], // loopback
["169.254.0.0", 16], // link-local (most cloud metadata endpoints)
["172.16.0.0", 12], // RFC 1918
["192.0.0.0", 24], // IETF protocol assignments
["192.0.2.0", 24], // documentation
["192.88.99.0", 24], // 6to4 relay anycast
["192.168.0.0", 16], // RFC 1918
["198.18.0.0", 15], // benchmarking
["198.51.100.0", 24], // documentation
["203.0.113.0", 24], // documentation
["224.0.0.0", 4], // multicast
["240.0.0.0", 4], // reserved, and broadcast
]) {
NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, "ipv4");
}
for (const [network, prefix] of [
["::", 128], // unspecified
["::1", 128], // loopback
["64:ff9b:1::", 48], // local-use NAT64
["100::", 64], // discard-only
["2001::", 23], // IETF protocol assignments (Teredo among them)
["2001:db8::", 32], // documentation
["2002::", 16], // 6to4
["fc00::", 7], // unique local (AWS's fd00:ec2::254)
["fe80::", 10], // link-local
["ff00::", 8], // multicast
]) {
NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, "ipv6");
}
// Globally reachable assignments inside those ranges (anycast services and
// the like), allowed as the fetch server allows its _PUBLIC_EXCEPTIONS.
const PUBLIC_EXCEPTIONS = new BlockList();
PUBLIC_EXCEPTIONS.addAddress("192.0.0.9", "ipv4");
PUBLIC_EXCEPTIONS.addAddress("192.0.0.10", "ipv4");
for (const [network, prefix] of [
["2001:1::1", 128],
["2001:1::2", 128],
["2001:3::", 32],
["2001:4:112::", 48],
["2001:20::", 28],
["2001:30::", 28],
]) {
PUBLIC_EXCEPTIONS.addSubnet(network, prefix, "ipv6");
}
/** True when `address` is not a public destination. */
function isNonPublic(address, family) {
const type = family === 6 ? "ipv6" : "ipv4";
return (!PUBLIC_EXCEPTIONS.check(address, type) &&
NON_PUBLIC_ADDRESSES.check(address, type));
}
/**
* Settles as `promise` does, or rejects with the signal's reason as soon as it
* aborts, so that the fetch deadline also bounds work that takes no signal
* (the DNS lookup).
*/
async function raceAbort(promise, signal) {
signal.throwIfAborted();
let onAbort = () => { };
const aborted = new Promise((_, reject) => {
onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([promise, aborted]);
}
finally {
signal.removeEventListener("abort", onAbort);
}
}
// Tool input schema
const GZipFileAsResourceSchema = z.object({
name: z
.string()
.max(255)
.describe("Name of the output file (at most 255 characters)")
.default("README.md.gz"),
data: z
.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 for a 2025-era client, or for ten minutes at a minted URI on protocol version 2026-07-28.",
inputSchema: GZipFileAsResourceSchema,
icons: TOOL_ICONS,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
};
/**
* 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.
* @param {ServerContext} context - The protocol era the instance serves.
* @throws {Error} Throws an error if an unknown output type is specified.
*/
export const registerGZipFileAsResourceTool = (server, context) => {
server.registerTool(name, config, async (args) => {
const { name, data: dataUri, outputType, } = GZipFileAsResourceSchema.parse(args);
// Fetch the data; each request it makes is checked first
const response = await fetchSafely(new URL(dataUri), {
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";
// #5434: the session resource is listed like any other, so it carries
// the resource icons (#3131) in resources/list and in its link.
const resource = { uri, name, mimeType, icons: RESOURCE_ICONS };
// 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") {
// Make the resource readable later, where this era can reach it
const resourceLink = context.era === "modern"
? mintBlobResource(name, mimeType, blob)
: registerSessionResource(server, resource, "blob", blob);
return {
content: [resourceLink],
};
}
else {
/* v8 ignore next -- unreachable: the input schema's enum admits only resource and resourceLink */
throw new Error(`Unknown outputType: ${outputType}`);
}
});
};
/**
* Refuses a request the server should not make: a protocol other than http,
* https or data; a host not in GZIP_ALLOWED_DOMAINS, when that is set; or,
* unless GZIP_ALLOW_PRIVATE_IPS is set, a host that resolves to any address
* that is not public. Called before every request, redirect hops included.
*
* This is a check, then a fetch: `fetch` resolves the name again to connect,
* so a name whose DNS answer changes between the two (DNS rebinding) can
* still reach a private address. Closing that window means pinning the
* checked address for the connection, which this demo server does not do.
*
* @param {URL} url The URL about to be requested.
* @param {AbortSignal} signal The fetch's deadline, which bounds the lookup too.
* @throws {Error} If the request is refused, or the host cannot be resolved.
*/
async function assertPermittedTarget(url, signal) {
const refuse = (reason) => new Error(`Error processing file ${url}: ${reason}`);
if (url.protocol === "data:")
return; // carries its own bytes
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw refuse(`Unsupported URL protocol for ${url}. Only http, https, and data URLs are supported.`);
}
// Names are matched as the URL spells them, an IPv6 literal with its
// brackets ([::1]), as they always have been
const domain = url.hostname;
if (GZIP_ALLOWED_DOMAINS.length > 0 &&
!GZIP_ALLOWED_DOMAINS.some((d) => domain === d || domain.endsWith(`.${d}`))) {
throw refuse(`Domain ${domain} is not in the allowed domains list.`);
}
if (GZIP_ALLOW_PRIVATE_IPS)
return;
// The resolver takes an IPv6 literal without its brackets
const host = domain.replace(/^\[(.*)\]$/, "$1");
const addresses = await raceAbort(lookup(host, { all: true }), signal);
for (const { address, family } of addresses) {
if (isNonPublic(address, family)) {
throw refuse(`${host} resolves to ${address}, which is not a public address. Set GZIP_ALLOW_PRIVATE_IPS=1 to allow private, loopback and link-local addresses.`);
}
}
}
/**
* Requests `url`, following redirects by hand so that every hop passes
* `assertPermittedTarget` first, and returns the first response that is not a
* redirect.
*
* @param {URL} url The URL the caller asked for.
* @param {AbortSignal} signal Aborts the request in flight.
* @return {Promise<Response>} The final response.
* @throws {Error} If a hop is refused, or there are more than MAX_REDIRECTS.
*/
async function fetchPermitted(url, signal) {
let target = url;
for (let redirects = 0;; redirects++) {
await assertPermittedTarget(target, signal);
const response = await fetch(target, { signal, redirect: "manual" });
const location = response.headers.get("location");
if (!REDIRECT_STATUSES.has(response.status) || location === null) {
return response;
}
// Discard the redirect's own body before moving on. Not awaited, as in
// fetchSafely: cancel() may never settle, and only the next hop matters.
response.body?.cancel().catch(
/* v8 ignore next -- only runs if cancelling the source fails, which nothing here can provoke */
() => { });
if (redirects === MAX_REDIRECTS) {
throw new Error(`Fetching ${url} followed more than ${MAX_REDIRECTS} redirects and was abandoned.`);
}
target = new URL(location, target);
}
}
/**
* 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<ArrayBuffer>} 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, { maxBytes, timeoutMillis }) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(`Fetching ${url} took more than ${timeoutMillis} ms and was aborted.`), timeoutMillis);
try {
// Fetch the data, following only redirects that pass the same checks.
// The response is the final, non-redirect one, so a redirect is never
// mistaken for an error status here.
const response = await fetchPermitted(url, controller.signal);
if (!response.ok) {
// An error page is not the requested file (#5207). Release the
// connection without reading the body; as with the size check below,
// the cancel is not awaited and its rejection is swallowed, since the
// thrown status error is the failure this path reports.
response.body?.cancel().catch(
/* v8 ignore next -- only runs if cancelling the source fails, which nothing here can provoke */
() => { });
throw new Error(`Fetching ${url} failed with HTTP ${response.status} ${response.statusText}`);
}
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) {
// Not awaited: cancel() adopts the source's promise and may never
// settle, and we are about to throw anyway. Swallow its rejection so
// it cannot surface as an unhandled one; the thrown size error is
// the failure this path reports.
reader.cancel().catch(
/* v8 ignore next -- only runs if cancelling the source fails, which nothing here can provoke */
() => { });
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);
}
}