forked from bai/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:
@@ -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 {
|
||||
|
||||
@@ -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(" ");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user