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:
2026-07-18 13:33:20 +08:00
parent a12984d174
commit 4e01c18cac
10 changed files with 2100 additions and 12 deletions
+3
View File
@@ -42,6 +42,9 @@ 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",
};
function toolIcon(toolName: string): string {
+81 -4
View File
@@ -6,6 +6,7 @@ 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 {
@@ -182,18 +183,18 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
tools.push(
tool(
"request_approval",
"Send an interactive Feishu approval/confirmation card to the current chat and wait for a button click.",
"Send an interactive Feishu approval/confirmation card in the current chat and wait for the user's button click.",
{
title: z.string().describe("Card title."),
body: z.string().describe("Markdown card body explaining what needs approval or confirmation."),
options: z
.array(z.object({
label: z.string().describe("Button label shown to the user."),
value: z.string().describe("Action value returned to the agent when this button is clicked."),
style: z.enum(["primary", "default", "danger"]).optional().describe("Optional Feishu button style."),
value: z.string().describe("Stable option value returned after the user clicks."),
style: z.enum(["default", "primary", "danger"]).optional(),
}))
.min(1)
.describe("Button choices for the approval card."),
.describe("One or more response options."),
},
async (args) => {
const messageId = await sendApprovalCard(
@@ -230,6 +231,76 @@ 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",
@@ -260,5 +331,11 @@ 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(" ");
}