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.
This commit is contained in:
Olivier Chafik 2026-02-20 17:37:47 -08:00 • committed by GitHub
commit 96c0af577d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 150 additions and 10 deletions

View file

@ -1,6 +1,11 @@
import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
import path from "node:path";
import {
createPdfCache,
validateUrl,
allowedLocalFiles,
allowedLocalDirs,
pathToFileUrl,
CACHE_INACTIVITY_TIMEOUT_MS,
CACHE_MAX_LIFETIME_MS,
CACHE_MAX_PDF_SIZE_BYTES,
@ -178,3 +183,65 @@ describe("PDF Cache with Timeouts", () => {
// The timeout behavior is straightforward and can be verified
// through manual testing or E2E tests.
});
describe("validateUrl with MCP roots (allowedLocalDirs)", () => {
const savedFiles = new Set(allowedLocalFiles);
const savedDirs = new Set(allowedLocalDirs);
beforeEach(() => {
allowedLocalFiles.clear();
allowedLocalDirs.clear();
});
afterEach(() => {
allowedLocalFiles.clear();
allowedLocalDirs.clear();
for (const f of savedFiles) allowedLocalFiles.add(f);
for (const d of savedDirs) allowedLocalDirs.add(d);
});
it("should allow a file under an allowed directory", () => {
// Use a real existing directory+file for the existsSync check
const dir = path.resolve(import.meta.dirname);
allowedLocalDirs.add(dir);
const filePath = path.join(dir, "server.ts");
const result = validateUrl(pathToFileUrl(filePath));
expect(result.valid).toBe(true);
});
it("should reject a file outside allowed directories", () => {
allowedLocalDirs.add("/some/allowed/dir");
const result = validateUrl("file:///other/dir/test.pdf");
expect(result.valid).toBe(false);
expect(result.error).toContain("not in allowed list");
});
it("should prevent prefix-based directory traversal", () => {
// /tmp/safe should NOT allow /tmp/safevil/file.pdf
allowedLocalDirs.add("/tmp/safe");
const result = validateUrl("file:///tmp/safevil/file.pdf");
expect(result.valid).toBe(false);
});
it("should still allow exact file matches from allowedLocalFiles", () => {
const filePath = path.resolve(import.meta.dirname, "server.ts");
allowedLocalFiles.add(filePath);
const result = validateUrl(pathToFileUrl(filePath));
expect(result.valid).toBe(true);
});
it("should reject non-existent file even if under allowed dir", () => {
const dir = path.resolve(import.meta.dirname);
allowedLocalDirs.add(dir);
const result = validateUrl(
pathToFileUrl(path.join(dir, "nonexistent-file.pdf")),
);
expect(result.valid).toBe(false);
expect(result.error).toContain("File not found");
});
});

View file

@ -14,14 +14,16 @@ import { randomUUID } from "crypto";
import fs from "node:fs";
import path from "node:path";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import type {
CallToolResult,
ReadResourceResult,
import {
RootsListChangedNotificationSchema,
type CallToolResult,
type ReadResourceResult,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
@ -65,6 +67,9 @@ export const allowedRemoteOrigins = new Set([
/** Allowed local file paths (populated from CLI args) */
export const allowedLocalFiles = new Set<string>();
/** Allowed local directories (populated from MCP roots) */
export const allowedLocalDirs = new Set<string>();
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
@ -107,7 +112,17 @@ export function pathToFileUrl(filePath: string): string {
export function validateUrl(url: string): { valid: boolean; error?: string } {
if (isFileUrl(url)) {
const filePath = fileUrlToPath(url);
if (!allowedLocalFiles.has(filePath)) {
const resolved = path.resolve(filePath);
// Check exact match (CLI args)
const exactMatch = allowedLocalFiles.has(filePath);
// Check directory match (MCP roots)
const dirMatch = [...allowedLocalDirs].some(
(dir) => resolved === dir || resolved.startsWith(dir + path.sep),
);
if (!exactMatch && !dirMatch) {
return {
valid: false,
error: `Local file not in allowed list: ${filePath}`,
@ -342,6 +357,41 @@ export function createPdfCache(): PdfCache {
};
}
// =============================================================================
// MCP Roots
// =============================================================================
/**
* Query the client for roots and update allowedLocalDirs with any file:// roots
* that point to existing directories.
*/
async function refreshRoots(server: Server): Promise<void> {
if (!server.getClientCapabilities()?.roots) return;
try {
const { roots } = await server.listRoots();
allowedLocalDirs.clear();
for (const root of roots) {
if (root.uri.startsWith("file://")) {
const dir = fileUrlToPath(root.uri);
const resolved = path.resolve(dir);
try {
if (fs.statSync(resolved).isDirectory()) {
allowedLocalDirs.add(resolved);
console.error(`[pdf-server] Root directory allowed: ${resolved}`);
}
} catch {
// stat failed — skip non-existent roots
}
}
}
} catch (err) {
console.error(
`[pdf-server] Failed to list roots: ${err instanceof Error ? err.message : err}`,
);
}
}
// =============================================================================
// MCP Server Factory
// =============================================================================
@ -349,6 +399,17 @@ export function createPdfCache(): PdfCache {
export function createServer(): McpServer {
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);
},
);
// Create session-local cache (isolated per server instance)
const { readPdfRange } = createPdfCache();
@ -365,16 +426,27 @@ export function createServer(): McpServer {
pdfs.push({ url: pathToFileUrl(filePath), type: "local" });
}
// Note: Remote URLs from allowed origins can be loaded dynamically
const text =
pdfs.length > 0
? `Available PDFs:\n${pdfs.map((p) => `- ${p.url} (${p.type})`).join("\n")}\n\nRemote PDFs from ${[...allowedRemoteOrigins].join(", ")} can also be loaded dynamically.`
: `No local PDFs configured. Remote PDFs from ${[...allowedRemoteOrigins].join(", ")} can be loaded dynamically.`;
// Build text
const parts: string[] = [];
if (pdfs.length > 0) {
parts.push(
`Available PDFs:\n${pdfs.map((p) => `- ${p.url} (${p.type})`).join("\n")}`,
);
}
if (allowedLocalDirs.size > 0) {
parts.push(
`Allowed local directories (from client roots):\n${[...allowedLocalDirs].map((d) => `- ${d}`).join("\n")}\nAny PDF file under these directories can be displayed.`,
);
}
parts.push(
`Remote PDFs from ${[...allowedRemoteOrigins].join(", ")} can also be loaded dynamically.`,
);
return {
content: [{ type: "text", text }],
content: [{ type: "text", text: parts.join("\n\n") }],
structuredContent: {
localFiles: pdfs.filter((p) => p.type === "local").map((p) => p.url),
allowedDirectories: [...allowedLocalDirs],
allowedOrigins: [...allowedRemoteOrigins],
},
};
@ -470,6 +542,7 @@ export function createServer(): McpServer {
Accepts:
- Local files explicitly added to the server (use list_pdfs to see available files)
- Local files under directories provided by the client as MCP roots
- Remote PDFs from: ${allowedDomains}`,
inputSchema: {
url: z.string().default(DEFAULT_PDF).describe("PDF URL"),