refactor: reduce approval context overhead
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

This commit is contained in:
2026-08-18 03:25:06 +00:00
parent 88f221e811
commit ac7be79288
12 changed files with 136 additions and 43 deletions
+30 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
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";
@@ -488,11 +489,38 @@ export async function startBridge(endpoint: string, statePath = process.env.DING
}
async function runPostInstallLogin(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);
+24 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { createHash, createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import {
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync,
chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync,
readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
@@ -301,8 +301,7 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm
}
renameSync(staging, versionDir);
}
mkdirPrivate(paths.skillDir);
writeAtomic(paths.skillFile, skillBody, 0o600);
installBundledSkill(paths, join(versionDir, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval"), skillBody);
writeAtomic(paths.publicKeyFile, publicArtifact, 0o600);
writeLaunchers(paths, context.platform);
const state: ClientInstallState = {
@@ -590,6 +589,28 @@ function mcpConfig(paths: ClientPaths): Record<string, unknown> {
return { mcpServers: { "dingtalk-approval": { type: "stdio", command: paths.bridgeCommand, args: [] } } };
}
function installBundledSkill(paths: ClientPaths, bundledSkillDir: string, skillBody: Buffer): void {
const reference = join(bundledSkillDir, "references", "create-approval.md");
if (!existsSync(join(bundledSkillDir, "SKILL.md")) || !existsSync(reference)) {
throw new Error("客户端包缺少完整 Skill 资源");
}
const parent = dirname(paths.skillDir);
mkdirPrivate(parent);
const staging = join(parent, `.dingtalk-approval.staging-${process.pid}-${randomUUID()}`);
const backup = join(parent, `.dingtalk-approval.backup-${process.pid}-${randomUUID()}`);
try {
cpSync(bundledSkillDir, staging, { recursive: true });
writeAtomic(join(staging, "SKILL.md"), skillBody, 0o600);
if (existsSync(paths.skillDir)) renameSync(paths.skillDir, backup);
renameSync(staging, paths.skillDir);
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
} catch (error) {
if (!existsSync(paths.skillDir) && existsSync(backup)) renameSync(backup, paths.skillDir);
if (existsSync(staging)) rmSync(staging, { recursive: true, force: true });
throw error;
}
}
function writeLaunchers(paths: ClientPaths, platform: NodeJS.Platform): void {
mkdirPrivate(paths.binDir);
const launcher = `#!/usr/bin/env node\nimport { readFileSync } from "node:fs";\nimport { dirname, join, resolve } from "node:path";\nimport { fileURLToPath } from "node:url";\nimport { spawn } from "node:child_process";\nconst mode = process.argv[2];\nconst root = resolve(dirname(fileURLToPath(import.meta.url)), "..");\nconst state = JSON.parse(readFileSync(join(root, "install-state.json"), "utf8"));\nconst runtime = resolve(root, state.runtimeDir);\nconst target = join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", mode === "bridge" ? "bridge.js" : "client-installer.js");\nconst args = mode === "bridge" ? ["--url", state.gatewayUrl, ...process.argv.slice(3)] : [...process.argv.slice(3), "--app-root", root];\nconst env = mode === "bridge" ? { ...process.env, DINGTALK_APPROVAL_BRIDGE_STATE: join(root, "state", "session.json") } : process.env;\nconst child = spawn(process.execPath, [target, ...args], { stdio: "inherit", env });\nchild.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });\nchild.on("error", (error) => { console.error(error.message); process.exitCode = 1; });\n`;
+1 -1
View File
@@ -65,7 +65,7 @@ export function createApprovalMcpServer(
server.registerTool("approval_list_todos", {
title: "列出我的审批待办",
description: "审批操作的入口。通过同一登录身份实时列出当前用户在钉钉OA和薪福通中的待办,并取得后续接口必须使用的approvalId;列表尽可能直接包含金额、UTC提交时间及Asia/Shanghai本地时间。",
description: "审批操作的入口。通过同一登录身份实时列出当前用户在钉钉OA和薪福通中的待办,并取得后续接口必须使用的approvalId;列表尽可能直接包含金额和带时区的提交时间。",
inputSchema: {
source: z.enum(["all", "dingtalk", "xft"]).default("all"),
query: z.string().max(200).default(""),
+6 -7
View File
@@ -1149,21 +1149,18 @@ export class UnifiedApprovalService {
return {
approvalId: this.signPortableApprovalReference(ref),
source: "dingtalk",
sourceType: item.source,
businessNo: item.processInstanceId,
title: item.title ?? item.processName ?? "钉钉审批",
applicant: {
...(item.applicantName ? { name: item.applicantName } : {}),
...(item.applicantDepartment ? { department: item.applicantDepartment } : {}),
},
...(item.submittedAt ? { submittedAt: item.submittedAt } : {}),
...(item.submittedAt ? localTimeFields(item.submittedAt) : {}),
...(item.submittedAt ? { submittedAt: localTime(item.submittedAt) } : {}),
...(item.amount !== undefined ? { amount: { value: item.amount, currency: item.currency ?? "CNY" } } : {}),
...(item.summary ? { summary: item.summary } : {}),
availableActions: [
...(clientOnly ? [] : ["approve", "reject"]),
],
actionsHint: "approve/reject 可直接批量提交,网关会在写入前逐张实时复核;只有退回需要调用 approval_get_actions 获取 targetId。",
...(clientOnly ? {
manualAction: {
required: true,
@@ -1188,15 +1185,13 @@ export class UnifiedApprovalService {
return {
approvalId: this.signPortableApprovalReference(ref),
source: "xft",
sourceType: "smart_expense",
businessNo: item.billId,
title: item.title ?? "薪福通审批",
applicant: {
...(item.applicantName ? { name: item.applicantName } : {}),
...(item.applicantDepartment ? { department: item.applicantDepartment } : {}),
},
...(item.applyTime ? { submittedAt: item.applyTime } : {}),
...(item.applyTime ? localTimeFields(item.applyTime) : {}),
...(item.applyTime ? { submittedAt: localTime(item.applyTime) } : {}),
...(item.amount !== undefined ? { amount: { value: item.amount, currency: item.currency ?? "CNY" } } : {}),
...(item.reason ? { summary: item.reason } : {}),
availableActions: [
@@ -1848,6 +1843,10 @@ function localTimeFields(value: string): Record<string, string> {
return { submittedAtLocal: local, submittedTimeZone: "Asia/Shanghai" };
}
function localTime(value: string): string {
return localTimeFields(value).submittedAtLocal ?? value;
}
function dingTalkSubmissionTemplateAllowed(template: { dirName?: string; name?: string }): boolean {
if (!template.dirName || !template.name) return false;
const names = DINGTALK_SUBMISSION_TEMPLATES[template.dirName as keyof typeof DINGTALK_SUBMISSION_TEMPLATES];