#!/usr/bin/env node import { createHash } from "node:crypto"; import { chmodSync, closeSync, constants as fsConstants, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, extname, isAbsolute, 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"; import { APPLICATION_VERSION } from "./version.js"; import { XFT_ATTACHMENT_MAX_FILE_BYTES, XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION, XFT_ATTACHMENT_MAX_TOTAL_BYTES, } from "./xft.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; const REMOTE_WRITE_TIMEOUT_MS = 45_000; interface LocalSubmissionAttachment { fieldName: string; localPath: string; displayName?: string; size?: number; modifiedAt?: string; sha256?: string; } interface InspectedLocalAttachment { fieldName: string; fileName: string; size: number; sha256: string; content: Buffer; } 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(); try { const options = name === "approval_process_batch" ? { timeout: REMOTE_WRITE_TIMEOUT_MS } : undefined; return this.appendOnboarding(await client.callTool({ name, arguments: args }, undefined, options)); } catch (error) { const uncertain = bridgeWriteOutcomeUncertainResult(name, args); if (uncertain) return uncertain; throw error; } } catch (error) { if (error instanceof AuthRequired) return this.authResult(error.challenge); throw error; } }); } async prepareSubmission(args: Record): Promise { const attachments = inspectLocalSubmissionAttachments(args.attachments); return this.call("approval_prepare_submission", { ...args, attachments: attachments.map(localAttachmentMetadata), }); } async submitApproval(args: Record): Promise { const attachments = inspectLocalSubmissionAttachments(args.attachments); if (!attachments.length) return this.call("approval_submit", { ...args, attachments: [] }); const confirmationId = typeof args.confirmationId === "string" ? args.confirmationId : ""; if (!confirmationId) throw new Error("附件提交缺少最终确认,请重新预览并取得用户确认"); try { const uploaded = []; for (const attachment of attachments) { const result = await this.uploadSubmissionAttachment(attachment, confirmationId); uploaded.push({ ...localAttachmentMetadata(attachment), attachmentRef: result.attachmentRef }); } return this.call("approval_submit", { ...args, attachments: uploaded }); } 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 uploadSubmissionAttachment( attachment: InspectedLocalAttachment, confirmationId: string, ): Promise<{ attachmentRef: string }> { await this.tryCompleteDeviceLogin(); if (!this.usableAccessToken()) await this.refreshAccessToken(); if (!this.usableAccessToken()) throw new AuthRequired(await this.startDeviceLogin()); let response: Response; try { response = await this.fetchFn(new URL("/xft/submission-attachments", this.origin), { method: "POST", headers: { authorization: `Bearer ${this.state.tokens!.access_token}`, "content-type": "application/octet-stream", "content-length": String(attachment.size), "x-dingmcp-field-name": encodeURIComponent(attachment.fieldName), "x-dingmcp-file-name": encodeURIComponent(attachment.fileName), "x-dingmcp-file-sha256": attachment.sha256, "x-dingmcp-confirmation-id": confirmationId, }, body: attachment.content.buffer.slice( attachment.content.byteOffset, attachment.content.byteOffset + attachment.content.byteLength, ) as ArrayBuffer, signal: AbortSignal.timeout(Math.max(REMOTE_WRITE_TIMEOUT_MS, 120_000)), }); } catch (error) { throw new Error(`附件“${attachment.fileName}”上传到审批网关时连接失败;审批尚未创建,请勿自动重试`, { cause: error }); } let value: Record; try { value = await response.json() as Record; } catch { throw new Error(`附件“${attachment.fileName}”上传失败:审批网关返回了无法解析的响应`); } if (!response.ok || typeof value.attachmentRef !== "string") { const detail = typeof value.error === "string" ? value.error : `HTTP ${response.status}`; throw new Error(`附件“${attachment.fileName}”上传失败:${detail}`); } if ( value.fieldName !== attachment.fieldName || value.fileName !== attachment.fileName || value.size !== attachment.size || value.sha256 !== attachment.sha256 ) { throw new Error(`附件“${attachment.fileName}”上传响应与本地文件不一致,审批尚未创建`); } return { attachmentRef: value.attachmentRef }; } 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: APPLICATION_VERSION }); 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 } }; } export function bridgeWriteOutcomeUncertainResult(name: string, args: Record) { if (name !== "approval_process_batch") return undefined; const requestedItems = Array.isArray(args.items) ? args.items.filter((item): item is Record => Boolean(item) && typeof item === "object" && !Array.isArray(item)) : []; const selectionUsed = requestedItems.length === 0 && typeof args.selectionId === "string"; const message = "审批写请求超过 bridge 等待时间或连接中断,操作可能已经到达网关;禁止自动重试,请重新列出实时待办核对。"; const results = requestedItems.map((item, index) => ({ index: index + 1, ...(typeof item.approvalId === "string" ? { approvalId: item.approvalId } : {}), status: "outcome_uncertain", outcomeUncertain: true, success: false, message, })); const result = { action: args.action, status: "outcome_uncertain", outcomeUncertain: true, total: selectionUsed ? null : requestedItems.length, uncertain: selectionUsed ? null : requestedItems.length, ...(selectionUsed ? { targetCount: "unknown" } : {}), succeeded: 0, failed: 0, results, requiresRelist: true, retryable: false, }; return { content: [{ type: "text" as const, text: `${message}\n${JSON.stringify(result)}` }], 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 inspectLocalSubmissionAttachments(value: unknown): InspectedLocalAttachment[] { if (value === undefined) return []; if (!Array.isArray(value)) throw new Error("attachments 必须是本机附件数组"); if (value.length > XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION) { throw new Error(`一次薪福通提单最多选择 ${XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION} 个附件`); } const attachments = value.map((item) => inspectLocalSubmissionAttachment(item)); const total = attachments.reduce((sum, attachment) => sum + attachment.size, 0); if (total > XFT_ATTACHMENT_MAX_TOTAL_BYTES) throw new Error("一次薪福通提单的附件总大小不能超过 50MB"); return attachments; } function inspectLocalSubmissionAttachment(value: unknown): InspectedLocalAttachment { const attachment = isObject(value) ? value as unknown as LocalSubmissionAttachment : undefined; if (!attachment || typeof attachment.fieldName !== "string" || !attachment.fieldName.trim()) { throw new Error("附件缺少实时表单中的字段名称"); } if (typeof attachment.localPath !== "string" || !attachment.localPath.trim()) { throw new Error("附件缺少用户明确选择的本机文件"); } if (!isAbsolute(attachment.localPath)) throw new Error("附件必须使用本机绝对路径,请重新选择文件"); const resolvedPath = resolve(attachment.localPath); const fileName = basename(resolvedPath); if (attachment.displayName !== undefined && attachment.displayName !== fileName) { throw new Error(`附件“${attachment.displayName}”与当前选择的文件名不一致,请重新选择`); } if (!fileName || fileName.length > 240 || /[\r\n\0"]/u.test(fileName)) throw new Error("附件文件名无效,请重新命名后选择"); let before; let fd: number | undefined; try { before = lstatSync(resolvedPath); if (!before.isFile() || before.isSymbolicLink()) throw new Error("not a regular file"); fd = openSync(resolvedPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0)); const opened = fstatSync(fd); if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) throw new Error("file changed while opening"); if (opened.size <= 0 || opened.size > XFT_ATTACHMENT_MAX_FILE_BYTES) throw new Error("invalid file size"); const content = readFileSync(fd); const after = fstatSync(fd); if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || content.length !== opened.size) { throw new Error("file changed while reading"); } validateLocalInvoiceAttachment(fileName, content); const sha256 = createHash("sha256").update(content).digest("hex"); if (attachment.size !== undefined && attachment.size !== content.length) { throw new Error("stored size changed"); } if (attachment.sha256 !== undefined && attachment.sha256 !== sha256) { throw new Error("stored hash changed"); } return { fieldName: attachment.fieldName.trim(), fileName, size: content.length, sha256, content, }; } catch (error) { const reason = error instanceof Error ? error.message : "file validation failed"; if (reason === "invalid file type") throw new Error(`附件“${fileName}”不是支持的 PDF、OFD、JPG、PNG、XML 或 ZIP 文件`); if (reason === "invalid file size") throw new Error(`附件“${fileName}”必须大于 0 字节且单个不超过 50MB`); if (reason === "stored hash changed" || reason === "stored size changed" || reason === "file changed while reading") { throw new Error(`附件“${fileName}”自保存草稿或预览后发生变化,请重新选择并确认`); } throw new Error(`无法安全读取附件“${fileName}”;请选择未经过符号链接的普通文件`); } finally { if (fd !== undefined) closeSync(fd); } } function validateLocalInvoiceAttachment(fileName: string, content: Buffer): void { const extension = extname(fileName).toLowerCase(); const zip = content.length >= 4 && content[0] === 0x50 && content[1] === 0x4b && [[0x03, 0x04], [0x05, 0x06], [0x07, 0x08]].some(([a, b]) => content[2] === a && content[3] === b); const valid = extension === ".pdf" ? content.subarray(0, 5).toString("ascii") === "%PDF-" : extension === ".png" ? content.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) : extension === ".jpg" || extension === ".jpeg" ? content[0] === 0xff && content[1] === 0xd8 && content[2] === 0xff : extension === ".xml" ? content.subarray(0, 1024).toString("utf8").replace(/^\uFEFF?\s*/u, "").startsWith("<") : extension === ".ofd" || extension === ".zip" ? zip : false; if (!valid) throw new Error("invalid file type"); } function localAttachmentMetadata(attachment: InspectedLocalAttachment) { return { fieldName: attachment.fieldName, fileName: attachment.fileName, size: attachment.size, sha256: attachment.sha256, }; } function isObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } 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: APPLICATION_VERSION }, { instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。薪福通待办不按单据类型过滤;availableActions=[]且manualAction.reason=unsupported_xft_task_type的项目仍要展示,但只能引导薪福通客户端查看处理,禁止尝试MCP写入。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。草稿附件保存字段名称、本地路径、大小、修改时间和SHA-256;prepare只读取并复核本地文件、把不含路径的元数据用于预览,最终确认后的submit才上传,网关不保存文件。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。", }); 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: "实时读取模板字段、必填项、可读选择名称、minimalExample、复杂字段格式和 API 构造能力;minimalExample的尖括号必须替换,client_only字段不得让用户提供内部值。", 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: "实时查询当前登录用户本人的薪福通承担部门,或按名称搜索城市和费用类型。所有选项只向用户展示label;values必须填所选项的code而不能填label,code仅供工具内部提交。", inputSchema: { templateId: z.string().min(20).max(4096), kind: z.enum(["department", "city", "expense_type"]), 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()).describe("选择项必须使用所选label对应的code而不是label;code不得展示给用户"), 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"), attachments: z.array(z.object({ fieldName: z.string().min(1).max(200).describe("实时 Schema 中的附件字段业务名称"), localPath: z.string().min(1).max(4096).describe("用户明确选择的本机绝对文件路径;只由签名 Bridge 读取,不发送给网关"), displayName: z.string().min(1).max(240).optional(), size: z.number().int().min(1).max(XFT_ATTACHMENT_MAX_FILE_BYTES).optional(), modifiedAt: z.string().datetime().optional(), sha256: z.string().regex(/^[0-9a-f]{64}$/).optional(), })).max(XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION).default([]) .describe("本机草稿附件引用;prepare复核文件且不上传,用户最终确认后的submit才上传"), }; server.registerTool("approval_prepare_submission", { title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;选择项在values中填所选label对应的code,成功预览只显示业务名称;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema, annotations: { readOnlyHint: true, destructiveHint: false }, }, async (args) => bridge.prepareSubmission(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.submitApproval(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")) || !existsSync(join(bundled, "references", "local-drafts.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; }); }