Files
dingMCP/src/bridge.ts
T
gitea-admin b7313ab74d
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
feat: support secure XFT submission attachments
2026-09-01 03:44:36 +00:00

784 lines
40 KiB
JavaScript
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.
#!/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<BridgeState["device"]>) { super("需要钉钉登录"); }
}
export class RemoteBridge {
private state: BridgeState;
private client?: Client;
private connecting?: Promise<Client>;
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<string, unknown>): Promise<any> {
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<string, unknown>): Promise<any> {
const attachments = inspectLocalSubmissionAttachments(args.attachments);
return this.call("approval_prepare_submission", {
...args,
attachments: attachments.map(localAttachmentMetadata),
});
}
async submitApproval(args: Record<string, unknown>): Promise<any> {
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<T>(work: () => Promise<T>): Promise<T> {
if (this.activeCalls < 3) this.activeCalls += 1;
else await new Promise<void>((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<string, unknown>;
try { value = await response.json() as Record<string, unknown>; }
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<Client> {
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<Client> {
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<NonNullable<BridgeState["client"]>> {
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<NonNullable<BridgeState["device"]>> {
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<string, unknown>;
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<void> {
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<string, unknown>;
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<void> {
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<string, unknown>;
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<BridgeState["device"]>) {
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<BridgeState["device"]>) {
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<ReturnType<RemoteBridge["loginChallenge"]>>;
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<string, unknown>) {
if (name !== "approval_process_batch") return undefined;
const requestedItems = Array.isArray(args.items)
? args.items.filter((item): item is Record<string, unknown> => 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<string, unknown> : {};
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<string, unknown>): 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<string, unknown> {
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<void> {
const bridge = new RemoteBridge(endpoint, resolve(statePath));
const server = createBridgeMcpServer(bridge);
await server.connect(new StdioServerTransport());
}
async function runLogin(endpoint: string, statePath: string): Promise<void> {
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; });
}