Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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.

- **HTTP mode** (default): Client roots are **enabled** — the client is typically on the same machine, so the roots are safe.
- **Stdio mode** (`--stdio`): 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 stdio client is local and its roots are safe
bun examples/pdf-server/main.ts --stdio --use-client-roots
```

When roots are ignored the server logs:

```
[pdf-server] Client roots are ignored (default for security). 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
30 changes: 25 additions & 5 deletions examples/pdf-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
allowedLocalFiles,
DEFAULT_PDF,
allowedLocalDirs,
type CreateServerOptions,
} from "./server.js";

/**
Expand Down Expand Up @@ -89,14 +90,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 +121,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 @@ -140,10 +152,18 @@ async function main() {

console.error(`[pdf-server] Ready (${urls.length} URL(s) configured)`);

// For stdio the client is typically remote — roots would expose the
// server's filesystem. Only honour them with an explicit opt-in.
// For HTTP the client is local, so roots are safe by default.
const effectiveUseClientRoots = useClientRoots || !stdio;
const serverOpts: CreateServerOptions = {
useClientRoots: effectiveUseClientRoots,
};

if (stdio) {
await startStdioServer(createServer);
await startStdioServer(() => createServer(serverOpts));
} else {
await startStreamableHTTPServer(createServer);
await startStreamableHTTPServer(() => createServer(serverOpts));
}
}

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();
});
});
49 changes: 38 additions & 11 deletions examples/pdf-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,19 +443,46 @@ 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 started with `--stdio`, the connecting client 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.
*
* Set this to `true` only when you trust the client (e.g. the client
* is running on the same host) or the server was started with the
* explicit `--use-client-roots` 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 security). " +
"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