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
535 lines
27 KiB
JavaScript
535 lines
27 KiB
JavaScript
#!/usr/bin/env node
|
|
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { basename, dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
import { z } from "zod";
|
|
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
|
|
import { isDirectEntrypoint } from "./entrypoint.js";
|
|
|
|
interface BridgeState {
|
|
client?: { client_id: string; client_secret?: string };
|
|
tokens?: { access_token: string; refresh_token?: string; expires_in?: number; savedAt: number };
|
|
device?: {
|
|
device_code: string;
|
|
user_code: string;
|
|
verification_uri_complete: string;
|
|
expiresAt: number;
|
|
interval: number;
|
|
};
|
|
onboarding?: {
|
|
schemaVersion: 1;
|
|
status: "pending" | "delivered";
|
|
deliveredAt?: string;
|
|
};
|
|
}
|
|
|
|
export const APPROVAL_ONBOARDING_EXAMPLES = [
|
|
"帮我列出当前待处理的审批",
|
|
"帮我查看我处理过或我发起的审批",
|
|
"总结费用类待办,按金额和费用发生日期整理",
|
|
"列出我可以发起的审批模板",
|
|
"先列出待办,我确认后同意指定的几张",
|
|
] as const;
|
|
|
|
class AuthRequired extends Error {
|
|
constructor(readonly challenge: NonNullable<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();
|
|
return this.appendOnboarding(await client.callTool({ name, arguments: args }));
|
|
} catch (error) {
|
|
if (error instanceof AuthRequired) return this.authResult(error.challenge);
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
|
|
private async withCallSlot<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 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: "1.1.0-rc.8" });
|
|
const transport = new StreamableHTTPClientTransport(this.endpoint, {
|
|
requestInit: { headers: { authorization: `Bearer ${this.state.tokens!.access_token}` } },
|
|
});
|
|
try { await client.connect(transport); }
|
|
catch (error) {
|
|
// A gateway restart, reverse-proxy reload or short network outage must
|
|
// not destroy an otherwise refreshable login session.
|
|
if (!isAuthenticationFailure(error)) {
|
|
throw new Error("审批网关暂时无法连接,已保留登录状态,请稍后重试", { cause: error });
|
|
}
|
|
const refreshToken = this.state.tokens?.refresh_token;
|
|
if (refreshToken && allowRefreshRetry) {
|
|
this.state.tokens = { access_token: "", refresh_token: refreshToken, expires_in: 0, savedAt: 0 };
|
|
this.saveState();
|
|
await this.refreshAccessToken();
|
|
if (this.usableAccessToken()) return this.connectRemote(false);
|
|
}
|
|
this.state.tokens = undefined;
|
|
this.saveState();
|
|
throw new AuthRequired(await this.startDeviceLogin());
|
|
}
|
|
this.client = client;
|
|
return client;
|
|
}
|
|
|
|
private usableAccessToken(): boolean {
|
|
const token = this.state.tokens;
|
|
return Boolean(token?.access_token && token.savedAt + (token.expires_in ?? 900) * 1000 > Date.now() + 30_000);
|
|
}
|
|
|
|
private async ensureClient(): Promise<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 } };
|
|
}
|
|
|
|
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 normalizeEndpoint(value: string): URL {
|
|
const url = new URL(value);
|
|
if (url.protocol !== "https:" || url.pathname !== "/mcp") throw new Error("远程 MCP 必须是 HTTPS /mcp 地址");
|
|
return url;
|
|
}
|
|
|
|
function parseEndpoint(argv: string[]): string {
|
|
const index = argv.indexOf("--url");
|
|
return (index >= 0 ? argv[index + 1] : undefined) ?? process.env.DINGTALK_APPROVAL_MCP_URL ?? "";
|
|
}
|
|
|
|
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
|
|
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.8" }, {
|
|
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。",
|
|
});
|
|
server.registerTool("approval_login", {
|
|
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
|
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
}, async () => {
|
|
const result = await bridge.loginChallenge();
|
|
return bridgeAuthToolResult(result);
|
|
});
|
|
server.registerTool("approval_list_todos", {
|
|
title: "列出我的审批待办", description: "实时列出当前登录用户本人的钉钉OA和薪福通待办。", inputSchema: {
|
|
source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""),
|
|
limit: z.number().int().min(1).max(200).default(50),
|
|
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
|
}, async (args) => bridge.call("approval_list_todos", args));
|
|
server.registerTool("approval_list_history", {
|
|
title: "列出我的审批历史", description: "实时列出当前用户处理过或本人发起的钉钉OA和薪福通审批;默认最近30天,可指定最长120天的时间窗口;结果仅用于读取。", inputSchema: {
|
|
scope: z.enum(["handled", "submitted"]),
|
|
source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""),
|
|
limit: z.number().int().min(1).max(200).default(50),
|
|
startDate: z.string().max(40).default("").describe("可选;YYYY-MM-DD(上海时区)或带时区 ISO 8601;默认最近30天"),
|
|
endDate: z.string().max(40).default("").describe("可选且包含结束日;单次跨度最多120天"),
|
|
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
|
}, async (args) => bridge.call("approval_list_history", args));
|
|
server.registerTool("approval_get_detail", {
|
|
title: "获取审批详情", description: "读取待办或历史列表返回的审批引用对应的实时详情。", inputSchema: {
|
|
approvalId: z.string().min(20).max(4096), view: z.enum(["summary", "expense", "raw"]).default("summary"),
|
|
rawPath: z.string().max(1024).default(""), rawCursor: z.number().int().min(0).default(0),
|
|
rawLimit: z.number().int().min(1).max(50).default(20),
|
|
rawQuery: z.string().max(200).default("").describe("raw 视图可选的有界字段搜索"),
|
|
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
|
}, async (args) => bridge.call("approval_get_detail", args));
|
|
server.registerTool("approval_get_actions", {
|
|
title: "获取审批可执行动作", description: "实时检查同意、拒绝和退回能力。", inputSchema: { approvalId: z.string().min(20).max(4096) },
|
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
}, async (args) => bridge.call("approval_get_actions", args));
|
|
server.registerTool("approval_process_batch", {
|
|
title: "处理审批", description: "仅在用户明确授权后处理本人待办;在授权范围内按Schema容量内部调用,不向用户解释引用、容量或分批方式。写前校验整批引用,任一无效则零写入,不自动重试。", inputSchema: {
|
|
action: z.enum(["approve", "reject", "return"]), remark: z.string().max(1000).default(""),
|
|
selectionId: z.string().min(20).max(4096).optional(),
|
|
items: z.array(z.object({ approvalId: z.string().min(20).max(4096), targetId: z.string().min(20).max(4096).optional() }))
|
|
.max(APPROVAL_BATCH_LIMIT, `一次最多处理 ${APPROVAL_BATCH_LIMIT} 条审批`).default([]),
|
|
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
}, async (args) => bridge.call("approval_process_batch", args));
|
|
server.registerTool("approval_list_submission_templates", {
|
|
title: "列出发起审批候选模板", description: "实时列出候选模板;是否可由 API 构造需继续读取 submissionCapability。", inputSchema: {
|
|
source: z.enum(["all", "dingtalk", "xft"]).default("all"), query: z.string().max(200).default(""),
|
|
limit: z.number().int().min(1).max(100).default(50),
|
|
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
|
}, async (args) => bridge.call("approval_list_submission_templates", args));
|
|
server.registerTool("approval_get_submission_schema", {
|
|
title: "获取发起审批表单结构", description: "实时读取模板字段、必填项、复杂字段格式和 API 构造能力。", inputSchema: {
|
|
templateId: z.string().min(20).max(4096),
|
|
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
|
}, async (args) => bridge.call("approval_get_submission_schema", args));
|
|
server.registerTool("approval_get_submission_options", {
|
|
title: "查询发起审批可用选项", description: "实时查询当前登录用户本人的薪福通承担部门,或搜索城市编码。", inputSchema: {
|
|
templateId: z.string().min(20).max(4096), kind: z.enum(["department", "city"]),
|
|
query: z.string().max(100).default(""), limit: z.number().int().min(1).max(100).default(50),
|
|
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
|
}, async (args) => bridge.call("approval_get_submission_options", args));
|
|
const submissionInputSchema = {
|
|
templateId: z.string().min(20).max(4096),
|
|
values: z.record(z.string().min(1).max(200), z.unknown()),
|
|
deptId: z.number().int().default(-1).describe("钉钉发起部门;默认-1,由网关按当前登录用户实时解析并验证"),
|
|
targetSelectActioners: z.array(z.object({
|
|
actionerKey: z.string().min(1).max(500),
|
|
actionerStaffIds: z.array(z.string().min(1).max(500)).min(1).max(50),
|
|
})).max(20).default([]),
|
|
allowDuplicateInvoices: z.boolean().default(false).describe("仅在网关已提示重复风险或查重不完整、且用户明确确认仍要提交后设为 true"),
|
|
};
|
|
server.registerTool("approval_prepare_submission", {
|
|
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
|
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
}, async (args) => bridge.call("approval_prepare_submission", args));
|
|
server.registerTool("approval_submit", {
|
|
title: "正式发起审批", description: "仅在用户看过最终预览并明确确认后,使用完全相同内容和重复风险决定发起审批;禁止自动重试。", inputSchema: {
|
|
confirmationId: z.string().min(20).max(4096), ...submissionInputSchema,
|
|
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
}, async (args) => bridge.call("approval_submit", args));
|
|
server.registerTool("approval_revoke_submission", {
|
|
title: "撤销本人发起的审批", description: "使用提单成功后返回的 revokeId 撤销本人发起的审批;禁止自动重试。薪福通 recall 当前为实验能力,必须以写后状态复核为准。", inputSchema: {
|
|
revokeId: z.string().min(20).max(4096), remark: z.string().max(1000).default(""),
|
|
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
}, async (args) => bridge.call("approval_revoke_submission", args));
|
|
server.registerTool("approval_get_submission_status", {
|
|
title: "核验本人已发起审批状态", description: "只读核验本人发起的薪福通单据状态;仍可撤回时返回新的短期revokeId。", inputSchema: {
|
|
source: z.literal("xft"), businessId: z.string().regex(/^\d{10,20}$/),
|
|
}, annotations: { readOnlyHint: true, destructiveHint: false },
|
|
}, async (args) => bridge.call("approval_get_submission_status", args));
|
|
return server;
|
|
}
|
|
|
|
export async function startBridge(endpoint: string, statePath = process.env.DINGTALK_APPROVAL_BRIDGE_STATE
|
|
?? join(homedir(), ".config", "dingtalk-approval-bridge", "session.json")): Promise<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"))) 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; });
|
|
}
|