forked from EduCraft/curriculum-project-hub
feat(hub): expose pdf_to_md_bundle as MCP tool to agent + skill (ADR-0027) (#9)
Co-authored-by: Hong Jiarong <me@jrhim.com> Co-committed-by: Hong Jiarong <me@jrhim.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.32",
|
"version": "0.0.33",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"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_read_context",
|
||||||
"feishu_download_resource",
|
"feishu_download_resource",
|
||||||
"request_approval",
|
"request_approval",
|
||||||
|
"convert_pdf_to_md",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
|
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_read_context", "feishu_read_context"],
|
||||||
["feishu_download_resource", "feishu_download_resource"],
|
["feishu_download_resource", "feishu_download_resource"],
|
||||||
["request_approval", "request_approval"],
|
["request_approval", "request_approval"],
|
||||||
|
["convert_pdf_to_md", "convert_pdf_to_md"],
|
||||||
["mcp__cph_hub__send_file", "send_file"],
|
["mcp__cph_hub__send_file", "send_file"],
|
||||||
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
|
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
|
||||||
["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"],
|
["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"],
|
||||||
["mcp__cph_hub__request_approval", "request_approval"],
|
["mcp__cph_hub__request_approval", "request_approval"],
|
||||||
|
["mcp__cph_hub__convert_pdf_to_md", "convert_pdf_to_md"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const SUPPORTED_ROLE_TOOLS = new Set([
|
const SUPPORTED_ROLE_TOOLS = new Set([
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ import { readFeishuContext } from "./read.js";
|
|||||||
import type { ApprovalManager } from "./approval.js";
|
import type { ApprovalManager } from "./approval.js";
|
||||||
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
|
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js";
|
||||||
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.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 {
|
export interface FileDeliveryToolOptions {
|
||||||
readonly rt: FeishuRuntime;
|
readonly rt: FeishuRuntime;
|
||||||
readonly chatId: string;
|
readonly chatId: string;
|
||||||
readonly projectId: string;
|
readonly projectId: string;
|
||||||
|
readonly organizationId: string;
|
||||||
readonly runId: string;
|
readonly runId: string;
|
||||||
readonly workspaceRoot?: string | undefined;
|
readonly workspaceRoot?: string | undefined;
|
||||||
readonly workspaceDir: string;
|
readonly workspaceDir: string;
|
||||||
@@ -20,6 +25,8 @@ export interface FileDeliveryToolOptions {
|
|||||||
readonly approvalManager: ApprovalManager;
|
readonly approvalManager: ApprovalManager;
|
||||||
readonly onDelivered?: (path: string) => void;
|
readonly onDelivered?: (path: string) => void;
|
||||||
readonly tools?: readonly CphHubMcpToolId[] | undefined;
|
readonly tools?: readonly CphHubMcpToolId[] | undefined;
|
||||||
|
readonly prisma: PrismaClient;
|
||||||
|
readonly secretEnvelope: LocalSecretEnvelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
|
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);
|
const instructions = mcpInstructions(enabledTools);
|
||||||
return createSdkMcpServer({
|
return createSdkMcpServer({
|
||||||
name: "cph_hub",
|
name: "cph_hub",
|
||||||
@@ -260,5 +312,12 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
|
|||||||
if (enabledTools.has("request_approval")) {
|
if (enabledTools.has("request_approval")) {
|
||||||
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
|
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(" ");
|
return instructions.join(" ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { join } from "node:path";
|
|||||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||||
import {
|
import {
|
||||||
sendText,
|
sendText,
|
||||||
sendTextMessage,
|
sendTextMessage,
|
||||||
@@ -93,6 +94,7 @@ interface TriggerDeps {
|
|||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
readonly settings: RuntimeSettings;
|
readonly settings: RuntimeSettings;
|
||||||
readonly logger: FastifyBaseLogger;
|
readonly logger: FastifyBaseLogger;
|
||||||
|
readonly secretEnvelope: LocalSecretEnvelope;
|
||||||
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
|
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
|
||||||
readonly authorizer?: PermissionAuthorizer | undefined;
|
readonly authorizer?: PermissionAuthorizer | undefined;
|
||||||
readonly messageBatcherOptions?: MessageBatcherOptions | 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
|
// Streaming agent card: single interactive card through the full run
|
||||||
// lifecycle (thinking → tool calls → streaming text → complete).
|
// lifecycle (thinking → tool calls → streaming text → complete).
|
||||||
// Shows tool-use trace panel + reasoning panel + answer text.
|
// Shows tool-use trace panel + reasoning panel + answer text.
|
||||||
|
const deliveredFiles: string[] = [];
|
||||||
const card = new StreamingAgentCard({
|
const card = new StreamingAgentCard({
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
rt,
|
rt,
|
||||||
@@ -494,11 +497,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
patchIntervalMs: undefined,
|
patchIntervalMs: undefined,
|
||||||
maxMessageLength: undefined,
|
maxMessageLength: undefined,
|
||||||
});
|
});
|
||||||
const deliveredFiles: string[] = [];
|
|
||||||
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
|
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
|
||||||
rt,
|
rt,
|
||||||
chatId,
|
chatId,
|
||||||
projectId,
|
projectId,
|
||||||
|
organizationId: siloOrganizationId,
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
workspaceRoot: projectWorkspaceRoot,
|
workspaceRoot: projectWorkspaceRoot,
|
||||||
workspaceDir: project.workspaceDir,
|
workspaceDir: project.workspaceDir,
|
||||||
@@ -506,6 +509,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
sendOptions,
|
sendOptions,
|
||||||
approvalManager,
|
approvalManager,
|
||||||
tools: cphHubMcpToolsForRole(roleTools),
|
tools: cphHubMcpToolsForRole(roleTools),
|
||||||
|
prisma: deps.prisma,
|
||||||
|
secretEnvelope: deps.secretEnvelope,
|
||||||
onDelivered: (path) => {
|
onDelivered: (path) => {
|
||||||
deliveredFiles.push(path);
|
deliveredFiles.push(path);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export async function startHub(): Promise<void> {
|
|||||||
prisma,
|
prisma,
|
||||||
settings: runtimeSettings,
|
settings: runtimeSettings,
|
||||||
logger: app.log,
|
logger: app.log,
|
||||||
|
secretEnvelope,
|
||||||
projectWorkspaceRoot,
|
projectWorkspaceRoot,
|
||||||
publicBaseUrl,
|
publicBaseUrl,
|
||||||
siloOrganizationId: siloOrganization.id,
|
siloOrganizationId: siloOrganization.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user