Files
dingMCP/test/server-instructions.test.ts
T

263 lines
15 KiB
TypeScript

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() ?? "", /单次条数限制.*内部调用细节.*绝不向用户.*一键全选.*如何分组/s);
assert.match(client.getInstructions() ?? "", /用户只是在查看.*业务信息.*不主动讨论.*自动分批.*不得造成重复确认/s);
assert.match(client.getInstructions() ?? "", /近30天.*allowDuplicateInvoices=true/s);
assert.match(client.getInstructions() ?? "", /草稿只能由 Agent 保存在.*当前 OS 用户私有数据目录/s);
assert.match(client.getInstructions() ?? "", /schemaVersion=1.*dingtalk-approval-local-draft.*submit_uncertain/s);
assert.match(client.getInstructions() ?? "", /不得上传网关.*不得.*完整上游 Schema/s);
assert.match(client.getInstructions() ?? "", /恢复时.*实时读取 Schema/s);
assert.match(client.getInstructions() ?? "", /自动清理超过 90 天/s);
assert.match(client.getInstructions() ?? "", /outcome_uncertain.*冻结.*禁止自动重试/s);
assert.match(client.getInstructions() ?? "", /所有选择型字段只向用户展示业务名称.*optionSource.*options\/enumLabels.*values.*正式值/s);
assert.match(client.getInstructions() ?? "", /用户选择 label.*values.*同一选项的 code.*不能把 label.*code 只存在于工具调用内部/s);
assert.match(client.getInstructions() ?? "", /绝不向用户展示、解释或索取.*城市码.*组织码.*工号.*档案编号.*数字舱位值.*内部 ID/s);
assert.match(client.getInstructions() ?? "", /selectionCapability\.status=client_only.*必填时停止.*可选时.*跳过/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 ?? "", /Schema容量.*不得转述给用户.*任一无效.*零写入/s);
assert.match(descriptions.approval_prepare_submission ?? "", /明确确认|确认/);
assert.match(descriptions.approval_get_submission_schema ?? "", /必填字段齐全.*可选字段/);
assert.match(descriptions.approval_get_submission_schema ?? "", /minimalExample.*values形状.*尖括号必须替换/);
assert.match(descriptions.approval_get_submission_options ?? "", /承担部门.*城市.*费用类型.*只向用户展示label.*values.*code.*不能填label/s);
const optionSchema = tools.tools.find((tool) => tool.name === "approval_get_submission_options")?.inputSchema as any;
assert.deepEqual(optionSchema.properties.kind.enum, ["department", "city", "expense_type"]);
assert.match(descriptions.approval_prepare_submission ?? "", /继续询问/);
assert.match(descriptions.approval_prepare_submission ?? "", /查重.*allowDuplicateInvoices/s);
assert.match(descriptions.approval_prepare_submission ?? "", /values.*code.*不能直接填label.*预览.*业务名称/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);
});