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
784 lines
40 KiB
JavaScript
784 lines
40 KiB
JavaScript
#!/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; });
|
||
}
|