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
67 changes: 67 additions & 0 deletions examples/pdf-server/server.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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");
});
});
93 changes: 83 additions & 10 deletions examples/pdf-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -342,13 +357,59 @@ 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
// =============================================================================

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();

Expand All @@ -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],
},
};
Expand Down Expand Up @@ -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"),
Expand Down
Loading