import { randomUUID } from "node:crypto"; import express, { type NextFunction, type Request, type Response } from "express"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { hostHeaderValidation } from "@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js"; import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js"; import { ApprovalService } from "./approval-service.js"; import { errorMessage } from "./errors.js"; import type { AppConfig } from "./config.js"; import { DingTalkClient } from "./dingtalk.js"; import type { XftApprovalProvider } from "./xft.js"; import { XftOpenApiClient } from "./xft-open-api.js"; import { UnifiedApprovalService, type XftChallengeProvider } from "./unified-approval.js"; import type { OfficialOaReturnClient } from "./unified-approval.js"; import { DINGTALK_OAUTH_AUTHORIZE_ORIGIN, DingTalkMcpOAuthProvider } from "./oauth.js"; import type { AuthenticatedDingTalkUser } from "./xft.js"; import { InvoiceDeduplicationService, InvoiceDedupStore, invoiceDedupDatabasePath } from "./invoice-dedup.js"; type TransportEntry = | { kind: "streamable"; transport: StreamableHTTPServerTransport; userId: string; createdAt: number; lastSeenAt: number } | { kind: "sse"; transport: SSEServerTransport; userId: string; createdAt: number; lastSeenAt: number }; const MCP_SESSION_IDLE_TTL_MS = 2 * 60 * 60_000; const MCP_SESSION_MAX_AGE_MS = 24 * 60 * 60_000; const MCP_SESSION_MAX_PER_USER = 8; const MCP_SESSION_MAX_TOTAL = 1000; export const APPROVAL_MCP_INSTRUCTIONS = [ "先调用 approval_list_todos 获取当前待办。用户只是在查看时,仅回答标题、申请人、金额、时间等业务信息,不主动讨论审批接口、批处理方式或索取写操作授权。用户明确要求处理后,在其授权范围内内部选择整组引用或自动分批;技术分批不得造成重复确认。", "approvalId、selectionId、targetId、单次条数限制、分批方式以及内部引用是否返回均属于 Agent 与 MCP 的内部调用细节,绝不向用户展示、解释或要求用户操作。面向用户只说明目标审批、业务动作、成功结果和需要用户决策的业务失败;不要说引用是否签发、是否失效、是否存在一键全选或如何分组。", "查询本人处理过或本人发起的审批时调用 approval_list_history;默认最近30天,可用 startDate/endDate 指定最长120天的时间窗口。用其只读 approvalId 获取详情;历史引用不能用于 approval_get_actions 或 approval_process_batch,也不会返回 selectionId。", "费用复核优先调用 approval_get_detail(view=expense);summary 用于一般详情,raw 仅用于分页补充字段。", "用于审批动作的 approvalId 和 selectionId 必须来自 approval_list_todos;approval_list_history 返回的 approvalId 只能读取详情。同意/拒绝无需逐张调用 approval_get_actions,网关会在写入前实时复核。只有退回必须先调用 approval_get_actions 并使用其 targetId,禁止自行构造。", "只有用户明确要求处理审批时,才调用 approval_process_batch;写操作不会二次确认。开始写入前会先校验整批 approvalId 和 targetId;任一引用无效则整批零写入。重新拉取待办后必须重新确认目标,禁止自动补偿或自动重试。", "钉钉开放接口无法预检部分模板的签字等业务规则;要求手写签名时返回客户端处理链接。处理结果中 failed 表示操作被拒绝且未生效;除非用户看过原因后再次明确要求,否则不要重试。succeeded 表示节点提交成功;completed 才表示无需当前用户继续审批。uncertain/outcome_uncertain 表示已经提交但未确认,禁止自动重试,应稍后重新列出待办。followUpRequired 大于 0 时应重新列出待办并告知用户。", "出现 authChallenge 时,工具不会通过其他消息通道自动发送内容。必须把 directUrl 原样写入当前面向用户的回复;有二维码图片时直接作为当前回复附件,禁止转成 ASCII,也不得只声称已生成或已推送。", "发起审批范围仅包括薪福通已支持模板,以及 6 个钉钉候选模板:北极大的出差申请、外出、业务招待申请,捷康的差旅费用报销、业务招待费用报销、业务招待申请。禁止绕过范围限制。固定使用 approval_list_submission_templates → approval_get_submission_schema → approval_prepare_submission。薪福通部门或城市编码必须用 approval_get_submission_options 实时查询,不得猜测。必须先检查 submissionCapability;client_only 时停止 API 构造并引导钉钉客户端。读取 Schema 后,逐项核对用户已提供的信息;必填字段缺失或含糊时必须继续询问且不得猜测,直到全部完整。必须列出尚未填写的非必填字段并询问用户是否补充,用户可以明确跳过。prepare 若返回缺失、无效或需自选处理人的字段,继续询问后重新 prepare。prepare 成功后必须向用户展示完整预览并另行取得明确确认;确认后才用完全相同的内容调用 approval_submit。不要让用户或 Agent 提供发起人 userId、薪福通员工号或审批状态。", "approval_prepare_submission 会自动检查当前用户近30天本人发起的钉钉与薪福通审批是否使用了相同发票。若返回 duplicate_confirmation_required 或 duplicate_check_unavailable,本次没有生成 confirmationId;必须展示匹配单号、状态和查重覆盖情况并询问用户。只有用户明确表示知晓重复或查重不完整仍要提交后,才可将 allowDuplicateInvoices=true 原样重新 prepare。不得自行代替用户确认。", "approval_submit 发起后会返回实时核验结果;仍在审批中的单据会带 revokeId,按企业规则即时完成的薪福通流程返回 completed 且不可撤回。只有用户明确要求撤销该本人审批时才调用 approval_revoke_submission;禁止用撤销代替拒绝别人的待办,禁止自动重试 completed、failed 或 outcome_uncertain。薪福通创建或撤回需要复核时使用只读 approval_get_submission_status,它会实时校验本人已发起单据归属,但不会再次执行写操作。", ].join("\n"); export function createApprovalMcpServer( config: AppConfig, service: ApprovalService, xft?: XftApprovalProvider, xftChallenges?: XftChallengeProvider, officialOaReturns?: OfficialOaReturnClient, authenticatedUser?: AuthenticatedDingTalkUser, audit?: (userId: string, action: string, reference: string | undefined, outcome: string) => void, invoiceDedup?: InvoiceDeduplicationService, ): McpServer { const server = new McpServer( { name: "dingtalk-approval-mcp", version: "1.1.0-rc.8" }, { instructions: APPROVAL_MCP_INSTRUCTIONS }, ); const unified = new UnifiedApprovalService(config, service, xft, xftChallenges, officialOaReturns, invoiceDedup); const user = authenticatedUser ?? config.trustedLocalUser; if (!user) throw new Error("MCP 会话缺少已认证用户"); server.registerTool("approval_list_todos", { title: "列出我的审批待办", description: "审批操作的入口。通过同一登录身份实时列出当前用户在钉钉OA和薪福通中的待办,并取得后续接口必须使用的approvalId;列表尽可能直接包含金额和带时区的提交时间。", inputSchema: { source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""), limit: z.number().int().min(1).max(200).default(50), }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async ({ source, query, limit }) => compactResult( "已实时查询审批待办。", await unified.listTodos(user, source, limit, query), xftChallenges, )); server.registerTool("approval_list_history", { title: "列出我的审批历史", description: "通过同一登录身份实时查询钉钉OA和薪福通历史。scope=handled表示当前用户已经处理过的审批;scope=submitted表示当前用户本人发起的审批。默认最近30天,可指定最长120天的时间窗口。返回只读approvalId,可继续读取详情但不能执行审批动作。", inputSchema: { scope: z.enum(["handled", "submitted"]), source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""), limit: z.number().int().min(1).max(200).default(50), startDate: z.string().max(40).default("").describe("可选;YYYY-MM-DD(按 Asia/Shanghai)或带时区 ISO 8601;不填时默认最近30天"), endDate: z.string().max(40).default("").describe("可选且包含结束日;格式同 startDate;单次跨度最多120天"), }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async ({ scope, source, query, limit, startDate, endDate }) => compactResult( scope === "handled" ? "已实时查询我处理过的审批。" : "已实时查询我发起的审批。", await unified.listHistory(user, scope, source, limit, query, startDate, endDate), xftChallenges, )); server.registerTool("approval_get_detail", { title: "获取审批详情", description: "使用approval_list_todos或approval_list_history返回的approvalId读取实时详情。历史引用会重新校验本人处理或本人发起关系且保持只读。一般查看用summary,费用复核优先用expense读取结构化expenseItems;只有缺少字段时才用经过过滤和硬限额保护的raw分页视图。", inputSchema: { approvalId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_todos 或 approval_list_history 返回的短期签名引用"), view: z.enum(["summary", "expense", "raw"]).default("summary"), rawPath: z.string().max(1024).default(""), rawCursor: z.number().int().min(0).default(0), rawLimit: z.number().int().min(1).max(50).default(20), rawQuery: z.string().max(200).default("").describe("raw 视图可选的有界字段搜索;例如‘关联申请单’"), }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async ({ approvalId, view, rawPath, rawCursor, rawLimit, rawQuery }) => compactResult( "已返回审批详情。", await unified.getDetail(user, approvalId, { view, rawPath, rawCursor, rawLimit, rawQuery }), xftChallenges, )); server.registerTool("approval_get_actions", { title: "获取审批可执行动作", description: "使用approval_list_todos返回的approvalId实时检查任务与退回目标。钉钉开放接口无法预检部分签字等业务规则;要求客户端处理时会返回明确链接。执行退回前必须先调用本工具,并将其返回的targetId原样传给approval_process_batch。", inputSchema: { approvalId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_todos 返回的短期签名引用") }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async ({ approvalId }) => compactResult( "已返回当前可执行审批动作。", await unified.getActions(user, approvalId), xftChallenges, )); server.registerTool("approval_process_batch", { title: "处理审批", description: "仅在用户明确要求时调用,写操作不二次确认。在用户已授权的业务范围内按Schema容量内部处理,调用容量、引用和分批方式不得转述给用户。网关先校验整批引用,任一无效则零写入;不会自动补偿或重试。同意/拒绝无需逐张预检;退回必须使用approval_get_actions返回的目标引用。succeeded表示节点提交成功,completed表示无需继续审批,followUpRequired表示仍有本人后续节点,outcome_uncertain禁止自动重试。", inputSchema: { action: z.enum(["approve", "reject", "return"]), remark: z.string().max(1000).default(""), selectionId: z.string().min(20).max(4096).optional().describe("approval_list_todos 返回、精确绑定整组列表结果的短期引用;仅用于整组同意或拒绝"), items: z.array(z.object({ approvalId: z.string().min(20).max(4096).describe("approval_list_todos 返回的签名引用;不是业务编号或 taskId"), targetId: z.string().min(20).max(4096).optional().describe("仅退回时使用 approval_get_actions 返回的不透明签名引用;不是上游 taskId"), })).max(APPROVAL_BATCH_LIMIT, `一次最多处理 ${APPROVAL_BATCH_LIMIT} 条审批`).default([]), }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, }, async ({ action, remark, selectionId, items }) => { try { const result = await unified.processBatch(user, action, remark, items, selectionId); audit?.( user.userId, action, selectionId ?? items.map((item) => item.approvalId).join(","), result.status === "preflight_failed" ? "preflight_failed" : "completed", ); return compactResult(approvalBatchSummary(result), result, xftChallenges); } catch (error) { audit?.(user.userId, action, selectionId ?? items.map((item) => item.approvalId).join(","), "failed"); throw error; } }); server.registerTool("approval_list_submission_templates", { title: "列出可发起审批模板", description: "实时列出允许检查发起能力的模板:薪福通已支持模板,以及北极大的出差申请、外出、业务招待申请和捷康的差旅费用报销、业务招待费用报销、业务招待申请;返回短期templateId。是否可由 API 构造必须再看 approval_get_submission_schema 的 submissionCapability。", inputSchema: { source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""), limit: z.number().int().min(1).max(100).default(50), }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async ({ source, query, limit }) => compactResult( "已实时列出当前用户可发起的审批模板。", await unified.listSubmissionTemplates(user, source, limit, query), )); server.registerTool("approval_get_submission_schema", { title: "获取发起审批表单结构", description: "使用模板列表返回的templateId实时读取结构化字段、必填状态和填写约束。Agent必须持续询问直至必填字段齐全,并主动提醒可选字段;不会返回超大的上游原始Schema。", inputSchema: { templateId: z.string().min(20).max(4096).describe("必须原样使用 approval_list_submission_templates 返回的短期签名引用"), }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async ({ templateId }) => compactResult( "已返回可填写字段和约束。", await unified.getSubmissionSchema(user, templateId), )); server.registerTool("approval_get_submission_options", { title: "查询发起审批可用选项", description: "使用薪福通templateId实时查询当前登录用户本人的承担部门,或按名称搜索城市编码。只返回可填写的code/label,不返回成员档案、员工号或原始上游数据。", inputSchema: { templateId: z.string().min(20).max(4096), kind: z.enum(["department", "city"]), query: z.string().max(100).default(""), limit: z.number().int().min(1).max(100).default(50), }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async ({ templateId, kind, query, limit }) => compactResult( "已实时返回可填写选项。", await unified.getSubmissionOptions(user, templateId, kind, query, limit), )); const submissionInputSchema = { templateId: z.string().min(20).max(4096).describe("模板列表返回的短期签名引用"), values: z.record(z.string().min(1).max(200), z.unknown()).describe("按模板Schema填写的表单业务字段;不得包含发起人身份、员工号或审批状态"), deptId: z.number().int().default(-1).describe("仅钉钉OA使用;默认传-1,由服务器实时解析并验证当前登录用户所属部门"), targetSelectActioners: z.array(z.object({ actionerKey: z.string().min(1).max(500), actionerStaffIds: z.array(z.string().min(1).max(500)).min(1).max(50), })).max(20).default([]), allowDuplicateInvoices: z.boolean().default(false).describe("仅当 prepare 已展示重复/疑似重复或查重覆盖不完整,且用户明确确认仍要提交时设为 true;不得由 Agent 自行开启"), }; server.registerTool("approval_prepare_submission", { title: "校验并预览新审批", description: "仅在用户已补齐所有必填字段并选择是否填写可选字段后调用。实时复核模板和字段构造能力,并自动查重本人近30天钉钉/薪福通发票。若返回缺失或无效字段,Agent必须继续询问;若为 client_only 则停止 API 构造。重复风险或查重不完整时不生成confirmationId,必须展示结果并询问用户;只有用户明确接受后才以allowDuplicateInvoices=true重试准备。成功预览仍需另行获得最终提交确认。", inputSchema: submissionInputSchema, annotations: { readOnlyHint: true, destructiveHint: false }, }, async ({ templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => { const result = await unified.prepareSubmission(user, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }); const summary = result.confirmationId ? "审批尚未提交;已生成待用户确认的完整预览。" : "审批尚未提交;发票查重需要用户决定,本次未生成提交确认。"; return compactResult(summary, result); }); server.registerTool("approval_submit", { title: "正式发起审批", description: "仅在用户看过approval_prepare_submission最终预览并明确确认后调用。必须原样重传相同表单内容、allowDuplicateInvoices决定和一次性confirmationId;服务端再次校验当前登录身份、模板和发票预留。薪福通流程可能进入待审批,也可能按企业规则即时完成并返回completed;两者都禁止自动重试。", inputSchema: { confirmationId: z.string().min(20).max(4096).describe("approval_prepare_submission返回的一次性确认引用"), ...submissionInputSchema, }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, }, async ({ confirmationId, templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }) => { try { const result = await unified.submitApproval(user, confirmationId, { templateId, values, deptId, targetSelectActioners, allowDuplicateInvoices }); audit?.(user.userId, "submit_approval", confirmationId, "submitted"); return compactResult( result.status === "completed" ? "审批已发起并按企业流程规则即时完成。" : "审批已正式提交。", result, ); } catch (error) { audit?.(user.userId, "submit_approval", confirmationId, "failed_or_uncertain"); throw error; } }); server.registerTool("approval_revoke_submission", { title: "撤销本人发起的审批", description: "仅在用户明确要求撤销时,使用 approval_submit 返回的用户绑定 revokeId 撤销本人刚通过 MCP 发起的审批。身份由服务器确定,禁止自动重试。钉钉撤销已实现;薪福通 recall 在当前企业仍属实验能力,真实调用可能返回 XFTSYS9999,必须以写后状态复核为准。", inputSchema: { revokeId: z.string().min(20).max(4096).describe("approval_submit 返回的短期、用户绑定撤销引用"), remark: z.string().max(1000).default("").describe("可选撤销原因"), }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, }, async ({ revokeId, remark }) => { try { const result = await unified.revokeSubmission(user, revokeId, remark); audit?.(user.userId, "revoke_submission", revokeId, String(result.status)); return compactResult( result.status === "revoked" ? "审批已撤销。" : result.status === "outcome_uncertain" ? "撤销结果暂不确定;禁止自动重试,请稍后查询状态。" : "审批未确认撤销成功;禁止自动重试。", result, ); } catch (error) { audit?.(user.userId, "revoke_submission", revokeId, "failed_or_uncertain"); throw error; } }); server.registerTool("approval_get_submission_status", { title: "核验本人已发起审批状态", description: "只读核验当前登录用户本人已发起的薪福通单据状态。服务端先用本人员工号实时验证归属,再返回精简状态;仍可撤回时签发新的短期revokeId,用于服务重启或原引用遗失后的安全恢复;不返回原始单据正文。", inputSchema: { source: z.literal("xft"), businessId: z.string().regex(/^\d{10,20}$/).describe("approval_submit 返回的薪福通 businessId"), }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async ({ source, businessId }) => compactResult( "已实时核验本人已发起单据状态。", await unified.getSubmissionStatus(user, source, businessId), )); return server; } export async function startHttpServer(config: AppConfig): Promise { if (config.appMode !== "gateway") throw new Error("HTTP 服务只允许以 APP_MODE=gateway 启动"); const dingTalk = new DingTalkClient(config); const service = new ApprovalService(dingTalk, config); const xft = config.xftOpenApi ? new XftOpenApiClient(config) : undefined; const invoiceDedupStore = new InvoiceDedupStore(invoiceDedupDatabasePath(config), config.approvalRefSecret); const invoiceDedup = new InvoiceDeduplicationService(config, invoiceDedupStore, service, xft); const app = express(); // The Node process only listens on loopback, while Nginx preserves the // public Host header. Keep DNS-rebinding protection and explicitly allow // both the configured public hostname and local health checks. app.use(hostHeaderValidation(mcpAllowedHosts(config.publicBaseUrl))); // The gateway only listens on loopback and is reached through the local // Nginx reverse proxy. Trust exactly that hop so OAuth rate limiting uses // the real client IP without accepting spoofed forwarding headers directly. app.set("trust proxy", "loopback"); // Install exactly one JSON parser so the advertised 1MB limit is the real // limit; createMcpExpressApp installs an earlier 100KB parser. app.use(express.json({ limit: "1mb" })); app.use(express.urlencoded({ extended: false })); const mcpUrl = new URL("/mcp", config.publicBaseUrl); const oauth = config.remoteAuth ? new DingTalkMcpOAuthProvider(config) : undefined; const officialOaReturns: OfficialOaReturnClient | undefined = undefined; if (!oauth) app.use(loopbackOnly); if (oauth && config.remoteAuth) { app.use(mcpAuthRouter({ provider: oauth, issuerUrl: new URL(config.remoteAuth.issuerUrl), baseUrl: new URL(config.remoteAuth.issuerUrl), resourceServerUrl: mcpUrl, scopesSupported: ["mcp:tools"], resourceName: "钉钉审批 MCP", })); app.get("/oauth/consent", (req, res) => { try { const loginState = queryString(req.query.login_state); if (!loginState) { res.status(400).send("授权请求缺少状态参数"); return; } const details = oauth.consentDetails(loginState); setOAuthHtmlHeaders(res); res.type("html").send(renderConsentPage(loginState, details)); } catch { setOAuthHtmlHeaders(res); res.status(400).type("html").send("授权失败

授权请求无效或已过期,请返回 Agent 重试。

"); } }); app.post("/oauth/consent", (req, res) => { try { const loginState = bodyString(req.body, "login_state"); const decision = bodyString(req.body, "decision"); if (!loginState || decision !== "approve") { res.status(400).send("授权确认无效"); return; } res.redirect(302, oauth.approveConsent(loginState)); } catch { res.status(400).send("授权请求无效或已过期,请返回 Agent 重试"); } }); app.get("/oauth/dingtalk/callback", async (req, res) => { try { const code = queryString(req.query.code); const state = queryString(req.query.state); if (!code || !state) { res.status(400).send("登录回调缺少 code 或 state"); return; } res.redirect(302, await oauth.completeDingTalkLogin(code, state)); } catch (error) { console.error("[dingtalk-approval-mcp] OAuth callback failed", error); res.status(400).send("钉钉登录失败或已过期,请返回 Agent 后重试"); } }); app.post("/oauth/device/authorize", (req, res) => { try { const clientId = bodyString(req.body, "client_id"); if (!clientId) { res.status(400).json({ error: "invalid_request" }); return; } const requested = bodyString(req.body, "scope")?.split(/\s+/).filter(Boolean) ?? ["mcp:tools"]; if (requested.some((scope) => scope !== "mcp:tools")) { res.status(400).json({ error: "invalid_scope" }); return; } const resource = bodyString(req.body, "resource"); if (resource && resource !== mcpUrl.toString()) { res.status(400).json({ error: "invalid_target" }); return; } res.setHeader("Cache-Control", "no-store"); res.json(oauth.startDeviceAuthorization(clientId, requested, resource ?? mcpUrl.toString())); } catch { res.status(400).json({ error: "invalid_client" }); } }); app.post("/oauth/device/token", (req, res) => { try { const clientId = bodyString(req.body, "client_id"); const deviceCode = bodyString(req.body, "device_code"); if (!clientId || !deviceCode) { res.status(400).json({ error: "invalid_request" }); return; } const result = oauth.exchangeDeviceCode(clientId, deviceCode); res.setHeader("Cache-Control", "no-store"); if (typeof result === "string") { res.status(400).json({ error: result }); return; } res.json(result); } catch { res.status(400).json({ error: "expired_token" }); } }); app.get("/oauth/device", (req, res) => { const userCode = queryString(req.query.user_code); if (!userCode) { res.status(400).type("html").send("钉钉审批登录

请使用 Agent 提供的完整登录链接。

"); return; } try { res.redirect(302, oauth.beginDeviceLogin(userCode)); } catch { res.status(400).type("html").send("登录失败

登录链接无效或已过期,请返回 Agent 重试。

"); } }); app.get("/oauth/device/success", (_req, res) => { res.setHeader("Cache-Control", "no-store"); res.type("html").send("登录成功

钉钉审批已授权,可以关闭此页面并返回 Agent。

"); }); } const authMiddleware = oauth ? requireBearerAuth({ verifier: oauth, requiredScopes: ["mcp:tools"], resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl), }) : undefined; const transports = new Map(); let pendingTransportSessions = 0; const pendingTransportSessionsByUser = new Map(); const closeTransport = (id: string, entry: TransportEntry) => { transports.delete(id); void entry.transport.close().catch(() => undefined); }; const cleanupTransports = () => { const now = Date.now(); for (const [id, entry] of transports) { if (entry.lastSeenAt < now - MCP_SESSION_IDLE_TTL_MS || entry.createdAt < now - MCP_SESSION_MAX_AGE_MS) { closeTransport(id, entry); } } }; const sessionCapacityAvailable = (userId: string) => transports.size + pendingTransportSessions < MCP_SESSION_MAX_TOTAL && [...transports.values()].filter((entry) => entry.userId === userId).length + (pendingTransportSessionsByUser.get(userId) ?? 0) < MCP_SESSION_MAX_PER_USER; const reserveSession = (userId: string) => { pendingTransportSessions += 1; pendingTransportSessionsByUser.set(userId, (pendingTransportSessionsByUser.get(userId) ?? 0) + 1); }; const releaseSessionReservation = (userId: string) => { pendingTransportSessions = Math.max(0, pendingTransportSessions - 1); const remaining = Math.max(0, (pendingTransportSessionsByUser.get(userId) ?? 1) - 1); if (remaining === 0) pendingTransportSessionsByUser.delete(userId); else pendingTransportSessionsByUser.set(userId, remaining); }; const transportCleanupTimer = setInterval(cleanupTransports, 10 * 60_000); transportCleanupTimer.unref(); const mcpHandler = async (req: Request, res: Response) => { let reservedUserId: string | undefined; try { cleanupTransports(); const user = requestUser(config, oauth, req.auth); const sessionId = headerString(req.headers["mcp-session-id"]); let entry = sessionId ? transports.get(sessionId) : undefined; if (entry && entry.kind !== "streamable") { res.status(400).json({ error: "Invalid MCP session transport" }); return; } if (entry && entry.userId !== user.userId) { res.status(403).json({ error: "MCP session belongs to another authenticated user" }); return; } if (entry) entry.lastSeenAt = Date.now(); if (!entry && req.method === "POST" && !sessionId && isInitializeRequest(req.body)) { if (!sessionCapacityAvailable(user.userId)) { res.status(429).json({ error: "MCP session limit reached; close an old Agent session and retry" }); return; } reserveSession(user.userId); reservedUserId = user.userId; const createdAt = Date.now(); const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, { kind: "streamable", transport, userId: user.userId, createdAt, lastSeenAt: Date.now() }); }, }); transport.onclose = () => { if (transport.sessionId) transports.delete(transport.sessionId); }; entry = { kind: "streamable", transport, userId: user.userId, createdAt, lastSeenAt: createdAt }; await createApprovalMcpServer( config, service, xft, undefined, officialOaReturns, user, oauth ? (userId, action, reference, outcome) => oauth.audit(userId, action, reference, outcome) : undefined, invoiceDedup, ).connect(transport); } if (!entry || entry.kind !== "streamable") { res.status(sessionId ? 404 : 400).json({ error: "Missing or invalid MCP session" }); return; } await entry.transport.handleRequest(req, res, req.body); } catch (error) { sendServerError(res, error); } finally { if (reservedUserId) releaseSessionReservation(reservedUserId); } }; app.all("/mcp", ...(authMiddleware ? [authMiddleware, mcpHandler] : [mcpHandler])); const sseHandler = async (req: Request, res: Response) => { const user = requestUser(config, oauth, req.auth); cleanupTransports(); if (!sessionCapacityAvailable(user.userId)) { res.status(429).send("MCP session limit reached"); return; } const transport = new SSEServerTransport("/messages", res); const createdAt = Date.now(); transports.set(transport.sessionId, { kind: "sse", transport, userId: user.userId, createdAt, lastSeenAt: createdAt }); res.on("close", () => transports.delete(transport.sessionId)); await createApprovalMcpServer(config, service, xft, undefined, officialOaReturns, user, undefined, invoiceDedup).connect(transport); }; app.get("/sse", ...(authMiddleware ? [authMiddleware, sseHandler] : [sseHandler])); const messagesHandler = async (req: Request, res: Response) => { const user = requestUser(config, oauth, req.auth); const entry = transports.get(String(req.query.sessionId ?? "")); if (!entry || entry.kind !== "sse") { res.status(404).send("Invalid SSE session"); return; } if (entry.userId !== user.userId) { res.status(403).send("Invalid SSE session"); return; } entry.lastSeenAt = Date.now(); await entry.transport.handlePostMessage(req, res, req.body); }; app.post("/messages", ...(authMiddleware ? [authMiddleware, messagesHandler] : [messagesHandler])); app.get("/healthz", (_req, res) => res.json({ status: "ok", mode: config.appMode })); await new Promise((resolve, reject) => { const server = app.listen(config.port, config.bindHost ?? "127.0.0.1", () => resolve()); server.on("error", reject); }); console.error(`[dingtalk-approval-mcp] listening on :${config.port}; MCP endpoint ${mcpUrl}`); } export function mcpAllowedHosts(publicBaseUrl: string): string[] { return [...new Set([new URL(publicBaseUrl).hostname, "127.0.0.1", "localhost", "[::1]"])]; } function requestUser( config: AppConfig, oauth: DingTalkMcpOAuthProvider | undefined, auth: AuthInfo | undefined, ): AuthenticatedDingTalkUser { if (oauth) { if (!auth) throw new Error("MCP request is not authenticated"); return oauth.userFromAuth(auth); } if (!config.trustedLocalUser) throw new Error("本机兼容模式缺少固定用户"); return config.trustedLocalUser; } function queryString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function bodyString(value: unknown, key: string): string | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; return queryString((value as Record)[key]); } function setOAuthHtmlHeaders(res: Response): void { res.setHeader("Cache-Control", "no-store"); res.setHeader("Content-Security-Policy", oauthHtmlContentSecurityPolicy()); res.setHeader("Referrer-Policy", "no-referrer"); res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "DENY"); } export function oauthHtmlContentSecurityPolicy(): string { return `default-src 'none'; style-src 'unsafe-inline'; form-action 'self' ${DINGTALK_OAUTH_AUTHORIZE_ORIGIN}; frame-ancestors 'none'`; } function renderConsentPage( loginState: string, details: { clientName: string; scopes: string[]; redirectOrigin?: string; userCode?: string }, ): string { const rows = [ ["请求客户端", details.clientName], ...(details.redirectOrigin ? [["授权后返回", details.redirectOrigin]] : []), ...(details.userCode ? [["设备授权码", details.userCode]] : []), ["权限范围", details.scopes.join(" ") || "mcp:tools"], ].map(([label, value]) => `
${htmlEscape(label!)}
${htmlEscape(value!)}
`).join(""); return `确认钉钉审批授权

确认钉钉审批授权

只有当你刚刚要求当前 Agent 使用钉钉审批时才继续。客户端名称由客户端自行声明,请同时核对返回域名或设备授权码。

${rows}
`; } function htmlEscape(value: string): string { return value.replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'", })[character]!); } function loopbackOnly(req: Request, res: Response, next: NextFunction): void { if (!isLoopbackAddress(req.socket.remoteAddress)) { res.status(403).json({ error: "Local MCP is available only on loopback" }); return; } next(); } export function isLoopbackAddress(value: string | undefined): boolean { return value === "127.0.0.1" || value === "::1" || value?.startsWith("::ffff:127.") === true; } function headerString(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; } export function compactResult(summary: string, value: unknown, images?: XftChallengeProvider) { const visibleValue = agentVisibleResult(value); const serialized = JSON.stringify(visibleValue); const content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> = [ { type: "text", text: serialized === undefined ? summary : `${summary}\n${serialized}` }, ]; const challengeId = findAuthChallengeId(value); const image = challengeId ? images?.getImage?.(challengeId) : undefined; if (image) { content.push({ type: "image", data: image.toString("base64"), mimeType: "image/png" }); const textBlock = content[0] as { type: "text"; text: string }; content[0] = { type: "text", text: `${textBlock.text}\n工具不会另行推送登录内容。请把 authChallenge.directUrl(如有)原样写入当前回复,并把附带二维码直接作为当前回复附件;禁止转成 ASCII,不得只声称已生成或已推送。` }; } return { content, structuredContent: { result: visibleValue } }; } export function approvalBatchSummary(value: unknown): string { const result = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; if (result.status === "preflight_failed" || result.writesStarted === false) { const invalid = Array.isArray(result.invalidItems) ? result.invalidItems.length : 0; return `审批批次未开始,0 张已写入;发现 ${invalid} 项引用无效。请重新列出待办并让用户重新确认目标,禁止自动重试。`; } const total = typeof result.total === "number" ? result.total : 0; const succeeded = typeof result.succeeded === "number" ? result.succeeded : 0; const uncertain = typeof result.uncertain === "number" ? result.uncertain : 0; if (uncertain > 0) return `审批批次有 ${uncertain} 项已提交但结果暂无法确认;禁止自动重试,请稍后重新列出待办。逐项结果如下。`; if (succeeded === 0) return "审批批次未有任何项目处理成功,逐项原因如下。"; if (succeeded < total) return `审批批次部分成功(${succeeded}/${total}),逐项结果如下。`; return `审批批次已全部提交成功(${succeeded}/${total}),逐项结果如下。`; } function agentVisibleResult(value: unknown): unknown { if (Array.isArray(value)) return value.map(agentVisibleResult); if (!value || typeof value !== "object") return value; const hidden = new Set([ "diagnostics", "response", "taskId", "nextTaskId", "userId", "employeeId", "employeeNumber", "empId", "enterpriseUserId", "originatorUserid", "originatorDeptId", "appType", "processCode", "processInstanceId", ]); const record = value as Record; const output: Record = {}; for (const [key, child] of Object.entries(record)) { if (hidden.has(key)) continue; if (key === "billId" && record.businessId === undefined && record.businessNo === undefined) { output.businessId = agentVisibleResult(child); continue; } output[key] = agentVisibleResult(child); } return output; } function findAuthChallengeId(value: unknown): string | undefined { if (Array.isArray(value)) { for (const item of value) { const found = findAuthChallengeId(item); if (found) return found; } return undefined; } if (!value || typeof value !== "object") return undefined; const record = value as Record; if (record.provider === "xft" && record.type === "qr" && typeof record.challengeId === "string") return record.challengeId; for (const child of Object.values(record)) { const found = findAuthChallengeId(child); if (found) return found; } return undefined; } function sendServerError(res: Response, error: unknown): void { console.error("[dingtalk-approval-mcp] request failed", error); if (!res.headersSent) res.status(500).json({ error: "Internal server error" }); }