diff --git a/examples/pdf-server/server.test.ts b/examples/pdf-server/server.test.ts index 92ef4535..619bfba9 100644 --- a/examples/pdf-server/server.test.ts +++ b/examples/pdf-server/server.test.ts @@ -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"); + }); +}); diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index 46f54036..585262f3 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -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(); +/** Allowed local directories (populated from MCP roots) */ +export const allowedLocalDirs = new Set(); + // 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 { + 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"),