From bb96159b3b85b7835c090033470f139b1a453dbd Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Sat, 18 Jul 2026 21:41:40 +0800 Subject: [PATCH] feat(hub): expose pdf_to_md_bundle as MCP tool to agent (ADR-0027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agent was falling back to Read+Write to manually parse PDFs because it had no way to invoke the capability adapter. This adds a convert_pdf_to_md MCP tool to the cph_hub server so the Agent can call it like send_file. Changes: - roleTools.ts: register convert_pdf_to_md as a CPH_HUB_MCP tool id - fileDeliveryTool.ts: add convert_pdf_to_md tool that creates the PdfToMdBundleAdapter (with AliyunDocmindClient + org credential resolution) and invokes it with workspace-confined paths. Returns generated file paths + page count + cost. MCP instructions tell the Agent to always use this tool instead of Read/Bash for PDF parsing. - trigger.ts: pass secretEnvelope + prisma + organizationId to the MCP server so it can resolve capability credentials - hub.ts: pass secretEnvelope to makeTriggerHandler - skills/pdf-to-md/SKILL.md: thin skill describing the workflow (download from Feishu → convert_pdf_to_md → send_file), when to use, and the fallback message if no capability connection is configured The Agent now sees mcp__cph_hub__convert_pdf_to_md and the MCP instructions explicitly say: 'Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md.' Version bump 0.0.32 → 0.0.33. --- hub/package.json | 2 +- hub/skills/pdf-to-md/SKILL.md | 63 ++++++++++++++++++++++++++++++ hub/src/agent/roleTools.ts | 3 ++ hub/src/feishu/fileDeliveryTool.ts | 59 ++++++++++++++++++++++++++++ hub/src/feishu/trigger.ts | 7 +++- hub/src/hub.ts | 1 + 6 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 hub/skills/pdf-to-md/SKILL.md diff --git a/hub/package.json b/hub/package.json index d5b108b..308b60d 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.32", + "version": "0.0.33", "private": true, "type": "module", "engines": { diff --git a/hub/skills/pdf-to-md/SKILL.md b/hub/skills/pdf-to-md/SKILL.md new file mode 100644 index 0000000..af0a6b9 --- /dev/null +++ b/hub/skills/pdf-to-md/SKILL.md @@ -0,0 +1,63 @@ +--- +name: pdf-to-md +description: > + Convert PDF documents to Markdown bundles using the convert_pdf_to_md tool. + Handles PDFs from Feishu messages, local workspace files, and produces + high-quality Markdown with LaTeX formulas and extracted images. +--- + +# PDF to Markdown Conversion + +## When to use + +Use this skill when the user asks to convert a PDF to Markdown, extract text +from a PDF, or turn a PDF document into an editable format. + +## How it works + +The `convert_pdf_to_md` tool (provided by the `cph_hub` MCP server) calls +Alibaba Cloud Document Mind to parse the PDF. It: + +- Extracts text in reading order (handles multi-column, scanned, and + multi-language documents) +- Converts mathematical formulas to **LaTeX** (`$...$` inline, `$$...$$` block) +- Extracts tables as Markdown tables +- Downloads embedded images into the output directory +- Writes a single `document.md` file plus image files + +## Workflow + +### PDF from a Feishu message + +1. Use `feishu_read_context` to find the `file_key` of the PDF attachment. +2. Use `feishu_download_resource` to download it into the workspace. +3. Use `convert_pdf_to_md` with the downloaded file path and an output directory. + +### PDF already in the workspace + +1. Use `convert_pdf_to_md` directly with the file path and an output directory. + +## Important rules + +- **Always** use `convert_pdf_to_md` for PDF→Markdown. Do NOT attempt to parse + PDFs yourself with Read, Bash, Python, or any other method. The tool provides + accurate formula, table, and image extraction that manual methods cannot + match. +- If `convert_pdf_to_md` fails because no capability connection is configured, + tell the user to ask their organization admin to configure the Aliyun + docmind credential in the admin web UI (组织后台 → 能力). +- The output directory will be created if it does not exist. +- After conversion, use `send_file` to send the generated markdown back to the + user if they requested it. + +## Output + +The tool returns a list of generated files: +- `document.md` — the main markdown file +- `*.jpg` / `*.png` — extracted images, referenced from the markdown + +## Cost + +The conversion is billed per page (0.04 CNY/page ≈ $0.0056/page for the +enhanced formula mode). The cost is automatically recorded on the run's +usage ledger. diff --git a/hub/src/agent/roleTools.ts b/hub/src/agent/roleTools.ts index b9869ab..a8e81fc 100644 --- a/hub/src/agent/roleTools.ts +++ b/hub/src/agent/roleTools.ts @@ -14,6 +14,7 @@ export const CPH_HUB_MCP_TOOL_IDS = [ "feishu_read_context", "feishu_download_resource", "request_approval", + "convert_pdf_to_md", ] as const; export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number]; @@ -50,10 +51,12 @@ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map([ ["feishu_read_context", "feishu_read_context"], ["feishu_download_resource", "feishu_download_resource"], ["request_approval", "request_approval"], + ["convert_pdf_to_md", "convert_pdf_to_md"], ["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__convert_pdf_to_md", "convert_pdf_to_md"], ]); const SUPPORTED_ROLE_TOOLS = new Set([ diff --git a/hub/src/feishu/fileDeliveryTool.ts b/hub/src/feishu/fileDeliveryTool.ts index 9314e87..b4ea7d5 100644 --- a/hub/src/feishu/fileDeliveryTool.ts +++ b/hub/src/feishu/fileDeliveryTool.ts @@ -7,11 +7,16 @@ import { readFeishuContext } from "./read.js"; import type { ApprovalManager } from "./approval.js"; import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js"; import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js"; +import type { PrismaClient } from "@prisma/client"; +import type { LocalSecretEnvelope } from "../security/secretEnvelope.js"; +import { createPdfToMdBundleAdapter } from "../capability/pdfToMdBundle.js"; +import { AliyunDocmindClient } from "../capability/docmindClient.js"; export interface FileDeliveryToolOptions { readonly rt: FeishuRuntime; readonly chatId: string; readonly projectId: string; + readonly organizationId: string; readonly runId: string; readonly workspaceRoot?: string | undefined; readonly workspaceDir: string; @@ -20,6 +25,8 @@ export interface FileDeliveryToolOptions { readonly approvalManager: ApprovalManager; readonly onDelivered?: (path: string) => void; readonly tools?: readonly CphHubMcpToolId[] | undefined; + readonly prisma: PrismaClient; + readonly secretEnvelope: LocalSecretEnvelope; } export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance { @@ -230,6 +237,51 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M ); } + if (enabledTools.has("convert_pdf_to_md")) { + const adapter = createPdfToMdBundleAdapter({ + secrets: options.secretEnvelope, + client: new AliyunDocmindClient(), + prisma: options.prisma, + }); + tools.push( + tool( + "convert_pdf_to_md", + "Convert a PDF file in the workspace to a Markdown bundle (markdown + extracted images) using Alibaba Cloud Document Mind. The PDF must already be in the workspace (use feishu_download_resource first if it came from Feishu). Returns the path to the generated markdown file and the list of extracted image paths. Mathematical formulas are converted to LaTeX.", + { + input_path: z.string().describe("Relative path to the input PDF within the workspace."), + output_dir: z.string().describe("Relative directory within the workspace to write the markdown and images into. Will be created if it does not exist."), + }, + async (args) => { + try { + const result = await adapter.invoke({ + runId: options.runId, + organizationId: options.organizationId, + projectId: options.projectId, + workspaceDir: options.workspaceDir, + inputPath: args.input_path, + outputDir: args.output_dir, + prisma: options.prisma, + }); + const lines = [`Converted PDF to markdown. ${result.artifacts.length} files written:`]; + for (const artifact of result.artifacts) { + lines.push(` - ${artifact.path} (${artifact.kind})`); + } + lines.push(`Pages: ${result.consumption.quantity}, Cost: $${(result.consumption.costUsd ?? 0).toFixed(4)}`); + return { + content: [{ type: "text", text: lines.join("\n") }], + }; + } catch (e) { + return { + isError: true, + content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], + }; + } + }, + { alwaysLoad: true }, + ), + ); + } + const instructions = mcpInstructions(enabledTools); return createSdkMcpServer({ name: "cph_hub", @@ -260,5 +312,12 @@ function mcpInstructions(enabledTools: ReadonlySet): string { if (enabledTools.has("request_approval")) { instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing."); } + if (enabledTools.has("convert_pdf_to_md")) { + instructions.push( + "Use convert_pdf_to_md when the user asks to convert a PDF to Markdown.", + "If the PDF came from a Feishu message, first use feishu_download_resource to save it to the workspace, then call convert_pdf_to_md.", + "Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md for accurate text, formula, and image extraction.", + ); + } return instructions.join(" "); } diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index f1b2747..d3797c7 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -14,6 +14,7 @@ import { join } from "node:path"; import type { Prisma, PrismaClient } from "@prisma/client"; import { z } from "zod"; import type { FastifyBaseLogger } from "fastify"; +import type { LocalSecretEnvelope } from "../security/secretEnvelope.js"; import { sendText, sendTextMessage, @@ -93,6 +94,7 @@ interface TriggerDeps { readonly prisma: PrismaClient; readonly settings: RuntimeSettings; readonly logger: FastifyBaseLogger; + readonly secretEnvelope: LocalSecretEnvelope; readonly runAgent?: (req: RunRequest) => Promise; readonly authorizer?: PermissionAuthorizer | undefined; readonly messageBatcherOptions?: MessageBatcherOptions | undefined; @@ -486,6 +488,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { // Streaming agent card: single interactive card through the full run // lifecycle (thinking → tool calls → streaming text → complete). // Shows tool-use trace panel + reasoning panel + answer text. + const deliveredFiles: string[] = []; const card = new StreamingAgentCard({ runId: run.id, rt, @@ -494,11 +497,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { patchIntervalMs: undefined, maxMessageLength: undefined, }); - const deliveredFiles: string[] = []; const fileDeliveryMcpServer = createFileDeliveryMcpServer({ rt, chatId, projectId, + organizationId: siloOrganizationId, runId: run.id, workspaceRoot: projectWorkspaceRoot, workspaceDir: project.workspaceDir, @@ -506,6 +509,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { sendOptions, approvalManager, tools: cphHubMcpToolsForRole(roleTools), + prisma: deps.prisma, + secretEnvelope: deps.secretEnvelope, onDelivered: (path) => { deliveredFiles.push(path); }, diff --git a/hub/src/hub.ts b/hub/src/hub.ts index 18509ca..c221ce4 100644 --- a/hub/src/hub.ts +++ b/hub/src/hub.ts @@ -162,6 +162,7 @@ export async function startHub(): Promise { prisma, settings: runtimeSettings, logger: app.log, + secretEnvelope, projectWorkspaceRoot, publicBaseUrl, siloOrganizationId: siloOrganization.id,