diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md index b73622cf..5b518d5f 100644 --- a/examples/pdf-server/README.md +++ b/examples/pdf-server/README.md @@ -149,9 +149,27 @@ bun examples/pdf-server/main.ts ./local.pdf https://arxiv.org/pdf/2401.00001.pdf bun examples/pdf-server/main.ts --stdio ./papers/ ``` +## Security: Client Roots + +MCP clients may advertise **roots** — `file://` URIs pointing to directories on the client's file system. The server uses these to allow access to local files under those directories. + +- **Stdio mode** (`--stdio`): Client roots are **always enabled** — the client is typically on the same machine (e.g. Claude Desktop), so the roots are safe. +- **HTTP mode** (default): Client roots are **ignored** by default — the client may be remote, and its roots would be resolved against the server's filesystem. To opt in, pass `--use-client-roots`: + +```bash +# Trust that the HTTP client is local and its roots are safe +bun examples/pdf-server/main.ts --use-client-roots +``` + +When roots are ignored the server logs: + +``` +[pdf-server] Client roots are ignored (default for remote transports). Pass --use-client-roots to allow the client to expose local directories. +``` + ## Allowed Sources -- **Local files**: Must be passed as CLI arguments +- **Local files**: Must be passed as CLI arguments (or via client roots when enabled) - **Remote URLs**: arxiv.org, biorxiv.org, medrxiv.org, chemrxiv.org, zenodo.org, osf.io, hal.science, ssrn.com, and more ## Tools diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts index ddf26c2b..2ff4979f 100644 --- a/examples/pdf-server/main.ts +++ b/examples/pdf-server/main.ts @@ -89,14 +89,21 @@ export async function startStdioServer( await createServer().connect(new StdioServerTransport()); } -function parseArgs(): { urls: string[]; stdio: boolean } { +function parseArgs(): { + urls: string[]; + stdio: boolean; + useClientRoots: boolean; +} { const args = process.argv.slice(2); const urls: string[] = []; let stdio = false; + let useClientRoots = false; for (const arg of args) { if (arg === "--stdio") { stdio = true; + } else if (arg === "--use-client-roots") { + useClientRoots = true; } else if (!arg.startsWith("-")) { // Convert local paths to file:// URLs, normalize arxiv URLs let url = arg; @@ -113,11 +120,15 @@ function parseArgs(): { urls: string[]; stdio: boolean } { } } - return { urls: urls.length > 0 ? urls : [DEFAULT_PDF], stdio }; + return { + urls: urls.length > 0 ? urls : [DEFAULT_PDF], + stdio, + useClientRoots, + }; } async function main() { - const { urls, stdio } = parseArgs(); + const { urls, stdio, useClientRoots } = parseArgs(); // Register local files in whitelist for (const url of urls) { @@ -141,9 +152,11 @@ async function main() { console.error(`[pdf-server] Ready (${urls.length} URL(s) configured)`); if (stdio) { - await startStdioServer(createServer); + // stdio → client is local (e.g. Claude Desktop), roots are safe + await startStdioServer(() => createServer({ useClientRoots: true })); } else { - await startStreamableHTTPServer(createServer); + // HTTP → client is remote, only honour roots with explicit opt-in + await startStreamableHTTPServer(() => createServer({ useClientRoots })); } } diff --git a/examples/pdf-server/server.test.ts b/examples/pdf-server/server.test.ts index dd5ac447..4ea1ecf4 100644 --- a/examples/pdf-server/server.test.ts +++ b/examples/pdf-server/server.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test"; import path from "node:path"; import { createPdfCache, + createServer, validateUrl, isAncestorDir, allowedLocalFiles, @@ -421,3 +422,27 @@ describe("isAncestorDir", () => { ); }); }); + +describe("createServer useClientRoots option", () => { + it("should not set up roots handlers by default", () => { + const server = createServer(); + // When useClientRoots is false (default), oninitialized should NOT + // be overridden by our roots logic. + expect(server.server.oninitialized).toBeUndefined(); + server.close(); + }); + + it("should not set up roots handlers when useClientRoots is false", () => { + const server = createServer({ useClientRoots: false }); + expect(server.server.oninitialized).toBeUndefined(); + server.close(); + }); + + it("should set up roots handlers when useClientRoots is true", () => { + const server = createServer({ useClientRoots: true }); + // When useClientRoots is true, oninitialized should be set to + // the roots refresh handler. + expect(server.server.oninitialized).toBeFunction(); + server.close(); + }); +}); diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index ccc400f0..0f835b76 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -443,19 +443,49 @@ async function refreshRoots(server: Server): Promise { // MCP Server Factory // ============================================================================= -export function createServer(): McpServer { +export interface CreateServerOptions { + /** + * Whether to honour MCP roots sent by the client. + * + * When a server is exposed over HTTP, the connecting client is + * typically remote and may advertise `roots` that refer to + * directories on the **client's** file system. Because the server + * resolves those paths locally, accepting them by default would give + * the remote client access to arbitrary directories on the + * **server's** machine. + * + * For stdio the client is typically local (e.g. Claude Desktop on the + * same machine), so roots are safe and enabled by default. + * + * Set this to `true` for HTTP only when you trust the client, or + * pass the `--use-client-roots` CLI flag. + * + * @default false + */ + useClientRoots?: boolean; +} + +export function createServer(options: CreateServerOptions = {}): McpServer { + const { useClientRoots = false } = options; const server = new McpServer({ name: "PDF Server", version: "2.0.0" }); - // Fetch roots on initialization and subscribe to changes - server.server.oninitialized = () => { - refreshRoots(server.server); - }; - server.server.setNotificationHandler( - RootsListChangedNotificationSchema, - async () => { - await refreshRoots(server.server); - }, - ); + if (useClientRoots) { + // Fetch roots on initialization and subscribe to changes + server.server.oninitialized = () => { + refreshRoots(server.server); + }; + server.server.setNotificationHandler( + RootsListChangedNotificationSchema, + async () => { + await refreshRoots(server.server); + }, + ); + } else { + console.error( + "[pdf-server] Client roots are ignored (default for remote transports). " + + "Pass --use-client-roots to allow the client to expose local directories.", + ); + } // Create session-local cache (isolated per server instance) const { readPdfRange } = createPdfCache();