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; 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); });