#!/usr/bin/env node import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js"; import { isDirectEntrypoint } from "./entrypoint.js"; interface BridgeState { client?: { client_id: string; client_secret?: string }; tokens?: { access_token: string; refresh_token?: string; expires_in?: number; savedAt: number }; device?: { device_code: string; user_code: string; verification_uri_complete: string; expiresAt: number; interval: number; }; onboarding?: { schemaVersion: 1; status: "pending" | "delivered"; deliveredAt?: string; }; } export const APPROVAL_ONBOARDING_EXAMPLES = [ "帮我列出当前待处理的审批", "帮我查看我处理过或我发起的审批", "总结费用类待办,按金额和费用发生日期整理", "列出我可以发起的审批模板", "先列出待办,我确认后同意指定的几张", ] as const; class AuthRequired extends Error { constructor(readonly challenge: NonNullable) { super("需要钉钉登录"); } } export class RemoteBridge { private state: BridgeState; private client?: Client; private connecting?: Promise; private activeCalls = 0; private readonly callWaiters: Array<() => void> = []; readonly endpoint: URL; readonly origin: URL; constructor(endpoint: string, private readonly statePath: string, private readonly fetchFn: typeof fetch = fetch) { this.endpoint = normalizeEndpoint(endpoint); this.origin = new URL(this.endpoint.origin); this.state = this.loadState(); } ensureOnboardingPending(): void { const diskState = this.loadState(); this.state = diskState; if (this.state.onboarding) return; this.state.onboarding = { schemaVersion: 1, status: "pending" }; this.saveState(); } async loginChallenge() { await this.tryCompleteDeviceLogin(); if (!this.usableAccessToken()) await this.refreshAccessToken(); if (this.usableAccessToken()) { const onboarding = this.takeOnboarding(); return { authenticated: true, message: onboarding ? `已登录,可以读取审批。\n\n${onboardingMessage()}` : "已登录,可以读取审批。", ...(onboarding ? { onboarding } : {}), }; } const challenge = await this.startDeviceLogin(); return this.presentChallenge(challenge); } async call(name: string, args: Record): Promise { return this.withCallSlot(async () => { try { const client = await this.remoteClient(); return this.appendOnboarding(await client.callTool({ name, arguments: args })); } catch (error) { if (error instanceof AuthRequired) return this.authResult(error.challenge); throw error; } }); } private async withCallSlot(work: () => Promise): Promise { if (this.activeCalls < 3) this.activeCalls += 1; else await new Promise((resolve) => this.callWaiters.push(resolve)); try { return await work(); } finally { const next = this.callWaiters.shift(); if (next) next(); else this.activeCalls -= 1; } } private async remoteClient(): Promise { if (this.client && this.usableAccessToken()) return this.client; if (this.client) { const stale = this.client; this.client = undefined; try { await stale.close(); } catch { /* stale sessions are best-effort cleanup */ } } if (this.connecting) return this.connecting; this.connecting = this.connectRemote(); try { return await this.connecting; } finally { this.connecting = undefined; } } private async connectRemote(allowRefreshRetry = true): Promise { await this.tryCompleteDeviceLogin(); if (!this.usableAccessToken()) await this.refreshAccessToken(); if (!this.usableAccessToken()) throw new AuthRequired(await this.startDeviceLogin()); const client = new Client({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.8" }); const transport = new StreamableHTTPClientTransport(this.endpoint, { requestInit: { headers: { authorization: `Bearer ${this.state.tokens!.access_token}` } }, }); try { await client.connect(transport); } catch (error) { // A gateway restart, reverse-proxy reload or short network outage must // not destroy an otherwise refreshable login session. if (!isAuthenticationFailure(error)) { throw new Error("审批网关暂时无法连接,已保留登录状态,请稍后重试", { cause: error }); } const refreshToken = this.state.tokens?.refresh_token; if (refreshToken && allowRefreshRetry) { this.state.tokens = { access_token: "", refresh_token: refreshToken, expires_in: 0, savedAt: 0 }; this.saveState(); await this.refreshAccessToken(); if (this.usableAccessToken()) return this.connectRemote(false); } this.state.tokens = undefined; this.saveState(); throw new AuthRequired(await this.startDeviceLogin()); } this.client = client; return client; } private usableAccessToken(): boolean { const token = this.state.tokens; return Boolean(token?.access_token && token.savedAt + (token.expires_in ?? 900) * 1000 > Date.now() + 30_000); } private async ensureClient(): Promise> { if (this.state.client?.client_id) return this.state.client; const response = await this.fetchFn(new URL("/register", this.origin), { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ client_name: "DingTalk Approval stdio bridge", redirect_uris: ["https://localhost.invalid/dingtalk-approval-device"], token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], }), }); const value = await response.json() as { client_id?: string; client_secret?: string }; if (!response.ok || !value.client_id) throw new Error("远程 MCP 拒绝注册兼容桥客户端"); this.state.client = { client_id: value.client_id, ...(value.client_secret ? { client_secret: value.client_secret } : {}) }; this.saveState(); return this.state.client; } private async startDeviceLogin(): Promise> { if (this.state.device && this.state.device.expiresAt > Date.now()) return this.state.device; const client = await this.ensureClient(); const response = await this.fetchFn(new URL("/oauth/device/authorize", this.origin), { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ client_id: client.client_id, scope: "mcp:tools", resource: this.endpoint.toString() }), }); const value = await response.json() as Record; if (!response.ok || typeof value.device_code !== "string" || typeof value.verification_uri_complete !== "string") { throw new Error("远程 MCP 无法创建钉钉登录链接"); } this.state.device = { device_code: value.device_code, user_code: String(value.user_code ?? ""), verification_uri_complete: value.verification_uri_complete, expiresAt: Date.now() + Number(value.expires_in ?? 600) * 1000, interval: Number(value.interval ?? 5), }; this.saveState(); return this.state.device; } private async tryCompleteDeviceLogin(): Promise { const device = this.state.device; const client = this.state.client; if (!device || !client || device.expiresAt <= Date.now()) { if (device) { this.state.device = undefined; this.saveState(); } return; } const response = await this.fetchFn(new URL("/oauth/device/token", this.origin), { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ client_id: client.client_id, device_code: device.device_code }), }); const value = await response.json() as Record; if (!response.ok) { if (value.error === "authorization_pending" || value.error === "slow_down") return; if (isExplicitOAuthInvalidation(response.status, value)) { this.state.device = undefined; this.saveState(); } return; } if (typeof value.access_token !== "string") return; this.state.tokens = { access_token: value.access_token, ...(typeof value.refresh_token === "string" ? { refresh_token: value.refresh_token } : {}), expires_in: Number(value.expires_in ?? 900), savedAt: Date.now(), }; this.state.device = undefined; this.saveState(); } private async refreshAccessToken(): Promise { const diskState = this.loadState(); if ((diskState.tokens?.savedAt ?? 0) > (this.state.tokens?.savedAt ?? 0)) { this.state.tokens = diskState.tokens; if (diskState.client?.client_id) this.state.client = diskState.client; if (this.usableAccessToken()) return; } const refreshToken = this.state.tokens?.refresh_token; const client = this.state.client; if (!refreshToken || !client) return; const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: client.client_id }); const response = await this.fetchFn(new URL("/token", this.origin), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body, }); const value = await response.json() as Record; if (!response.ok || typeof value.access_token !== "string") { // Another bridge process may have rotated the single-use refresh token // while this request was in flight. Prefer its newer saved state. const latest = this.loadState(); if ((latest.tokens?.savedAt ?? 0) > (this.state.tokens?.savedAt ?? 0)) { this.state.tokens = latest.tokens; if (latest.client?.client_id) this.state.client = latest.client; if (this.usableAccessToken()) return; } if (isExplicitOAuthInvalidation(response.status, value)) { this.state.tokens = undefined; this.saveState(); return; } throw new Error("审批网关暂时无法刷新登录,已保留登录状态,请稍后重试"); } this.state.tokens = { access_token: value.access_token, refresh_token: typeof value.refresh_token === "string" ? value.refresh_token : refreshToken, expires_in: Number(value.expires_in ?? 900), savedAt: Date.now(), }; this.saveState(); } private presentChallenge(challenge: NonNullable) { const directUrl = challenge.verification_uri_complete; const expiresAt = new Date(challenge.expiresAt).toISOString(); const message = [ "需要钉钉授权。系统不会另行推送或弹出登录链接。", `请直接打开:[打开钉钉授权登录](${directUrl})`, `备用原始链接:${directUrl}`, ...(challenge.user_code ? [`授权码:${challenge.user_code}`] : []), `链接有效期至:${expiresAt}`, "链接过期后让 Agent 重新获取即可,无需重新安装客户端。", "Agent 必须在当前回复正文中原样显示以上链接,不得只说“已生成”或“已推送”。", "登录完成后重新调用原工具。", ].join("\n"); return { authenticated: false, authChallenge: { provider: "dingtalk", type: "link", directUrl, userCode: challenge.user_code, expiresAt, pollAfterSeconds: challenge.interval, }, // Some stdio MCP hosts render only content.text and discard // structuredContent. Keep the structured challenge for capable agents, // but always put the short-lived URL in the portable text fallback. message, }; } private authResult(challenge: NonNullable) { return bridgeAuthToolResult(this.presentChallenge(challenge)); } private takeOnboarding() { const diskState = this.loadState(); if (diskState.onboarding?.status === "delivered") { this.state.onboarding = diskState.onboarding; return undefined; } if (diskState.onboarding?.status === "pending") this.state.onboarding = diskState.onboarding; if (this.state.onboarding?.status !== "pending") return undefined; const onboarding = { firstRun: true as const, examples: [...APPROVAL_ONBOARDING_EXAMPLES], writeSafety: "审批写操作只会在用户明确指定动作和目标后执行。", }; this.state.onboarding = { schemaVersion: 1, status: "delivered", deliveredAt: new Date().toISOString() }; this.saveState(); return onboarding; } private appendOnboarding(result: any): any { if (result?.isError === true) return result; const onboarding = this.takeOnboarding(); if (!onboarding) return result; const record = result && typeof result === "object" && !Array.isArray(result) ? result : {}; const content = Array.isArray(record.content) ? [...record.content] : []; content.push({ type: "text", text: onboardingMessage() }); const structured = record.structuredContent && typeof record.structuredContent === "object" && !Array.isArray(record.structuredContent) ? record.structuredContent : {}; return { ...record, content, structuredContent: { ...structured, onboarding } }; } private loadState(): BridgeState { if (!existsSync(this.statePath)) return {}; try { return JSON.parse(readFileSync(this.statePath, "utf8")) as BridgeState; } catch { return {}; } } private saveState(): void { mkdirSync(dirname(this.statePath), { recursive: true, mode: 0o700 }); chmodSync(dirname(this.statePath), 0o700); const temporary = `${this.statePath}.tmp-${process.pid}`; writeFileSync(temporary, JSON.stringify(this.state), { mode: 0o600 }); chmodSync(temporary, 0o600); renameSync(temporary, this.statePath); } } type BridgeLoginResult = Awaited>; function onboardingMessage(): string { return [ "首次登录已完成。你可以直接这样使用钉钉审批:", ...APPROVAL_ONBOARDING_EXAMPLES.map((example) => `- “${example}”`), "审批写操作只会在你明确指定动作和目标后执行。", ].join("\n"); } export function bridgeAuthToolResult(result: BridgeLoginResult) { const content: Array< { type: "text"; text: string } | { type: "resource_link"; name: string; title: string; uri: string; description: string } > = [{ type: "text", text: result.message }]; if ("authChallenge" in result) { content.push({ type: "resource_link", name: "dingtalk-approval-login", title: "打开钉钉授权登录", uri: result.authChallenge.directUrl, description: "当前用户的短期钉钉授权登录链接", }); } return { content, structuredContent: { result } }; } function isAuthenticationFailure(error: unknown): boolean { const record = error && typeof error === "object" ? error as Record : {}; const status = Number(record.status ?? record.statusCode ?? record.code); if (status === 401 || status === 403) return true; const message = error instanceof Error ? error.message : String(error); return /^(?:MCP error[^:]*:\s*)?(?:Unauthorized|Forbidden|Invalid[_ -]?token)\b/i.test(message) || /\bHTTP\s+(?:401|403)\b/i.test(message); } function isExplicitOAuthInvalidation(status: number, value: Record): boolean { const code = typeof value.error === "string" ? value.error : ""; return status >= 400 && status < 500 && ["invalid_grant", "invalid_client", "unauthorized_client", "access_denied", "expired_token"].includes(code); } function normalizeEndpoint(value: string): URL { const url = new URL(value); if (url.protocol !== "https:" || url.pathname !== "/mcp") throw new Error("远程 MCP 必须是 HTTPS /mcp 地址"); return url; } function parseEndpoint(argv: string[]): string { const index = argv.indexOf("--url"); return (index >= 0 ? argv[index + 1] : undefined) ?? process.env.DINGTALK_APPROVAL_MCP_URL ?? ""; } export function createBridgeMcpServer(bridge: RemoteBridge): McpServer { const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.8" }, { instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。", }); server.registerTool("approval_login", { title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false }, }, async () => { const result = await bridge.loginChallenge(); return bridgeAuthToolResult(result); }); server.registerTool("approval_list_todos", { title: "列出我的审批待办", description: "实时列出当前登录用户本人的钉钉OA和薪福通待办。", 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 (args) => bridge.call("approval_list_todos", args)); server.registerTool("approval_list_history", { title: "列出我的审批历史", description: "实时列出当前用户处理过或本人发起的钉钉OA和薪福通审批;默认最近30天,可指定最长120天的时间窗口;结果仅用于读取。", 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(上海时区)或带时区 ISO 8601;默认最近30天"), endDate: z.string().max(40).default("").describe("可选且包含结束日;单次跨度最多120天"), }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async (args) => bridge.call("approval_list_history", args)); server.registerTool("approval_get_detail", { title: "获取审批详情", description: "读取待办或历史列表返回的审批引用对应的实时详情。", inputSchema: { approvalId: z.string().min(20).max(4096), 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 (args) => bridge.call("approval_get_detail", args)); server.registerTool("approval_get_actions", { title: "获取审批可执行动作", description: "实时检查同意、拒绝和退回能力。", inputSchema: { approvalId: z.string().min(20).max(4096) }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async (args) => bridge.call("approval_get_actions", args)); server.registerTool("approval_process_batch", { title: "处理审批", description: "仅在用户明确授权后处理本人待办;在授权范围内按Schema容量内部调用,不向用户解释引用、容量或分批方式。写前校验整批引用,任一无效则零写入,不自动重试。", inputSchema: { action: z.enum(["approve", "reject", "return"]), remark: z.string().max(1000).default(""), selectionId: z.string().min(20).max(4096).optional(), items: z.array(z.object({ approvalId: z.string().min(20).max(4096), targetId: z.string().min(20).max(4096).optional() })) .max(APPROVAL_BATCH_LIMIT, `一次最多处理 ${APPROVAL_BATCH_LIMIT} 条审批`).default([]), }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, }, async (args) => bridge.call("approval_process_batch", args)); server.registerTool("approval_list_submission_templates", { title: "列出发起审批候选模板", description: "实时列出候选模板;是否可由 API 构造需继续读取 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 (args) => bridge.call("approval_list_submission_templates", args)); server.registerTool("approval_get_submission_schema", { title: "获取发起审批表单结构", description: "实时读取模板字段、必填项、复杂字段格式和 API 构造能力。", inputSchema: { templateId: z.string().min(20).max(4096), }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async (args) => bridge.call("approval_get_submission_schema", args)); server.registerTool("approval_get_submission_options", { title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或搜索城市编码。", 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 (args) => bridge.call("approval_get_submission_options", args)); const submissionInputSchema = { templateId: z.string().min(20).max(4096), values: z.record(z.string().min(1).max(200), z.unknown()), deptId: z.number().int().default(-1).describe("钉钉发起部门;默认-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("仅在网关已提示重复风险或查重不完整、且用户明确确认仍要提交后设为 true"), }; server.registerTool("approval_prepare_submission", { title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema, annotations: { readOnlyHint: true, destructiveHint: false }, }, async (args) => bridge.call("approval_prepare_submission", args)); server.registerTool("approval_submit", { title: "正式发起审批", description: "仅在用户看过最终预览并明确确认后,使用完全相同内容和重复风险决定发起审批;禁止自动重试。", inputSchema: { confirmationId: z.string().min(20).max(4096), ...submissionInputSchema, }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, }, async (args) => bridge.call("approval_submit", args)); server.registerTool("approval_revoke_submission", { title: "撤销本人发起的审批", description: "使用提单成功后返回的 revokeId 撤销本人发起的审批;禁止自动重试。薪福通 recall 当前为实验能力,必须以写后状态复核为准。", inputSchema: { revokeId: z.string().min(20).max(4096), remark: z.string().max(1000).default(""), }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, }, async (args) => bridge.call("approval_revoke_submission", args)); server.registerTool("approval_get_submission_status", { title: "核验本人已发起审批状态", description: "只读核验本人发起的薪福通单据状态;仍可撤回时返回新的短期revokeId。", inputSchema: { source: z.literal("xft"), businessId: z.string().regex(/^\d{10,20}$/), }, annotations: { readOnlyHint: true, destructiveHint: false }, }, async (args) => bridge.call("approval_get_submission_status", args)); return server; } export async function startBridge(endpoint: string, statePath = process.env.DINGTALK_APPROVAL_BRIDGE_STATE ?? join(homedir(), ".config", "dingtalk-approval-bridge", "session.json")): Promise { const bridge = new RemoteBridge(endpoint, resolve(statePath)); const server = createBridgeMcpServer(bridge); await server.connect(new StdioServerTransport()); } async function runLogin(endpoint: string, statePath: string): Promise { syncBundledSkillForLegacyUpdate(statePath); const bridge = new RemoteBridge(endpoint, resolve(statePath)); bridge.ensureOnboardingPending(); process.stdout.write(`${JSON.stringify(await bridge.loginChallenge())}\n`); } export function syncBundledSkillForLegacyUpdate( statePath: string, bundled = resolve(dirname(fileURLToPath(import.meta.url)), "../../skill/dingtalk-approval"), ): void { const resolvedState = resolve(statePath); if (basename(resolvedState) !== "session.json" || basename(dirname(resolvedState)) !== "state") return; if (!existsSync(join(bundled, "SKILL.md")) || !existsSync(join(bundled, "references", "create-approval.md"))) return; const target = resolve(dirname(resolvedState), "../skill/dingtalk-approval"); const parent = dirname(target); mkdirSync(parent, { recursive: true, mode: 0o700 }); const staging = join(parent, `.dingtalk-approval.staging-${process.pid}`); const backup = join(parent, `.dingtalk-approval.backup-${process.pid}`); rmSync(staging, { recursive: true, force: true }); rmSync(backup, { recursive: true, force: true }); try { cpSync(bundled, staging, { recursive: true }); if (existsSync(target)) renameSync(target, backup); renameSync(staging, target); rmSync(backup, { recursive: true, force: true }); } catch (error) { if (!existsSync(target) && existsSync(backup)) renameSync(backup, target); rmSync(staging, { recursive: true, force: true }); throw error; } } if (isDirectEntrypoint(import.meta.url, process.argv[1])) { const args = process.argv.slice(2); const endpoint = parseEndpoint(args); const statePath = process.env.DINGTALK_APPROVAL_BRIDGE_STATE ?? join(homedir(), ".config", "dingtalk-approval-bridge", "session.json"); const task = args.includes("--login") || args.includes("--post-install-login") ? runLogin(endpoint, statePath) : startBridge(endpoint, statePath); task.catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); }