Files
dingMCP/src/server.ts
T
gitea-admin ac7be79288
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
refactor: reduce approval context overhead
2026-08-18 03:25:06 +00:00

654 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;",
})[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" });
}