Skip to content
Merged
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
20 changes: 19 additions & 1 deletion examples/pdf-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 18 additions & 5 deletions examples/pdf-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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 }));
}
}

Expand Down
25 changes: 25 additions & 0 deletions examples/pdf-server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
});
});
52 changes: 41 additions & 11 deletions examples/pdf-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,19 +443,49 @@ async function refreshRoots(server: Server): Promise<void> {
// 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();
Expand Down
Loading