CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
654 lines
41 KiB
TypeScript
654 lines
41 KiB
TypeScript
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<void> {
|
||
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("<!doctype html><meta charset=utf-8><title>授权失败</title><p>授权请求无效或已过期,请返回 Agent 重试。</p>");
|
||
}
|
||
});
|
||
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("<!doctype html><meta charset=utf-8><title>钉钉审批登录</title><p>请使用 Agent 提供的完整登录链接。</p>");
|
||
return;
|
||
}
|
||
try { res.redirect(302, oauth.beginDeviceLogin(userCode)); }
|
||
catch { res.status(400).type("html").send("<!doctype html><meta charset=utf-8><title>登录失败</title><p>登录链接无效或已过期,请返回 Agent 重试。</p>"); }
|
||
});
|
||
app.get("/oauth/device/success", (_req, res) => {
|
||
res.setHeader("Cache-Control", "no-store");
|
||
res.type("html").send("<!doctype html><meta charset=utf-8><title>登录成功</title><p>钉钉审批已授权,可以关闭此页面并返回 Agent。</p>");
|
||
});
|
||
}
|
||
|
||
const authMiddleware = oauth ? requireBearerAuth({
|
||
verifier: oauth,
|
||
requiredScopes: ["mcp:tools"],
|
||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
|
||
}) : undefined;
|
||
|
||
const transports = new Map<string, TransportEntry>();
|
||
let pendingTransportSessions = 0;
|
||
const pendingTransportSessionsByUser = new Map<string, number>();
|
||
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<void>((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<string, unknown>)[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]) => `<dt>${htmlEscape(label!)}</dt><dd>${htmlEscape(value!)}</dd>`).join("");
|
||
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>确认钉钉审批授权</title><style>body{font:16px system-ui;max-width:42rem;margin:3rem auto;padding:0 1rem;color:#1f2328}dl{display:grid;grid-template-columns:8rem 1fr;gap:.75rem}dt{font-weight:600}dd{margin:0;overflow-wrap:anywhere}.warning{padding:1rem;background:#fff8c5;border-radius:.5rem}button{font:inherit;padding:.7rem 1.2rem;border:0;border-radius:.4rem;background:#1677ff;color:white;cursor:pointer}</style><h1>确认钉钉审批授权</h1><p class="warning">只有当你刚刚要求当前 Agent 使用钉钉审批时才继续。客户端名称由客户端自行声明,请同时核对返回域名或设备授权码。</p><dl>${rows}</dl><form method="post" action="/oauth/consent"><input type="hidden" name="login_state" value="${htmlEscape(loginState)}"><button name="decision" value="approve" type="submit">确认并前往钉钉授权</button></form></html>`;
|
||
}
|
||
|
||
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<string, unknown> : {};
|
||
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<string, unknown>;
|
||
const output: Record<string, unknown> = {};
|
||
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<string, unknown>;
|
||
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" });
|
||
}
|