import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; import { sendApprovalCard, sendFileData, type FeishuRuntime, type SendMessageOptions } from "./client.js"; import { resolveDeliverableFile } from "./fileDelivery.js"; 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 { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js"; import type { PrismaClient } from "@prisma/client"; import type { LocalSecretEnvelope } from "../security/secretEnvelope.js"; import { createPdfToMdBundleAdapter, invokePdfToMdBatch, readPdfToMdConcurrency, MAX_PDF_TO_MD_BATCH_ITEMS, type PdfToMdBatchItemResult, } from "../capability/pdfToMdBundle.js"; import { AliyunDocmindClient } from "../capability/docmindClient.js"; import { createPbankService, type PbankToolResult } from "../capability/pbank.js"; import { CapabilityConnectionUnavailable } from "../capability/types.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; readonly maxFileBytes?: number | undefined; readonly sendOptions?: SendMessageOptions | undefined; 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 { const enabledTools = new Set(options.tools ?? CPH_HUB_MCP_TOOL_IDS); const tools: Array> = []; if (enabledTools.has("send_file")) { tools.push( tool( "send_file", "Upload any existing regular file from the current project workspace to the current Feishu chat. The path must point to a concrete no-symlink file and must not be inside platform runtime directories.", { path: z.string().describe("Workspace-relative path, or an absolute path physically inside the current workspace."), name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."), }, async (args) => { const workspaceRoot = options.workspaceRoot?.trim(); if (workspaceRoot === undefined || workspaceRoot === "") { options.rt.logger.error( { runId: options.runId, projectId: options.projectId }, "Agent file delivery missing configured workspace root", ); return { isError: true, content: [{ type: "text", text: "File delivery is unavailable because workspace isolation is not configured." }], }; } if (options.maxFileBytes === undefined) { throw new Error("Agent file delivery requires a configured maximum file size"); } let file; try { file = await resolveDeliverableFile(args.path, workspaceRoot, options.workspaceDir, options.maxFileBytes); } catch (error) { const boundaryViolation = error instanceof WorkspaceFileBoundaryError && error.reason === "boundary"; const limitViolation = error instanceof WorkspaceFileBoundaryError && error.reason === "limit"; const log = boundaryViolation || limitViolation ? 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, err: error, }, boundaryViolation ? "Agent file delivery refused by workspace boundary" : limitViolation ? "Agent file delivery refused by file size limit" : "Agent file delivery failed during workspace file access", ); if (limitViolation) { return { isError: true, content: [{ type: "text", text: `File exceeds the configured delivery limit: ${args.path}` }], }; } if (!boundaryViolation) throw error; return { isError: true, content: [{ type: "text", text: "File path is outside the current workspace, belongs to platform runtime, or uses a symlink." }], }; } if (file === null) { return { isError: true, content: [{ type: "text", text: `File not found or not deliverable: ${args.path}` }], }; } const messageId = await sendFileData( options.rt, options.chatId, file.data, args.name ?? file.name, options.sendOptions, ); options.onDelivered?.(file.path); return { content: [{ type: "text", text: `Sent file: ${file.path}` }], }; }, { alwaysLoad: true }, ), ); } if (enabledTools.has("feishu_read_context")) { tools.push( tool( "feishu_read_context", "Read Feishu context for the current project's bound chat. Use anchor ids from the trigger context; this tool never reads arbitrary chats.", { anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of Feishu anchor to read."), id: z.string().describe("The message/thread/status anchor id to read."), }, async (args) => { const result = await readFeishuContext( { chat_id: options.chatId, anchor: args.anchor, id: args.id }, { runId: options.runId, projectId: options.projectId, boundChatId: options.chatId, workspaceDir: options.workspaceDir, }, options.rt, ); return { content: [{ type: "text", text: result }] }; }, { alwaysLoad: true }, ), ); } if (enabledTools.has("feishu_download_resource")) { tools.push( tool( "feishu_download_resource", "Download an image or file resource from a Feishu message in the current project's bound chat into the project workspace. Use message_id and file_key returned by feishu_read_context.", { message_id: z.string().describe("The Feishu message id containing the resource."), file_key: z.string().describe("The image_key or file_key from that message's content."), resource_type: z.enum(["image", "file"]).describe("Use image for image_key and file for file_key."), }, async (args) => { const workspaceRoot = options.workspaceRoot?.trim(); if (workspaceRoot === undefined || workspaceRoot === "") { options.rt.logger.error( { runId: options.runId, projectId: options.projectId }, "Feishu resource download missing configured workspace root", ); return { isError: true, content: [{ type: "text", text: "Resource download is unavailable because workspace isolation is not configured." }], }; } const downloaded = await downloadFeishuMessageResource( { messageId: args.message_id, fileKey: args.file_key, resourceType: args.resource_type, }, { boundChatId: options.chatId, workspaceRoot, workspaceDir: options.workspaceDir, }, options.rt, ); return { content: [{ type: "text", text: JSON.stringify(downloaded) }] }; }, { alwaysLoad: true }, ), ); } if (enabledTools.has("request_approval")) { tools.push( tool( "request_approval", "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("Stable option value returned after the user clicks."), style: z.enum(["default", "primary", "danger"]).optional(), })) .min(1) .describe("One or more response options."), }, async (args) => { const messageId = await sendApprovalCard( options.rt, options.chatId, args.title, args.body, args.options.map((option) => ({ label: option.label, value: option.value, ...(option.style === undefined ? {} : { style: option.style }), })), options.sendOptions, ); if (messageId === null) { return { isError: true, content: [{ type: "text", text: "Failed to send approval card." }], }; } try { const result = await options.approvalManager.register(messageId, options.chatId); return { content: [{ type: "text", text: JSON.stringify(result) }] }; } catch (e) { return { isError: true, content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], }; } }, { alwaysLoad: true }, ), ); } 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 one or more PDF files in the workspace to Markdown bundles (markdown + extracted images) via Alibaba Cloud Document Mind. PDFs must already be in the workspace (use feishu_download_resource first for Feishu attachments). Prefer a single call with `items` for multiple PDFs — Hub converts them concurrently (bounded). Each item needs its own output_dir because the tool always writes document.md inside that directory. Formulas become LaTeX.", { input_path: z.string().optional().describe("Single-file mode: workspace-relative path to the input PDF. Required when `items` is omitted."), output_dir: z.string().optional().describe("Single-file mode: workspace-relative directory for document.md + images. Required when `items` is omitted."), items: z.array(z.object({ input_path: z.string().describe("Workspace-relative path to one input PDF."), output_dir: z.string().describe("Workspace-relative output directory for this PDF (must be unique per item)."), })).min(1).max(MAX_PDF_TO_MD_BATCH_ITEMS).optional().describe(`Batch mode: multiple PDFs converted concurrently. Max ${MAX_PDF_TO_MD_BATCH_ITEMS} items. Do not reuse output_dir across items.`), concurrency: z.number().int().min(1).max(8).optional().describe("Optional parallel job limit for batch mode (1-8). Defaults to HUB_PDF_TO_MD_MAX_CONCURRENT (usually 3)."), }, async (args) => { const base = { runId: options.runId, organizationId: options.organizationId, projectId: options.projectId, workspaceDir: options.workspaceDir, prisma: options.prisma, }; try { if (args.items !== undefined && args.items.length > 0) { const batchResults = await invokePdfToMdBatch( adapter, base, args.items.map((item) => ({ inputPath: item.input_path, outputDir: item.output_dir, })), args.concurrency ?? readPdfToMdConcurrency(), ); return { content: [{ type: "text", text: formatPdfToMdBatchResult(batchResults) }], ...(batchResults.every((item) => item.ok) ? {} : { isError: true }), }; } if (args.input_path === undefined || args.input_path.trim() === "" || args.output_dir === undefined || args.output_dir.trim() === "") { return { isError: true, content: [{ type: "text", text: "convert_pdf_to_md requires either items[{input_path,output_dir},...] or both input_path and output_dir.", }], }; } const result = await adapter.invoke({ ...base, inputPath: args.input_path, outputDir: args.output_dir, }); 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 pbankEnabled = enabledTools.has("pbank_search_problems") || enabledTools.has("pbank_get_problem") || enabledTools.has("pbank_get_many_problems"); if (pbankEnabled) { const pbank = createPbankService({ prisma: options.prisma, secrets: options.secretEnvelope, }); const pbankCtx = { organizationId: options.organizationId, runId: options.runId, workspaceDir: options.workspaceDir, }; if (enabledTools.has("pbank_search_problems")) { tools.push( tool( "pbank_search_problems", "Search Paradigm PBank (题库) by title/keyword. Returns page metadata, operator-confirmed rights guidance, and matching problem summaries. Requires an ACTIVE org capability connection for `pbank`.", { q: z.string().optional().describe("Search text."), keywords: z.array(z.string()).optional().describe("Exact keywords to filter by."), pageNum: z.number().int().min(1).optional().describe("Page number (default 1)."), pageSize: z.number().int().min(1).max(50).optional().describe("Page size (default 10, max 50)."), }, async (args) => runPbankTool(() => pbank.searchProblems(pbankCtx, { q: args.q, keywords: args.keywords, pageNum: args.pageNum, pageSize: args.pageSize, }), ), { alwaysLoad: true }, ), ); } if (enabledTools.has("pbank_get_problem")) { tools.push( tool( "pbank_get_problem", "Fetch one PBank problem by URL or UUID. Returns metadata, rights guidance, text-like source files, local zip/extract paths under .pbank-sources/, and optional image assets. Requires ACTIVE org capability `pbank`.", { urlOrId: z.string().min(1).describe("PBank problem URL or UUID."), includeProjects: z.boolean().optional().describe("Include problem/answer project archives (default true)."), materializeProjects: z.boolean().optional().describe("Write archives under workspace .pbank-sources/ (default true)."), includeAssetImages: z.boolean().optional().describe("Inline small image assets when possible (default true)."), includeOccurrences: z.boolean().optional().describe("Include occurrence list (default false)."), }, async (args) => runPbankTool(() => pbank.getProblem(pbankCtx, args)), { alwaysLoad: true }, ), ); } if (enabledTools.has("pbank_get_many_problems")) { tools.push( tool( "pbank_get_many_problems", "Fetch several PBank problems by URL or UUID. Use when the teacher pastes multiple example links. Returns rights guidance together with each problem. Requires ACTIVE org capability `pbank`.", { urlsOrIds: z.array(z.string().min(1)).min(1).max(20).describe("PBank problem URLs or UUIDs."), includeProjects: z.boolean().optional().describe("Include problem/answer project archives (default true)."), materializeProjects: z.boolean().optional().describe("Write archives under workspace .pbank-sources/ (default true)."), includeAssetImages: z.boolean().optional().describe("Inline small image assets when possible (default true)."), includeOccurrences: z.boolean().optional().describe("Include occurrence list (default false)."), }, async (args) => runPbankTool(() => pbank.getManyProblems(pbankCtx, args)), { alwaysLoad: true }, ), ); } } const instructions = mcpInstructions(enabledTools); return createSdkMcpServer({ name: "cph_hub", version: "0.0.0", alwaysLoad: true, ...(instructions === "" ? {} : { instructions }), tools, }); } function formatPdfToMdBatchResult(results: readonly PdfToMdBatchItemResult[]): string { const ok = results.filter((item) => item.ok); const failed = results.filter((item) => !item.ok); const lines = [ `Batch PDF→Markdown finished: ${ok.length} succeeded, ${failed.length} failed (of ${results.length}).`, ]; for (const item of results) { if (item.ok) { const md = item.result.artifacts.find((artifact) => artifact.kind === "markdown")?.path; lines.push( `OK ${item.inputPath} → ${item.outputDir}` + (md !== undefined ? ` (${md})` : "") + `; pages=${item.result.consumption.quantity}` + `; cost=$${(item.result.consumption.costUsd ?? 0).toFixed(4)}`, ); for (const artifact of item.result.artifacts) { lines.push(` - ${artifact.path} (${artifact.kind})`); } } else { lines.push(`FAIL ${item.inputPath} → ${item.outputDir}: ${item.error}`); } } return lines.join("\n"); } async function runPbankTool( invoke: () => Promise, ): Promise<{ content: Array< | { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } >; isError?: boolean; }> { try { const result = await invoke(); const content: Array< | { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } > = [{ type: "text", text: JSON.stringify(result.data, null, 2) }]; for (const image of result.inlineImages) { content.push({ type: "image", data: image.data, mimeType: image.mimeType }); } return { content }; } catch (error) { const message = error instanceof CapabilityConnectionUnavailable ? `${error.message}. Ask an org admin to configure the pbank capability connection.` : error instanceof Error ? error.message : String(error); return { isError: true, content: [{ type: "text", text: message }] }; } } function mcpInstructions(enabledTools: ReadonlySet): string { const instructions: string[] = []; if (enabledTools.has("send_file")) { instructions.push( "Use send_file only for downloadable attachments the user should save (PDF, DOCX, ZIP, etc.).", "For inline 图文 answers, put ![alt](workspace-relative-path) in the final assistant text instead of send_file; the hub embeds those images in the reply card.", "Do not claim a file was sent unless send_file returns success.", "If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.", ); } if (enabledTools.has("feishu_read_context")) { instructions.push("Use feishu_read_context when the trigger context contains a reply, thread, or message anchor and more Feishu context is needed."); } if (enabledTools.has("feishu_download_resource")) { instructions.push( "When feishu_read_context returns an image_key or file_key whose pixels or bytes are needed, call feishu_download_resource and then read the returned local path.", ); } 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 (or several PDFs) to Markdown.", "If PDFs came from Feishu, download each with feishu_download_resource first, then convert.", "For multiple PDFs, call convert_pdf_to_md once with items=[{input_path,output_dir},...] so Hub converts them concurrently; give each file its own output_dir (the tool writes document.md inside it).", "Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md.", ); } if ( enabledTools.has("pbank_search_problems") || enabledTools.has("pbank_get_problem") || enabledTools.has("pbank_get_many_problems") ) { instructions.push( "Use pbank_search_problems / pbank_get_problem / pbank_get_many_problems for Paradigm PBank (题库) selection.", "Treat the returned rights object as authoritative for derivative use.", "Materialized sources land under workspace-relative .pbank-sources/ — read them; do not invent problem content.", "If tools fail because no ACTIVE pbank capability connection exists, tell the user an org admin must configure 题库 on the admin capabilities page.", ); } instructions.push( "Role skill docs (when bound) are readable at .cph/runtime-skills//SKILL.md or $CPH_RUNTIME_SKILLS_DIR//SKILL.md. Prefer the Skill tool when available. Workspace .claude/ and .mcp.json are sandbox stubs — not skill or MCP source.", ); return instructions.join(" "); }