ext-apps/examples/pdf-server/main.ts
Olivier Chafik 30f79b9e3f
ignore client roots by default in pdf-server (#510)
* security: ignore client roots by default in pdf-server

When the pdf-server is started with --stdio, MCP clients may advertise
roots that refer to directories on the *client's* file system. Because
the server resolves those paths locally, accepting them by default would
give the remote client access to arbitrary directories on the server's
machine.

This commit makes client roots opt-in via the --use-client-roots flag.
Without the flag, the server logs a notice and skips roots setup
entirely. The createServer() function now accepts a CreateServerOptions
object with a `useClientRoots` boolean (defaults to false).

https://claude.ai/code/session_014ohk5NMEPe8TBKpqp4ZRSw

* Enable client roots by default for HTTP, keep off for stdio

HTTP mode serves a local client so roots are safe. Stdio mode may
have a remote client whose roots would resolve against the server's
filesystem, so roots stay off unless --use-client-roots is passed.

https://claude.ai/code/session_014ohk5NMEPe8TBKpqp4ZRSw

* fix: correct transport-aware defaults for useClientRoots

stdio = local client (e.g. Claude Desktop) → auto-enable roots
HTTP = remote client → ignore roots unless --use-client-roots passed

The previous commit had the logic inverted.

https://claude.ai/code/session_014ohk5NMEPe8TBKpqp4ZRSw

* refactor: inline useClientRoots per transport branch

Remove the intermediate effectiveUseClientRoots variable — just pass
`true` in the stdio branch and the flag value in the HTTP branch.

https://claude.ai/code/session_014ohk5NMEPe8TBKpqp4ZRSw

* docs: update README to match transport-aware client roots defaults

Stdio always enables roots (client is local); HTTP ignores them by
default. The previous README text had these reversed.

https://claude.ai/code/session_014ohk5NMEPe8TBKpqp4ZRSw

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-28 00:42:11 +00:00

166 lines
4.5 KiB
TypeScript

/**
* Entry point for running the MCP server.
* Run with: npx mcp-pdf-server
* Or: node dist/index.js [--stdio] [pdf-urls...]
*/
import fs from "node:fs";
import path from "node:path";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import cors from "cors";
import type { Request, Response } from "express";
import {
createServer,
isArxivUrl,
isFileUrl,
normalizeArxivUrl,
pathToFileUrl,
fileUrlToPath,
allowedLocalFiles,
DEFAULT_PDF,
allowedLocalDirs,
} from "./server.js";
/**
* Starts an MCP server with Streamable HTTP transport in stateless mode.
*/
export async function startStreamableHTTPServer(
createServer: () => McpServer,
): Promise<void> {
const port = parseInt(process.env.PORT ?? "3001", 10);
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => {
transport.close().catch(() => {});
server.close().catch(() => {});
});
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error("MCP error:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
}
});
const httpServer = app.listen(port, (err) => {
if (err) {
console.error("Failed to start server:", err);
process.exit(1);
}
console.log(`MCP server listening on http://localhost:${port}/mcp`);
});
const shutdown = () => {
console.log("\nShutting down...");
httpServer.close(() => process.exit(0));
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
/**
* Starts an MCP server with stdio transport.
*
* @param createServer - Factory function that creates a new McpServer instance.
*/
export async function startStdioServer(
createServer: () => McpServer,
): Promise<void> {
await createServer().connect(new StdioServerTransport());
}
function parseArgs(): {
urls: string[];
stdio: boolean;
useClientRoots: boolean;
} {
const args = process.argv.slice(2);
const urls: string[] = [];
let stdio = false;
let useClientRoots = false;
for (const arg of args) {
if (arg === "--stdio") {
stdio = true;
} else if (arg === "--use-client-roots") {
useClientRoots = true;
} else if (!arg.startsWith("-")) {
// Convert local paths to file:// URLs, normalize arxiv URLs
let url = arg;
if (
!arg.startsWith("http://") &&
!arg.startsWith("https://") &&
!arg.startsWith("file://")
) {
url = pathToFileUrl(arg);
} else if (isArxivUrl(arg)) {
url = normalizeArxivUrl(arg);
}
urls.push(url);
}
}
return {
urls: urls.length > 0 ? urls : [DEFAULT_PDF],
stdio,
useClientRoots,
};
}
async function main() {
const { urls, stdio, useClientRoots } = parseArgs();
// Register local files in whitelist
for (const url of urls) {
if (isFileUrl(url)) {
const filePath = path.resolve(fileUrlToPath(url));
if (fs.existsSync(filePath)) {
const s = fs.statSync(filePath);
if (s.isFile()) {
allowedLocalFiles.add(filePath);
console.error(`[pdf-server] Registered local file: ${filePath}`);
} else if (s.isDirectory()) {
allowedLocalDirs.add(filePath);
console.error(`[pdf-server] Registered local directory: ${filePath}`);
}
} else {
console.error(`[pdf-server] Warning: File not found: ${filePath}`);
}
}
}
console.error(`[pdf-server] Ready (${urls.length} URL(s) configured)`);
if (stdio) {
// stdio → client is local (e.g. Claude Desktop), roots are safe
await startStdioServer(() => createServer({ useClientRoots: true }));
} else {
// HTTP → client is remote, only honour roots with explicit opt-in
await startStreamableHTTPServer(() => createServer({ useClientRoots }));
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});