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 } 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; 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 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", version: "0.0.0", alwaysLoad: true, ...(instructions === "" ? {} : { instructions }), tools, }); } function mcpInstructions(enabledTools: ReadonlySet): string { const instructions: string[] = []; if (enabledTools.has("send_file")) { instructions.push( "Use send_file when the user asks to receive, resend, download, or attach a file.", "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 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(" "); }