chore(hub): remove markdown_to_pdf tool (#3)

Drop markdown_to_pdf MCP surface, implementation, tests, and md-to-pdf dependency.

Roles that still list markdown_to_pdf must be cleaned before startup.

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
This commit is contained in:
2026-07-18 13:57:27 +08:00
committed by 洪佳荣
parent cc42e6a7c6
commit 97f7972cc5
8 changed files with 5 additions and 2077 deletions
-1
View File
@@ -18,7 +18,6 @@ export const TOOL_OPTIONS: ToolOption[] = [
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' },
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' },
{ id: 'request_approval', label: '请求审批', group: '飞书' },
{ id: 'markdown_to_pdf', label: 'Markdown → PDF', group: '文档' },
];
/** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel */
+5 -1491
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -14,7 +14,6 @@
"ai": "^7.0.16",
"dotenv": "^17.4.2",
"fastify": "^5.8.5",
"md-to-pdf": "^5.2.5",
"zod": "^4.4.3"
},
"devDependencies": {
-323
View File
@@ -1,323 +0,0 @@
/**
* Markdown → PDF via md-to-pdf (Marked + headless Chrome).
*
* Input/output paths are confined to the project workspace. The package's local
* file server uses `basedir = workspaceDir` so relative media resolve inside
* the project, and remote images/styles load over the network. Front-matter is
* stripped so untrusted markdown cannot override basedir/dest/launch options.
*/
import { access } from "node:fs/promises";
import { constants } from "node:fs";
import { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { mdToPdf } from "md-to-pdf";
const DEFAULT_MAX_MARKDOWN_BYTES = 2 * 1024 * 1024;
const RENDER_TIMEOUT_MS = 120_000;
const DEFAULT_CSS = `
body {
font-family: "Noto Sans CJK SC", "Source Han Sans SC", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
font-size: 12pt;
line-height: 1.65;
color: #111;
max-width: 48rem;
margin: 0 auto;
padding: 0 0.5rem;
}
img, svg, video { max-width: 100%; height: auto; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid #ccc; padding: 0.4em 0.6em; vertical-align: top; }
th { background: #f5f5f5; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; }
pre { overflow: auto; padding: 0.75em 1em; background: #f6f8fa; border-radius: 6px; }
blockquote { margin-left: 0; padding-left: 1em; border-left: 4px solid #ddd; color: #444; }
.page-break { page-break-after: always; break-after: page; }
`;
const MATHJAX_CONFIG = `
window.MathJax = {
tex: {
inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],
displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']],
processEscapes: true,
},
options: {
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code'],
},
};
`;
const SYSTEM_CHROME_CANDIDATES = [
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/snap/bin/chromium",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
] as const;
export class MarkdownToPdfError extends Error {
constructor(
message: string,
readonly reason: "boundary" | "not_found" | "limit" | "config" | "compile" | "io" = "io",
) {
super(message);
this.name = "MarkdownToPdfError";
}
}
export interface MarkdownToPdfInput {
readonly workspaceRoot: string;
readonly workspaceDir: string;
readonly markdownPath: string;
readonly outputPath?: string | undefined;
readonly chromePath?: string | undefined;
readonly maxMarkdownBytes?: number | undefined;
}
export interface MarkdownToPdfResult {
readonly outputPath: string;
readonly bytes: number;
}
export function defaultMarkdownToPdfOutputPath(markdownPath: string): string {
const base = basename(markdownPath.trim());
const stem = base === "" || base === "." || base === ".."
? "document"
: basename(base, extname(base)) || "document";
return join("build", `${stem}.pdf`);
}
export async function renderMarkdownToPdf(input: MarkdownToPdfInput): Promise<MarkdownToPdfResult> {
const workspaceRoot = input.workspaceRoot.trim();
if (workspaceRoot === "") {
throw new MarkdownToPdfError("markdown_to_pdf requires a configured workspace root", "config");
}
const workspaceDir = input.workspaceDir.trim();
if (workspaceDir === "") {
throw new MarkdownToPdfError("markdown_to_pdf requires a configured workspace directory", "config");
}
const maxMarkdownBytes = input.maxMarkdownBytes ?? DEFAULT_MAX_MARKDOWN_BYTES;
const markdownAbsolute = await resolveReadableWorkspaceFile(
workspaceRoot,
workspaceDir,
input.markdownPath,
maxMarkdownBytes,
);
// Strip front-matter: md-to-pdf merges YAML into config and would otherwise
// let untrusted markdown override basedir/dest/launch_options.
const markdownBody = stripYamlFrontMatter(await readFile(markdownAbsolute, "utf8"));
const outputRelative = input.outputPath?.trim() || defaultMarkdownToPdfOutputPath(input.markdownPath);
const outputAbsolute = confineWorkspacePath(outputRelative, workspaceDir);
if (!outputAbsolute.toLowerCase().endsWith(".pdf")) {
throw new MarkdownToPdfError(`output path must end with .pdf: ${outputRelative}`, "config");
}
const workspaceReal = await realpath(workspaceDir);
const markdownDir = dirname(markdownAbsolute);
// Content-mode relativePath is "."; serve the markdown's directory so
// sibling relative media work, without exposing parent of the project.
const markdownDirReal = await realpath(markdownDir);
const dirRel = relative(workspaceReal, markdownDirReal);
if (dirRel === ".." || dirRel.startsWith(`..${sep}`) || isAbsolute(dirRel)) {
throw new MarkdownToPdfError("markdown directory escapes workspace", "boundary");
}
await mkdir(dirname(outputAbsolute), { recursive: true });
const chromePath = input.chromePath?.trim()
|| process.env["PUPPETEER_EXECUTABLE_PATH"]?.trim()
|| process.env["CHROME_PATH"]?.trim()
|| process.env["GOOGLE_CHROME_BIN"]?.trim()
|| await findSystemChrome();
const launchOptions: Record<string, unknown> = {
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--font-render-hinting=medium",
"--disable-gpu",
],
};
if (chromePath !== undefined && chromePath !== "") {
launchOptions.executablePath = chromePath;
}
let pdf;
try {
pdf = await withTimeout(
mdToPdf(
{ content: markdownBody },
{
// Local images resolve relative to the markdown file's directory.
basedir: markdownDirReal,
dest: outputAbsolute,
body_class: ["markdown-body"],
css: DEFAULT_CSS,
script: [
{ content: MATHJAX_CONFIG },
{ url: "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js" },
],
pdf_options: {
format: "A4",
printBackground: true,
margin: { top: "18mm", right: "16mm", bottom: "18mm", left: "16mm" },
},
launch_options: launchOptions,
// gray-matter JS engines stay off (default). Front-matter already stripped.
},
),
RENDER_TIMEOUT_MS,
"md-to-pdf timed out",
);
} catch (error) {
throw new MarkdownToPdfError(`markdown pdf render failed: ${formatError(error)}`, "compile");
}
if (pdf === undefined || pdf.content === undefined) {
throw new MarkdownToPdfError("markdown pdf render produced no output", "compile");
}
const bytes = Buffer.isBuffer(pdf.content)
? pdf.content
: Buffer.from(pdf.content as Uint8Array);
if (!bytes.subarray(0, 4).equals(Buffer.from("%PDF"))) {
// md-to-pdf may have written dest already; re-read as source of truth.
const written = await readFile(outputAbsolute);
if (!written.subarray(0, 4).equals(Buffer.from("%PDF"))) {
throw new MarkdownToPdfError("markdown pdf render produced a non-PDF artifact", "compile");
}
return {
outputPath: relative(workspaceDir, outputAbsolute).split(sep).join("/"),
bytes: written.length,
};
}
// Ensure dest holds the bytes even if the package's write path differed.
await writeFile(outputAbsolute, bytes);
return {
outputPath: relative(workspaceDir, outputAbsolute).split(sep).join("/"),
bytes: bytes.length,
};
}
function stripYamlFrontMatter(source: string): string {
if (!source.startsWith("---\n") && !source.startsWith("---\r\n")) return source;
const match = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(source);
if (match === null) return source;
return source.slice(match[0].length);
}
function confineWorkspacePath(requestedPath: string, workspaceDir: string): string {
const workspace = resolve(workspaceDir);
const resolved = isAbsolute(requestedPath)
? resolve(requestedPath)
: resolve(workspace, requestedPath);
const rel = relative(workspace, resolved);
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new MarkdownToPdfError(`path escapes workspace: ${requestedPath}`, "boundary");
}
if (rel.split(sep)[0] === ".cph") {
throw new MarkdownToPdfError(
`platform runtime paths are not valid markdown_to_pdf targets: ${requestedPath}`,
"boundary",
);
}
return resolved;
}
async function resolveReadableWorkspaceFile(
workspaceRoot: string,
workspaceDir: string,
requestedPath: string,
maxBytes: number,
): Promise<string> {
const root = resolve(workspaceRoot);
const workspace = resolve(workspaceDir);
const workspaceRel = relative(root, workspace);
if (
workspaceRel === "" ||
workspaceRel === ".." ||
workspaceRel.startsWith(`..${sep}`) ||
isAbsolute(workspaceRel)
) {
throw new MarkdownToPdfError("project workspace escapes configured workspace root", "boundary");
}
const candidate = confineWorkspacePath(requestedPath, workspace);
let metadata;
try {
metadata = await lstat(candidate);
} catch (error) {
if (isErrno(error, "ENOENT")) {
throw new MarkdownToPdfError(`markdown file not found: ${requestedPath}`, "not_found");
}
throw error;
}
if (metadata.isSymbolicLink()) {
throw new MarkdownToPdfError(`markdown path must not be a symlink: ${requestedPath}`, "boundary");
}
if (!metadata.isFile()) {
throw new MarkdownToPdfError(`markdown path is not a regular file: ${requestedPath}`, "boundary");
}
if (metadata.size > maxBytes) {
throw new MarkdownToPdfError(
`markdown file exceeds ${maxBytes} bytes: ${requestedPath}`,
"limit",
);
}
const realFile = await realpath(candidate);
const realWorkspace = await realpath(workspace);
const realRel = relative(realWorkspace, realFile);
if (realRel === "" || realRel === ".." || realRel.startsWith(`..${sep}`) || isAbsolute(realRel)) {
throw new MarkdownToPdfError(
`markdown path escapes workspace after resolution: ${requestedPath}`,
"boundary",
);
}
return realFile;
}
async function findSystemChrome(): Promise<string | undefined> {
for (const candidate of SYSTEM_CHROME_CANDIDATES) {
try {
await access(candidate, constants.X_OK);
return candidate;
} catch {
// try next
}
}
return undefined;
}
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise<T>((resolvePromise, reject) => {
const timer = setTimeout(() => reject(new Error(`${message} after ${ms}ms`)), ms);
promise.then(
(value) => {
clearTimeout(timer);
resolvePromise(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error);
},
);
});
}
function isErrno(error: unknown, code: string): boolean {
return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === code;
}
function formatError(error: unknown): string {
if (error instanceof Error && error.message.trim() !== "") return error.message.trim();
return String(error);
}
-3
View File
@@ -14,7 +14,6 @@ export const CPH_HUB_MCP_TOOL_IDS = [
"feishu_read_context",
"feishu_download_resource",
"request_approval",
"markdown_to_pdf",
] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
@@ -51,12 +50,10 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
["feishu_read_context", "feishu_read_context"],
["feishu_download_resource", "feishu_download_resource"],
["request_approval", "request_approval"],
["markdown_to_pdf", "markdown_to_pdf"],
["mcp__cph_hub__send_file", "send_file"],
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"],
["mcp__cph_hub__request_approval", "request_approval"],
["mcp__cph_hub__markdown_to_pdf", "markdown_to_pdf"],
]);
const SUPPORTED_ROLE_TOOLS = new Set([
-1
View File
@@ -42,7 +42,6 @@ const TOOL_ICONS: Record<string, string> = {
request_approval: "thumb-up-filled",
feishu_read_context: "search-filled",
feishu_download_resource: "download-filled",
markdown_to_pdf: "file-link-excel-filled",
webfetch: "link-copy-filled",
websearch: "search-filled",
};
-77
View File
@@ -6,7 +6,6 @@ import { downloadFeishuMessageResource } from "./download.js";
import { readFeishuContext } from "./read.js";
import type { ApprovalManager } from "./approval.js";
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
import { MarkdownToPdfError, renderMarkdownToPdf } from "../agent/markdownToPdf.js";
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
export interface FileDeliveryToolOptions {
@@ -231,76 +230,6 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
);
}
if (enabledTools.has("markdown_to_pdf")) {
tools.push(
tool(
"markdown_to_pdf",
"Render a Markdown file in the current project workspace to PDF (CommonMark/GFM via md-to-pdf + headless Chrome). Supports external image/CSS URLs, local images relative to the project root, and LaTeX math ($...$ / $$...$$). path is workspace-relative; output defaults to build/<name>.pdf.",
{
path: z.string().describe("Workspace-relative Markdown path (or absolute path inside the workspace)."),
output: z
.string()
.optional()
.describe("Workspace-relative PDF output path. Defaults to build/<markdown-basename>.pdf."),
},
async (args) => {
const workspaceRoot = options.workspaceRoot?.trim();
if (workspaceRoot === undefined || workspaceRoot === "") {
options.rt.logger.error(
{ runId: options.runId, projectId: options.projectId },
"markdown_to_pdf missing configured workspace root",
);
return {
isError: true,
content: [{ type: "text", text: "markdown_to_pdf is unavailable because workspace isolation is not configured." }],
};
}
try {
const result = await renderMarkdownToPdf({
workspaceRoot,
workspaceDir: options.workspaceDir,
markdownPath: args.path,
...(args.output === undefined ? {} : { outputPath: args.output }),
...(options.maxFileBytes === undefined ? {} : { maxMarkdownBytes: options.maxFileBytes }),
});
return {
content: [{
type: "text",
text: JSON.stringify({
ok: true,
path: result.outputPath,
bytes: result.bytes,
}),
}],
};
} catch (error) {
if (error instanceof MarkdownToPdfError) {
const log = error.reason === "compile" || error.reason === "config"
? options.rt.logger.warn.bind(options.rt.logger)
: options.rt.logger.error.bind(options.rt.logger);
log(
{
runId: options.runId,
projectId: options.projectId,
requestedPath: args.path,
reason: error.reason,
err: error,
},
"markdown_to_pdf failed",
);
return {
isError: true,
content: [{ type: "text", text: error.message }],
};
}
throw error;
}
},
{ alwaysLoad: true },
),
);
}
const instructions = mcpInstructions(enabledTools);
return createSdkMcpServer({
name: "cph_hub",
@@ -331,11 +260,5 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
if (enabledTools.has("request_approval")) {
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
}
if (enabledTools.has("markdown_to_pdf")) {
instructions.push(
"Use markdown_to_pdf to convert a workspace Markdown file into a PDF (GFM + external resources + LaTeX math).",
"After rendering, call send_file with the returned PDF path if the user should receive the file in chat.",
);
}
return instructions.join(" ");
}
-180
View File
@@ -1,180 +0,0 @@
import { access, constants } from "node:fs";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import {
defaultMarkdownToPdfOutputPath,
MarkdownToPdfError,
renderMarkdownToPdf,
} from "../../src/agent/markdownToPdf.js";
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
const accessAsync = promisify(access);
const roots: string[] = [];
const CHROME_CANDIDATES = [
process.env["PUPPETEER_EXECUTABLE_PATH"],
process.env["CHROME_PATH"],
process.env["GOOGLE_CHROME_BIN"],
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
].filter((v): v is string => typeof v === "string" && v.trim() !== "");
afterEach(async () => {
while (roots.length > 0) {
const root = roots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true });
}
});
describe("role tool defaults", () => {
it("includes WebFetch, WebSearch, and markdown_to_pdf in the unrestricted surface", () => {
const config = claudeSdkToolConfigForRole(undefined);
expect(config.tools).toEqual([
"Read",
"Write",
"Bash",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
]);
expect(config.allowedTools).toContain("WebFetch");
expect(config.allowedTools).toContain("WebSearch");
expect(config.allowedTools).toContain("mcp__cph_hub__markdown_to_pdf");
expect(cphHubMcpToolsForRole(undefined)).toContain("markdown_to_pdf");
});
it("maps explicit markdown_to_pdf and web tools", () => {
expect(claudeSdkToolConfigForRole(["web_fetch", "web_search", "markdown_to_pdf"])).toEqual({
tools: ["WebFetch", "WebSearch"],
allowedTools: ["WebFetch", "WebSearch", "mcp__cph_hub__markdown_to_pdf"],
});
expect(cphHubMcpToolsForRole(["markdown_to_pdf"])).toEqual(["markdown_to_pdf"]);
});
});
describe("renderMarkdownToPdf", () => {
it("defaults the output next to build/<stem>.pdf", () => {
expect(defaultMarkdownToPdfOutputPath("notes/handout.md")).toBe(join("build", "handout.pdf"));
expect(defaultMarkdownToPdfOutputPath("README")).toBe(join("build", "README.pdf"));
});
it("renders markdown through md-to-pdf with a system Chrome", async () => {
let chromePath: string | undefined;
for (const candidate of CHROME_CANDIDATES) {
try {
await accessAsync(candidate, constants.X_OK);
chromePath = candidate;
break;
} catch {
// try next
}
}
if (chromePath === undefined) {
// No browser on this host — mapping tests above still cover the contract.
return;
}
const root = await mkdtemp(join(tmpdir(), "cph-md2pdf-test-"));
roots.push(root);
const workspace = join(root, "project");
await mkdir(workspace, { recursive: true });
await writeFile(
join(workspace, "lesson.md"),
[
"# 测试讲义",
"",
"段落里有行内数学 $E=mc^2$。",
"",
"$$",
"\\int_0^1 x\\,dx = \\frac{1}{2}",
"$$",
"",
"- item a",
"- item b",
"",
"![remote](https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/128px-React-icon.svg.png)",
"",
].join("\n"),
"utf8",
);
const result = await renderMarkdownToPdf({
workspaceRoot: root,
workspaceDir: workspace,
markdownPath: "lesson.md",
outputPath: "build/lesson.pdf",
chromePath,
});
expect(result.outputPath).toBe("build/lesson.pdf");
expect(result.bytes).toBeGreaterThan(500);
const pdf = await readFile(join(workspace, "build", "lesson.pdf"));
expect(pdf.subarray(0, 4).toString("utf8")).toBe("%PDF");
}, 120_000);
it("refuses output outside the workspace", async () => {
const root = await mkdtemp(join(tmpdir(), "cph-md2pdf-boundary-"));
roots.push(root);
const workspace = join(root, "project");
await mkdir(workspace, { recursive: true });
await writeFile(join(workspace, "a.md"), "# a\n", "utf8");
await expect(
renderMarkdownToPdf({
workspaceRoot: root,
workspaceDir: workspace,
markdownPath: "a.md",
outputPath: "../escape.pdf",
}),
).rejects.toMatchObject({ reason: "boundary" } satisfies Partial<MarkdownToPdfError>);
});
it("ignores front-matter that would override basedir/dest", async () => {
let chromePath: string | undefined;
for (const candidate of CHROME_CANDIDATES) {
try {
await accessAsync(candidate, constants.X_OK);
chromePath = candidate;
break;
} catch {
// try next
}
}
if (chromePath === undefined) return;
const root = await mkdtemp(join(tmpdir(), "cph-md2pdf-fm-"));
roots.push(root);
const workspace = join(root, "project");
await mkdir(workspace, { recursive: true });
await writeFile(
join(workspace, "evil.md"),
[
"---",
"basedir: /",
"dest: /tmp/should-not-write.pdf",
"---",
"",
"# safe body",
"",
].join("\n"),
"utf8",
);
const result = await renderMarkdownToPdf({
workspaceRoot: root,
workspaceDir: workspace,
markdownPath: "evil.md",
outputPath: "build/safe.pdf",
chromePath,
});
expect(result.outputPath).toBe("build/safe.pdf");
const pdf = await readFile(join(workspace, "build", "safe.pdf"));
expect(pdf.subarray(0, 4).toString("utf8")).toBe("%PDF");
}, 120_000);
});