feat: initial public release
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
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:
@@ -0,0 +1,247 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import type { AppConfig } from "../src/config.js";
|
||||
import type { ApprovalService } from "../src/approval-service.js";
|
||||
import type { XftApprovalProvider } from "../src/xft.js";
|
||||
import { approvalBatchSummary, APPROVAL_MCP_INSTRUCTIONS, compactResult, createApprovalMcpServer, isLoopbackAddress, mcpAllowedHosts, oauthHtmlContentSecurityPolicy } from "../src/server.js";
|
||||
|
||||
function config(): AppConfig {
|
||||
return {
|
||||
clientId: "client",
|
||||
clientSecret: "secret",
|
||||
apiBaseUrl: "https://api.test",
|
||||
oapiBaseUrl: "https://oapi.test",
|
||||
publicBaseUrl: "http://localhost:3000",
|
||||
trustedLocalUser: { userId: "user-1" },
|
||||
port: 3000,
|
||||
requestTimeoutMs: 1_000,
|
||||
officialLookbackDays: 120,
|
||||
officialScanConcurrency: 2,
|
||||
officialClientOnlyProcessCodes: [],
|
||||
batchLimit: 20,
|
||||
approvalRefSecret: "fixed-reference-secret-for-tests",
|
||||
};
|
||||
}
|
||||
|
||||
test("MCP initialization teaches the workflow while tools/list stays compact", async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const server = createApprovalMcpServer(config(), {} as ApprovalService);
|
||||
const client = new Client({ name: "instructions-test", version: "1.0.0" });
|
||||
await server.connect(serverTransport);
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
assert.equal(client.getInstructions(), APPROVAL_MCP_INSTRUCTIONS);
|
||||
assert.match(client.getInstructions() ?? "", /approval_list_todos.*approval_get_detail/s);
|
||||
assert.match(client.getInstructions() ?? "", /approval_list_history.*只读/s);
|
||||
assert.match(client.getInstructions() ?? "", /approval_process_batch/);
|
||||
assert.match(client.getInstructions() ?? "", /completed.*followUpRequired/);
|
||||
assert.match(client.getInstructions() ?? "", /不会通过其他消息通道.*directUrl.*当前面向用户的回复/s);
|
||||
assert.doesNotMatch(client.getInstructions() ?? "", /通过当前 Messenger/);
|
||||
assert.match(client.getInstructions() ?? "", /必填字段缺失.*继续询问/s);
|
||||
assert.match(client.getInstructions() ?? "", /非必填字段.*是否补充/s);
|
||||
assert.match(client.getInstructions() ?? "", /出差申请.*外出.*业务招待申请.*差旅费用报销.*业务招待费用报销/s);
|
||||
assert.match(client.getInstructions() ?? "", /revokeId.*approval_revoke_submission/);
|
||||
assert.match(client.getInstructions() ?? "", /最多(?:处理 )?20 条.*零写入.*禁止自动.*重试/s);
|
||||
assert.match(client.getInstructions() ?? "", /近30天.*allowDuplicateInvoices=true/s);
|
||||
|
||||
const tools = await client.listTools();
|
||||
assert.deepEqual(tools.tools.map((tool) => tool.name), [
|
||||
"approval_list_todos",
|
||||
"approval_list_history",
|
||||
"approval_get_detail",
|
||||
"approval_get_actions",
|
||||
"approval_process_batch",
|
||||
"approval_list_submission_templates",
|
||||
"approval_get_submission_schema",
|
||||
"approval_get_submission_options",
|
||||
"approval_prepare_submission",
|
||||
"approval_submit",
|
||||
"approval_revoke_submission",
|
||||
"approval_get_submission_status",
|
||||
]);
|
||||
const descriptions = Object.fromEntries(tools.tools.map((tool) => [tool.name, tool.description ?? ""]));
|
||||
assert.match(descriptions.approval_list_todos ?? "", /入口.*approvalId/);
|
||||
assert.match(descriptions.approval_list_history ?? "", /handled.*处理过.*submitted.*本人发起.*只读/s);
|
||||
assert.match(descriptions.approval_get_detail ?? "", /expenseItems.*raw/);
|
||||
assert.match(descriptions.approval_process_batch ?? "", /outcome_uncertain|禁止自动重试/);
|
||||
assert.match(descriptions.approval_get_actions ?? "", /targetId.*approval_process_batch/);
|
||||
const historySchema = tools.tools.find((tool) => tool.name === "approval_list_history")?.inputSchema as any;
|
||||
assert.equal(historySchema.properties.startDate.default, "");
|
||||
assert.equal(historySchema.properties.endDate.default, "");
|
||||
assert.match(historySchema.properties.startDate.description, /最近30天/);
|
||||
assert.match(descriptions.approval_process_batch ?? "", /用户明确.*不二次确认/);
|
||||
assert.match(descriptions.approval_process_batch ?? "", /succeeded.*completed.*followUpRequired/);
|
||||
assert.match(descriptions.approval_process_batch ?? "", /最多 20 条.*任一无效.*零写入/s);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /明确确认|确认/);
|
||||
assert.match(descriptions.approval_get_submission_schema ?? "", /必填字段齐全.*可选字段/);
|
||||
assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市编码/);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /继续询问/);
|
||||
assert.match(descriptions.approval_prepare_submission ?? "", /查重.*allowDuplicateInvoices/s);
|
||||
const prepareSchema = tools.tools.find((tool) => tool.name === "approval_prepare_submission")?.inputSchema as any;
|
||||
assert.equal(prepareSchema.properties.allowDuplicateInvoices.default, false);
|
||||
assert.match(descriptions.approval_list_submission_templates ?? "", /出差申请.*外出.*差旅费用报销.*业务招待费用报销/);
|
||||
assert.match(descriptions.approval_submit ?? "", /禁止自动重试/);
|
||||
assert.match(descriptions.approval_revoke_submission ?? "", /禁止自动重试.*薪福通.*实验能力/);
|
||||
assert.match(descriptions.approval_get_submission_status ?? "", /本人.*归属/);
|
||||
const batchTool = tools.tools.find((tool) => tool.name === "approval_process_batch")!;
|
||||
const itemSchema = (batchTool.inputSchema as any).properties.items;
|
||||
assert.equal(itemSchema.maxItems, 20);
|
||||
const tooMany = await client.callTool({
|
||||
name: "approval_process_batch",
|
||||
arguments: {
|
||||
action: "approve",
|
||||
remark: "",
|
||||
items: Array.from({ length: 21 }, () => ({ approvalId: "v4.this-reference-is-long-enough" })),
|
||||
},
|
||||
});
|
||||
assert.equal(tooMany.isError, true);
|
||||
assert.match(JSON.stringify(tooMany.content), /一次最多处理 20 条审批/);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("local-only server recognizes loopback addresses", () => {
|
||||
assert.equal(isLoopbackAddress("127.0.0.1"), true);
|
||||
assert.equal(isLoopbackAddress("::1"), true);
|
||||
assert.equal(isLoopbackAddress("::ffff:127.0.0.1"), true);
|
||||
assert.equal(isLoopbackAddress("192.168.1.13"), false);
|
||||
assert.equal(isLoopbackAddress(undefined), false);
|
||||
});
|
||||
|
||||
test("HTTP MCP accepts the configured public host without dropping loopback health checks", () => {
|
||||
assert.deepEqual(mcpAllowedHosts("https://approval.justconn.top"), [
|
||||
"approval.justconn.top", "127.0.0.1", "localhost", "[::1]",
|
||||
]);
|
||||
});
|
||||
|
||||
test("OAuth consent CSP permits only the DingTalk login redirect after same-origin submit", () => {
|
||||
const policy = oauthHtmlContentSecurityPolicy();
|
||||
assert.match(policy, /form-action 'self' https:\/\/login\.dingtalk\.com(?:;|\s)/);
|
||||
assert.doesNotMatch(policy, /form-action[^;]*\*/);
|
||||
});
|
||||
|
||||
test("remote gateway lists DingTalk OA and XFT through one authenticated MCP session", async () => {
|
||||
const dingTalk = {
|
||||
async listMyTodos() {
|
||||
return { items: [{ source: "official_oa", processInstanceId: "oa-1", taskId: "task-1", title: "采购审批", raw: {} }] };
|
||||
},
|
||||
} as unknown as ApprovalService;
|
||||
const xft = {
|
||||
async listMyTodos() {
|
||||
return { items: [{ source: "xft", billId: "xft-1", title: "费用报销" }], totalCount: 1, identity: {}, diagnostics: {} };
|
||||
},
|
||||
} as unknown as XftApprovalProvider;
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const server = createApprovalMcpServer(
|
||||
{ ...config(), appMode: "gateway", trustedLocalUser: undefined },
|
||||
dingTalk, xft, undefined, undefined, { userId: "user-1" },
|
||||
);
|
||||
const client = new Client({ name: "unified-gateway-test", version: "1.0.0" });
|
||||
await server.connect(serverTransport);
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
const response = await client.callTool({ name: "approval_list_todos", arguments: { source: "all", query: "", limit: 20 } });
|
||||
const result = (response.structuredContent as { result: { items: Array<{ source: string; approvalId: string }>; selectionId: string } }).result;
|
||||
assert.deepEqual(result.items.map((item) => item.source).sort(), ["dingtalk", "xft"]);
|
||||
const blocks = response.content as Array<{ type: string; text?: string }>;
|
||||
const text = blocks.find((item) => item.type === "text")?.text ?? "";
|
||||
assert.match(text, /采购审批/);
|
||||
assert.match(text, /费用报销/);
|
||||
assert.ok(result.items.every((item) => text.includes(item.approvalId)));
|
||||
assert.ok(text.includes(result.selectionId));
|
||||
assert.doesNotMatch(text, /diagnostics|enterpriseUserId|processInstanceId|taskId|userId/);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("batch summaries distinguish total failure, partial success, and full success", () => {
|
||||
assert.match(approvalBatchSummary({ total: 2, succeeded: 0 }), /未有任何.*成功/);
|
||||
assert.match(approvalBatchSummary({ total: 2, succeeded: 1 }), /部分成功.*1\/2/);
|
||||
assert.match(approvalBatchSummary({ total: 2, succeeded: 2 }), /全部提交成功.*2\/2/);
|
||||
assert.match(approvalBatchSummary({ total: 2, succeeded: 0, uncertain: 1 }), /禁止自动重试/);
|
||||
assert.match(approvalBatchSummary({ status: "preflight_failed", writesStarted: false, invalidItems: [{}] }), /未开始.*0 张已写入.*禁止自动重试/);
|
||||
});
|
||||
|
||||
test("portable tool output exposes compact domain data without upstream internals", () => {
|
||||
const response = compactResult("已完成。", {
|
||||
action: "approve",
|
||||
total: 1,
|
||||
results: [{
|
||||
approvalId: "v3.portable.reference.value",
|
||||
processInstanceId: "internal-instance",
|
||||
taskId: "internal-task",
|
||||
userId: "internal-user",
|
||||
status: "processed",
|
||||
success: true,
|
||||
response: { traceId: "upstream-trace" },
|
||||
}],
|
||||
diagnostics: { realtimeValidation: true },
|
||||
});
|
||||
const text = (response.content[0] as { type: "text"; text: string }).text;
|
||||
assert.match(text, /v3\.portable\.reference\.value/);
|
||||
assert.match(text, /"status":"processed"/);
|
||||
assert.doesNotMatch(text, /internal-instance|internal-task|internal-user|upstream-trace|diagnostics/);
|
||||
assert.deepEqual(response.structuredContent.result, JSON.parse(text.split("\n")[1]!));
|
||||
});
|
||||
|
||||
test("QR auth challenges instruct the agent to reply directly instead of claiming a separate push", () => {
|
||||
const response = compactResult("需要重新登录。", {
|
||||
sourceStatus: {
|
||||
xft: {
|
||||
status: "auth_required",
|
||||
authChallenge: {
|
||||
provider: "xft", type: "qr", challengeId: "qr-1",
|
||||
directUrl: "https://approval.example.test/xft/login",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
async getOrStart() {
|
||||
return {
|
||||
provider: "xft" as const, type: "qr" as const, challengeId: "qr-1",
|
||||
qrImageUrl: "https://approval.example.test/xft/qr",
|
||||
directUrl: "https://approval.example.test/xft/login",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(), pollAfterSeconds: 3,
|
||||
};
|
||||
},
|
||||
getImage: () => Buffer.from("png"),
|
||||
});
|
||||
const text = (response.content[0] as { type: "text"; text: string }).text;
|
||||
assert.match(text, /不会另行推送登录内容/);
|
||||
assert.match(text, /原样写入当前回复/);
|
||||
assert.doesNotMatch(text, /通过当前 Messenger/);
|
||||
assert.equal(response.content[1]?.type, "image");
|
||||
});
|
||||
|
||||
test("portable presentation preserves submission fields and opaque write references", () => {
|
||||
const response = compactResult("审批尚未提交。", {
|
||||
templateId: "v3.template.reference.value",
|
||||
fields: [{ fieldNumber: "trip_reason", name: "出差事由", required: true, valueFormat: "文本字符串" }],
|
||||
inputContract: { values: { required: ["businessTrip"] } },
|
||||
confirmationId: "v3.confirmation.reference.value",
|
||||
preview: {
|
||||
template: { name: "出差申请", processCode: "internal-process-code" },
|
||||
formSummary: { businessTrip: { reason: "客户拜访" } },
|
||||
identityValidation: { validated: true, employeeNumber: "internal-employee" },
|
||||
},
|
||||
revokeId: "v3.revoke.reference.value",
|
||||
billId: "2026081300000001",
|
||||
diagnostics: { persistedBusinessData: false },
|
||||
});
|
||||
const visible = response.structuredContent.result as Record<string, any>;
|
||||
assert.equal(visible.templateId, "v3.template.reference.value");
|
||||
assert.equal(visible.confirmationId, "v3.confirmation.reference.value");
|
||||
assert.equal(visible.revokeId, "v3.revoke.reference.value");
|
||||
assert.equal(visible.businessId, "2026081300000001");
|
||||
assert.equal(visible.fields[0].fieldNumber, "trip_reason");
|
||||
assert.equal(visible.preview.formSummary.businessTrip.reason, "客户拜访");
|
||||
assert.equal(visible.preview.template.processCode, undefined);
|
||||
assert.equal(visible.preview.identityValidation.employeeNumber, undefined);
|
||||
assert.equal(visible.diagnostics, undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user