Skip to content

Commit ce3104b

Browse files
committed
pdf-server: file roots from MCP client are read-only
allowedLocalFiles conflated two sources: CLI args (user explicitly named the file when starting the server — overwriting is intentional) and MCP file roots (client-uploaded copies in ad-hoc hidden folders that the client doesn't expect to change). New cliLocalFiles set tracks only the CLI-sourced files. Writable now requires: file is in cliLocalFiles OR strictly under a directory root. Directory roots are mounted folders where saving is expected. save_pdf enforces the same scope server-side — the viewer hides the button based on _meta.writable, but we must not trust the client.
1 parent f9cc6cd commit ce3104b

3 files changed

Lines changed: 106 additions & 11 deletions

File tree

examples/pdf-server/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
pathToFileUrl,
2121
fileUrlToPath,
2222
allowedLocalFiles,
23+
cliLocalFiles,
2324
DEFAULT_PDF,
2425
allowedLocalDirs,
2526
} from "./server.js";
@@ -138,6 +139,7 @@ async function main() {
138139
const s = fs.statSync(filePath);
139140
if (s.isFile()) {
140141
allowedLocalFiles.add(filePath);
142+
cliLocalFiles.add(filePath);
141143
console.error(`[pdf-server] Registered local file: ${filePath}`);
142144
} else if (s.isDirectory()) {
143145
allowedLocalDirs.add(filePath);

examples/pdf-server/server.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
pathToFileUrl,
1515
startFileWatch,
1616
stopFileWatch,
17+
cliLocalFiles,
1718
CACHE_INACTIVITY_TIMEOUT_MS,
1819
CACHE_MAX_LIFETIME_MS,
1920
CACHE_MAX_PDF_SIZE_BYTES,
@@ -484,11 +485,13 @@ describe("file watching", () => {
484485
tmpFile = path.join(tmpDir, "test.pdf");
485486
fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%test\n"));
486487
allowedLocalFiles.add(tmpFile);
488+
cliLocalFiles.add(tmpFile); // save_pdf test needs write scope
487489
});
488490

489491
afterEach(() => {
490492
stopFileWatch(uuid);
491493
allowedLocalFiles.delete(tmpFile);
494+
cliLocalFiles.delete(tmpFile);
492495
fs.rmSync(tmpDir, { recursive: true, force: true });
493496
});
494497

@@ -580,6 +583,59 @@ describe("file watching", () => {
580583
await server.close();
581584
});
582585

586+
it("save_pdf refuses file roots from MCP client (not CLI)", async () => {
587+
// Simulate: file is readable (in allowedLocalFiles via refreshRoots)
588+
// but NOT in cliLocalFiles — it came from the client, not a CLI arg.
589+
cliLocalFiles.delete(tmpFile);
590+
591+
const server = createServer({ enableInteract: true });
592+
const client = new Client({ name: "t", version: "1" });
593+
const [ct, st] = InMemoryTransport.createLinkedPair();
594+
await Promise.all([server.connect(st), client.connect(ct)]);
595+
596+
const original = fs.readFileSync(tmpFile);
597+
const r = await client.callTool({
598+
name: "save_pdf",
599+
arguments: {
600+
url: tmpFile,
601+
data: Buffer.from("%PDF-1.4\nshould-not-write").toString("base64"),
602+
},
603+
});
604+
expect(r.isError).toBe(true);
605+
const text = (r.content as { text: string }[])[0].text;
606+
expect(text).toContain("read-only");
607+
// Verify the file was NOT modified
608+
expect(fs.readFileSync(tmpFile)).toEqual(original);
609+
610+
await client.close();
611+
await server.close();
612+
});
613+
614+
it("save_pdf allows files under a directory root", async () => {
615+
// File NOT in cliLocalFiles but under a mounted directory root
616+
cliLocalFiles.delete(tmpFile);
617+
allowedLocalDirs.add(tmpDir);
618+
619+
const server = createServer({ enableInteract: true });
620+
const client = new Client({ name: "t", version: "1" });
621+
const [ct, st] = InMemoryTransport.createLinkedPair();
622+
await Promise.all([server.connect(st), client.connect(ct)]);
623+
624+
const r = await client.callTool({
625+
name: "save_pdf",
626+
arguments: {
627+
url: tmpFile,
628+
data: Buffer.from("%PDF-1.4\nvia-dir-root").toString("base64"),
629+
},
630+
});
631+
expect(r.isError).toBeFalsy();
632+
expect(fs.readFileSync(tmpFile, "utf8")).toBe("%PDF-1.4\nvia-dir-root");
633+
634+
allowedLocalDirs.delete(tmpDir);
635+
await client.close();
636+
await server.close();
637+
});
638+
583639
// fs.watch rename semantics differ between kqueue (macOS) and inotify
584640
// (Linux) — on Linux, the watcher on the replaced inode may not receive
585641
// further events, and the re-attach race is inherently flaky in CI.

examples/pdf-server/server.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,20 @@ export const CACHE_MAX_LIFETIME_MS = 60_000; // 60 seconds
8080
/** Max size for cached PDFs (defensive limit to prevent memory exhaustion) */
8181
export const CACHE_MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
8282

83-
/** Allowed local file paths (populated from CLI args) */
83+
/** Allowed local file paths (CLI args + file roots — read access). */
8484
export const allowedLocalFiles = new Set<string>();
8585

86-
/** Allowed local directories (populated from MCP roots) */
86+
/** Allowed local directories (CLI args + directory roots — read access). */
8787
export const allowedLocalDirs = new Set<string>();
8888

89+
/**
90+
* Subset of allowedLocalFiles that came from CLI args (not MCP roots).
91+
* Only these individual files are writable. File roots from the client
92+
* are uploaded copies in ad-hoc hidden folders — treat as read-only.
93+
* Directory roots are mounted folders; files UNDER them are writable.
94+
*/
95+
export const cliLocalFiles = new Set<string>();
96+
8997
// Works both from source (server.ts) and compiled (dist/server.js)
9098
const DIST_DIR = import.meta.filename.endsWith(".ts")
9199
? path.join(import.meta.dirname, "dist")
@@ -1350,21 +1358,29 @@ Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before
13501358
const uuid = randomUUID();
13511359

13521360
// Check writability for local files (governs save button visibility).
1353-
// Writable only if: (a) the file is explicitly in allowedLocalFiles
1354-
// (passed as a CLI arg, so the user clearly opted in), OR the file
1355-
// is STRICTLY UNDER an allowed directory root (isAncestorDir already
1356-
// excludes rel === "", so a root itself doesn't count); AND
1357-
// (b) the process has OS write permission (fs.access W_OK).
1361+
//
1362+
// Writable only if:
1363+
// (a) the file was passed as a CLI arg — the user explicitly named
1364+
// it when starting the server, so overwriting is clearly
1365+
// intentional; OR
1366+
// (b) the file is STRICTLY UNDER a directory root (isAncestorDir
1367+
// excludes rel === "", so the root itself doesn't count).
1368+
// Directory roots are mounted folders where saving makes sense.
1369+
// AND the process has OS write permission.
1370+
//
1371+
// NOT writable: file roots from the MCP client. Those are typically
1372+
// uploaded copies in ad-hoc hidden folders (e.g. Claude Desktop's
1373+
// uploads directory) — the client doesn't expect them to change.
13581374
let writable = false;
13591375
if (isFileUrl(normalized) || isLocalPath(normalized)) {
13601376
const localPath = isFileUrl(normalized)
13611377
? fileUrlToPath(normalized)
13621378
: decodeURIComponent(normalized);
13631379
const resolved = path.resolve(localPath);
1364-
const inAllowedScope =
1365-
allowedLocalFiles.has(resolved) ||
1380+
const inWriteScope =
1381+
cliLocalFiles.has(resolved) ||
13661382
[...allowedLocalDirs].some((dir) => isAncestorDir(dir, resolved));
1367-
if (inAllowedScope) {
1383+
if (inWriteScope) {
13681384
try {
13691385
await fs.promises.access(resolved, fs.constants.W_OK);
13701386
writable = true;
@@ -2336,9 +2352,30 @@ Example — add a signature image and a stamp, then screenshot to verify:
23362352
isError: true,
23372353
};
23382354
}
2355+
const resolved = path.resolve(filePath);
2356+
// Enforce the same write scope the display_pdf writable flag uses.
2357+
// The viewer hides the save button for non-writable files, but we
2358+
// must not trust the client: a direct save_pdf call should also refuse.
2359+
const inWriteScope =
2360+
cliLocalFiles.has(resolved) ||
2361+
[...allowedLocalDirs].some((dir) => isAncestorDir(dir, resolved));
2362+
if (!inWriteScope) {
2363+
return {
2364+
content: [
2365+
{
2366+
type: "text",
2367+
text:
2368+
"Save refused: file is not under a mounted directory root " +
2369+
"and was not passed as a CLI argument. MCP file roots are " +
2370+
"read-only (typically uploaded copies the client doesn't " +
2371+
"expect to change).",
2372+
},
2373+
],
2374+
isError: true,
2375+
};
2376+
}
23392377
try {
23402378
const bytes = Buffer.from(data, "base64");
2341-
const resolved = path.resolve(filePath);
23422379
await fs.promises.writeFile(resolved, bytes);
23432380
const { mtimeMs } = await fs.promises.stat(resolved);
23442381
// Don't suppress file_changed here — the saving viewer will recognise

0 commit comments

Comments
 (0)