forked from bai/curriculum-project-hub
feat(hub): expose pdf_to_md_bundle as MCP tool to agent (ADR-0027)
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.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.32",
|
||||
"version": "0.0.33",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -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.
|
||||
@@ -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<string, CphHubMcpToolId>([
|
||||
["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([
|
||||
|
||||
@@ -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<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("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(" ");
|
||||
}
|
||||
|
||||
@@ -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<RunResult>;
|
||||
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);
|
||||
},
|
||||
|
||||
@@ -162,6 +162,7 @@ export async function startHub(): Promise<void> {
|
||||
prisma,
|
||||
settings: runtimeSettings,
|
||||
logger: app.log,
|
||||
secretEnvelope,
|
||||
projectWorkspaceRoot,
|
||||
publicBaseUrl,
|
||||
siloOrganizationId: siloOrganization.id,
|
||||
|
||||
Reference in New Issue
Block a user