forked from EduCraft/curriculum-project-hub
feat(hub): add markdown_to_pdf tool and default web tools
Teachers need ad-hoc Markdown → PDF. Ship an MCP tool powered by md-to-pdf (Marked + headless Chrome) so remote images/CSS work, with workspace-scoped basedir, front-matter stripped so untrusted markdown cannot override dest/basedir/launch options, and MathJax for $/$ math. Also include WebFetch and WebSearch in the unrestricted role tool surface by default. Deploy skips Puppeteer's browser download and expects a host Chrome/Chromium (PUPPETEER_EXECUTABLE_PATH / CHROME_PATH).
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].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);
|
||||
});
|
||||
Reference in New Issue
Block a user