Skip to content

Commit 96c0af5

Browse files
authored
feat(pdf-server): use MCP roots to auto-allow local directories (#479)
When the client advertises roots capability, the server queries roots/list on initialization and subscribes to roots/list_changed notifications. Any file:// roots pointing to existing directories are added to an allowedLocalDirs set, enabling access to all PDFs under those directories without needing to enumerate them via CLI args. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> fix: revert unrelated changes from bad rebase Restores files that were accidentally modified alongside the MCP roots feature: README, pdf-server UI (search feature), threejs example, SDK guards (app.ts, app-bridge.ts), typedoc theme, media assets, and e2e snapshots.
1 parent 37e1270 commit 96c0af5

2 files changed

Lines changed: 150 additions & 10 deletions

File tree

examples/pdf-server/server.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
2+
import path from "node:path";
23
import {
34
createPdfCache,
5+
validateUrl,
6+
allowedLocalFiles,
7+
allowedLocalDirs,
8+
pathToFileUrl,
49
CACHE_INACTIVITY_TIMEOUT_MS,
510
CACHE_MAX_LIFETIME_MS,
611
CACHE_MAX_PDF_SIZE_BYTES,
@@ -178,3 +183,65 @@ describe("PDF Cache with Timeouts", () => {
178183
// The timeout behavior is straightforward and can be verified
179184
// through manual testing or E2E tests.
180185
});
186+
187+
describe("validateUrl with MCP roots (allowedLocalDirs)", () => {
188+
const savedFiles = new Set(allowedLocalFiles);
189+
const savedDirs = new Set(allowedLocalDirs);
190+
191+
beforeEach(() => {
192+
allowedLocalFiles.clear();
193+
allowedLocalDirs.clear();
194+
});
195+
196+
afterEach(() => {
197+
allowedLocalFiles.clear();
198+
allowedLocalDirs.clear();
199+
for (const f of savedFiles) allowedLocalFiles.add(f);
200+
for (const d of savedDirs) allowedLocalDirs.add(d);
201+
});
202+
203+
it("should allow a file under an allowed directory", () => {
204+
// Use a real existing directory+file for the existsSync check
205+
const dir = path.resolve(import.meta.dirname);
206+
allowedLocalDirs.add(dir);
207+
208+
const filePath = path.join(dir, "server.ts");
209+
const result = validateUrl(pathToFileUrl(filePath));
210+
expect(result.valid).toBe(true);
211+
});
212+
213+
it("should reject a file outside allowed directories", () => {
214+
allowedLocalDirs.add("/some/allowed/dir");
215+
216+
const result = validateUrl("file:///other/dir/test.pdf");
217+
expect(result.valid).toBe(false);
218+
expect(result.error).toContain("not in allowed list");
219+
});
220+
221+
it("should prevent prefix-based directory traversal", () => {
222+
// /tmp/safe should NOT allow /tmp/safevil/file.pdf
223+
allowedLocalDirs.add("/tmp/safe");
224+
225+
const result = validateUrl("file:///tmp/safevil/file.pdf");
226+
expect(result.valid).toBe(false);
227+
});
228+
229+
it("should still allow exact file matches from allowedLocalFiles", () => {
230+
const filePath = path.resolve(import.meta.dirname, "server.ts");
231+
allowedLocalFiles.add(filePath);
232+
233+
const result = validateUrl(pathToFileUrl(filePath));
234+
expect(result.valid).toBe(true);
235+
});
236+
237+
it("should reject non-existent file even if under allowed dir", () => {
238+
const dir = path.resolve(import.meta.dirname);
239+
allowedLocalDirs.add(dir);
240+
241+
const result = validateUrl(
242+
pathToFileUrl(path.join(dir, "nonexistent-file.pdf")),
243+
);
244+
expect(result.valid).toBe(false);
245+
expect(result.error).toContain("File not found");
246+
});
247+
});

examples/pdf-server/server.ts

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@ import { randomUUID } from "crypto";
1414
import fs from "node:fs";
1515
import path from "node:path";
1616
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17+
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
1718
import {
1819
registerAppResource,
1920
registerAppTool,
2021
RESOURCE_MIME_TYPE,
2122
} from "@modelcontextprotocol/ext-apps/server";
22-
import type {
23-
CallToolResult,
24-
ReadResourceResult,
23+
import {
24+
RootsListChangedNotificationSchema,
25+
type CallToolResult,
26+
type ReadResourceResult,
2527
} from "@modelcontextprotocol/sdk/types.js";
2628
import { z } from "zod";
2729

@@ -65,6 +67,9 @@ export const allowedRemoteOrigins = new Set([
6567
/** Allowed local file paths (populated from CLI args) */
6668
export const allowedLocalFiles = new Set<string>();
6769

70+
/** Allowed local directories (populated from MCP roots) */
71+
export const allowedLocalDirs = new Set<string>();
72+
6873
// Works both from source (server.ts) and compiled (dist/server.js)
6974
const DIST_DIR = import.meta.filename.endsWith(".ts")
7075
? path.join(import.meta.dirname, "dist")
@@ -107,7 +112,17 @@ export function pathToFileUrl(filePath: string): string {
107112
export function validateUrl(url: string): { valid: boolean; error?: string } {
108113
if (isFileUrl(url)) {
109114
const filePath = fileUrlToPath(url);
110-
if (!allowedLocalFiles.has(filePath)) {
115+
const resolved = path.resolve(filePath);
116+
117+
// Check exact match (CLI args)
118+
const exactMatch = allowedLocalFiles.has(filePath);
119+
120+
// Check directory match (MCP roots)
121+
const dirMatch = [...allowedLocalDirs].some(
122+
(dir) => resolved === dir || resolved.startsWith(dir + path.sep),
123+
);
124+
125+
if (!exactMatch && !dirMatch) {
111126
return {
112127
valid: false,
113128
error: `Local file not in allowed list: ${filePath}`,
@@ -342,13 +357,59 @@ export function createPdfCache(): PdfCache {
342357
};
343358
}
344359

360+
// =============================================================================
361+
// MCP Roots
362+
// =============================================================================
363+
364+
/**
365+
* Query the client for roots and update allowedLocalDirs with any file:// roots
366+
* that point to existing directories.
367+
*/
368+
async function refreshRoots(server: Server): Promise<void> {
369+
if (!server.getClientCapabilities()?.roots) return;
370+
371+
try {
372+
const { roots } = await server.listRoots();
373+
allowedLocalDirs.clear();
374+
for (const root of roots) {
375+
if (root.uri.startsWith("file://")) {
376+
const dir = fileUrlToPath(root.uri);
377+
const resolved = path.resolve(dir);
378+
try {
379+
if (fs.statSync(resolved).isDirectory()) {
380+
allowedLocalDirs.add(resolved);
381+
console.error(`[pdf-server] Root directory allowed: ${resolved}`);
382+
}
383+
} catch {
384+
// stat failed — skip non-existent roots
385+
}
386+
}
387+
}
388+
} catch (err) {
389+
console.error(
390+
`[pdf-server] Failed to list roots: ${err instanceof Error ? err.message : err}`,
391+
);
392+
}
393+
}
394+
345395
// =============================================================================
346396
// MCP Server Factory
347397
// =============================================================================
348398

349399
export function createServer(): McpServer {
350400
const server = new McpServer({ name: "PDF Server", version: "2.0.0" });
351401

402+
// Fetch roots on initialization and subscribe to changes
403+
server.server.oninitialized = () => {
404+
refreshRoots(server.server);
405+
};
406+
server.server.setNotificationHandler(
407+
RootsListChangedNotificationSchema,
408+
async () => {
409+
await refreshRoots(server.server);
410+
},
411+
);
412+
352413
// Create session-local cache (isolated per server instance)
353414
const { readPdfRange } = createPdfCache();
354415

@@ -365,16 +426,27 @@ export function createServer(): McpServer {
365426
pdfs.push({ url: pathToFileUrl(filePath), type: "local" });
366427
}
367428

368-
// Note: Remote URLs from allowed origins can be loaded dynamically
369-
const text =
370-
pdfs.length > 0
371-
? `Available PDFs:\n${pdfs.map((p) => `- ${p.url} (${p.type})`).join("\n")}\n\nRemote PDFs from ${[...allowedRemoteOrigins].join(", ")} can also be loaded dynamically.`
372-
: `No local PDFs configured. Remote PDFs from ${[...allowedRemoteOrigins].join(", ")} can be loaded dynamically.`;
429+
// Build text
430+
const parts: string[] = [];
431+
if (pdfs.length > 0) {
432+
parts.push(
433+
`Available PDFs:\n${pdfs.map((p) => `- ${p.url} (${p.type})`).join("\n")}`,
434+
);
435+
}
436+
if (allowedLocalDirs.size > 0) {
437+
parts.push(
438+
`Allowed local directories (from client roots):\n${[...allowedLocalDirs].map((d) => `- ${d}`).join("\n")}\nAny PDF file under these directories can be displayed.`,
439+
);
440+
}
441+
parts.push(
442+
`Remote PDFs from ${[...allowedRemoteOrigins].join(", ")} can also be loaded dynamically.`,
443+
);
373444

374445
return {
375-
content: [{ type: "text", text }],
446+
content: [{ type: "text", text: parts.join("\n\n") }],
376447
structuredContent: {
377448
localFiles: pdfs.filter((p) => p.type === "local").map((p) => p.url),
449+
allowedDirectories: [...allowedLocalDirs],
378450
allowedOrigins: [...allowedRemoteOrigins],
379451
},
380452
};
@@ -470,6 +542,7 @@ export function createServer(): McpServer {
470542
471543
Accepts:
472544
- Local files explicitly added to the server (use list_pdfs to see available files)
545+
- Local files under directories provided by the client as MCP roots
473546
- Remote PDFs from: ${allowedDomains}`,
474547
inputSchema: {
475548
url: z.string().default(DEFAULT_PDF).describe("PDF URL"),

0 commit comments

Comments
 (0)