352 lines
18 KiB
TypeScript
352 lines
18 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
import {
|
|
APPROVAL_ONBOARDING_EXAMPLES, RemoteBridge, bridgeAuthToolResult, createBridgeMcpServer,
|
|
syncBundledSkillForLegacyUpdate,
|
|
} from "../src/bridge.js";
|
|
|
|
test("legacy updater post-install syncs the complete bundled Skill", () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-bridge-skill-sync-"));
|
|
const bundled = join(root, "package", "skill", "dingtalk-approval");
|
|
const statePath = join(root, "client", "state", "session.json");
|
|
const target = join(root, "client", "skill", "dingtalk-approval");
|
|
mkdirSync(join(bundled, "references"), { recursive: true });
|
|
mkdirSync(target, { recursive: true });
|
|
writeFileSync(join(bundled, "SKILL.md"), "new skill");
|
|
writeFileSync(join(bundled, "references", "create-approval.md"), "create rules");
|
|
writeFileSync(join(bundled, "references", "local-drafts.md"), "draft rules");
|
|
writeFileSync(join(target, "SKILL.md"), "old skill");
|
|
|
|
syncBundledSkillForLegacyUpdate(statePath, bundled);
|
|
|
|
assert.equal(readFileSync(join(target, "SKILL.md"), "utf8"), "new skill");
|
|
assert.equal(readFileSync(join(target, "references", "create-approval.md"), "utf8"), "create rules");
|
|
assert.equal(readFileSync(join(target, "references", "local-drafts.md"), "utf8"), "draft rules");
|
|
assert.deepEqual(readdirSync(join(root, "client", "skill")), ["dingtalk-approval"]);
|
|
});
|
|
|
|
test("stdio bridge creates a messenger-friendly device login challenge and protects local state", async () => {
|
|
const calls: string[] = [];
|
|
const fetchFn: typeof fetch = async (input) => {
|
|
const url = String(input);
|
|
calls.push(url);
|
|
if (url.endsWith("/register")) return Response.json({ client_id: "bridge-client" }, { status: 201 });
|
|
if (url.endsWith("/oauth/device/authorize")) return Response.json({
|
|
device_code: "device-secret", user_code: "ABCD-2345",
|
|
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=ABCD-2345",
|
|
expires_in: 600, interval: 5,
|
|
});
|
|
if (url.endsWith("/oauth/device/token")) return Response.json({ error: "authorization_pending" }, { status: 400 });
|
|
throw new Error(`unexpected ${url}`);
|
|
};
|
|
const root = mkdtempSync(join(tmpdir(), "ding-bridge-"));
|
|
const statePath = join(root, "private", "session.json");
|
|
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, fetchFn);
|
|
const first = await bridge.loginChallenge();
|
|
assert.equal(first.authenticated, false);
|
|
assert.ok("authChallenge" in first);
|
|
assert.equal(first.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=ABCD-2345");
|
|
assert.match(first.message, /https:\/\/approval\.example\.test\/oauth\/device\?user_code=ABCD-2345/);
|
|
assert.match(first.message, /\[打开钉钉授权登录\]\(https:\/\/approval\.example\.test\/oauth\/device\?user_code=ABCD-2345\)/);
|
|
assert.match(first.message, /不会另行推送或弹出/);
|
|
assert.match(first.message, /当前回复正文中原样显示/);
|
|
assert.match(first.message, /授权码:ABCD-2345/);
|
|
const toolResult = bridgeAuthToolResult(first);
|
|
assert.equal(toolResult.content[0]?.type, "text");
|
|
assert.match(toolResult.content[0]!.type === "text" ? toolResult.content[0].text : "", /备用原始链接:https:\/\/approval\.example\.test/);
|
|
assert.deepEqual(toolResult.content[1], {
|
|
type: "resource_link",
|
|
name: "dingtalk-approval-login",
|
|
title: "打开钉钉授权登录",
|
|
uri: "https://approval.example.test/oauth/device?user_code=ABCD-2345",
|
|
description: "当前用户的短期钉钉授权登录链接",
|
|
});
|
|
assert.equal(statSync(statePath).mode & 0o777, 0o600);
|
|
assert.equal(statSync(join(root, "private")).mode & 0o777, 0o700);
|
|
assert.match(readFileSync(statePath, "utf8"), /device-secret/);
|
|
|
|
const second = await bridge.loginChallenge();
|
|
assert.equal(second.authenticated, false);
|
|
assert.deepEqual(calls.map((url) => new URL(url).pathname), ["/register", "/oauth/device/authorize", "/oauth/device/token"]);
|
|
});
|
|
|
|
test("post-install onboarding waits for authentication and is visible exactly once in portable text", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-bridge-onboarding-login-"));
|
|
const statePath = join(root, "session.json");
|
|
let authorized = false;
|
|
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
|
|
const path = new URL(String(input)).pathname;
|
|
if (path === "/register") return Response.json({ client_id: "bridge-client" }, { status: 201 });
|
|
if (path === "/oauth/device/authorize") return Response.json({
|
|
device_code: "onboarding-device", user_code: "START-1234",
|
|
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=START-1234",
|
|
expires_in: 600, interval: 5,
|
|
});
|
|
if (path === "/oauth/device/token") return authorized
|
|
? Response.json({ access_token: "fresh-access", refresh_token: "fresh-refresh", expires_in: 900 })
|
|
: Response.json({ error: "authorization_pending" }, { status: 400 });
|
|
throw new Error(`unexpected ${path}`);
|
|
});
|
|
bridge.ensureOnboardingPending();
|
|
|
|
const challenge = await bridge.loginChallenge();
|
|
assert.equal(challenge.authenticated, false);
|
|
assert.equal(JSON.parse(readFileSync(statePath, "utf8")).onboarding.status, "pending", "showing a login URL must not consume onboarding");
|
|
|
|
authorized = true;
|
|
const authenticated = await bridge.loginChallenge();
|
|
assert.equal(authenticated.authenticated, true);
|
|
assert.ok("onboarding" in authenticated && authenticated.onboarding);
|
|
assert.deepEqual(authenticated.onboarding.examples, [...APPROVAL_ONBOARDING_EXAMPLES]);
|
|
const portable = bridgeAuthToolResult(authenticated);
|
|
const portableText = portable.content[0]?.type === "text" ? portable.content[0].text : "";
|
|
assert.match(portableText, /首次登录已完成/);
|
|
assert.match(portableText, /帮我列出当前待处理的审批/);
|
|
assert.equal(JSON.parse(readFileSync(statePath, "utf8")).onboarding.status, "delivered");
|
|
|
|
const repeated = await bridge.loginChallenge();
|
|
assert.equal("onboarding" in repeated, false);
|
|
assert.doesNotMatch(repeated.message, /首次登录已完成/);
|
|
});
|
|
|
|
test("the first successful business response can deliver onboarding without an explicit login tool call", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-bridge-onboarding-call-"));
|
|
const statePath = join(root, "session.json");
|
|
writeFileSync(statePath, JSON.stringify({
|
|
client: { client_id: "bridge-client" },
|
|
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
|
|
}));
|
|
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
|
|
throw new Error("network should not be used by the injected client");
|
|
});
|
|
bridge.ensureOnboardingPending();
|
|
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
|
|
async callTool() { return { isError: true, content: [{ type: "text", text: "upstream failed" }] }; },
|
|
async close() {},
|
|
};
|
|
|
|
const failed = await bridge.call("approval_list_todos", { source: "all", query: "", limit: 1 });
|
|
assert.equal(failed.isError, true);
|
|
assert.equal(JSON.parse(readFileSync(statePath, "utf8")).onboarding.status, "pending", "an error response must not consume onboarding");
|
|
|
|
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
|
|
async callTool() { return { content: [{ type: "text", text: "已实时查询审批待办。" }], structuredContent: { items: [] } }; },
|
|
async close() {},
|
|
};
|
|
|
|
const first = await bridge.call("approval_list_todos", { source: "all", query: "", limit: 1 });
|
|
assert.match(first.content.at(-1).text, /首次登录已完成/);
|
|
assert.deepEqual(first.structuredContent.onboarding.examples, [...APPROVAL_ONBOARDING_EXAMPLES]);
|
|
const second = await bridge.call("approval_list_todos", { source: "all", query: "", limit: 1 });
|
|
assert.equal(second.content.length, 1);
|
|
assert.equal(second.structuredContent.onboarding, undefined);
|
|
});
|
|
|
|
test("stdio bridge discards a connected client after its access token expires", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-bridge-expired-"));
|
|
const statePath = join(root, "session.json");
|
|
writeFileSync(statePath, JSON.stringify({
|
|
client: { client_id: "bridge-client" },
|
|
tokens: { access_token: "expired-access", savedAt: Date.now() - 120_000, expires_in: 60 },
|
|
}));
|
|
const paths: string[] = [];
|
|
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
|
|
const path = new URL(String(input)).pathname;
|
|
paths.push(path);
|
|
if (path === "/oauth/device/authorize") return Response.json({
|
|
device_code: "replacement-device", user_code: "NEXT-1234",
|
|
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=NEXT-1234",
|
|
expires_in: 600, interval: 5,
|
|
});
|
|
throw new Error(`unexpected ${path}`);
|
|
});
|
|
let staleCalls = 0;
|
|
let closed = 0;
|
|
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
|
|
async callTool() { staleCalls += 1; return { structuredContent: { result: { stale: true } } }; },
|
|
async close() { closed += 1; },
|
|
};
|
|
|
|
const result = await bridge.call("approval_list_todos", { source: "xft", query: "", limit: 20 });
|
|
assert.equal(staleCalls, 0);
|
|
assert.equal(closed, 1);
|
|
assert.deepEqual(paths, ["/oauth/device/authorize"]);
|
|
assert.equal(result.structuredContent.result.authenticated, false);
|
|
assert.equal(result.structuredContent.result.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=NEXT-1234");
|
|
assert.match(result.content[0].text, /https:\/\/approval\.example\.test\/oauth\/device\?user_code=NEXT-1234/);
|
|
assert.match(result.content[0].text, /授权码:NEXT-1234/);
|
|
assert.equal(result.content[1].type, "resource_link");
|
|
assert.equal(result.content[1].uri, "https://approval.example.test/oauth/device?user_code=NEXT-1234");
|
|
});
|
|
|
|
test("expired device link is replaced by a fresh login challenge without reinstalling", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-bridge-expired-link-"));
|
|
const statePath = join(root, "session.json");
|
|
writeFileSync(statePath, JSON.stringify({
|
|
client: { client_id: "bridge-client" },
|
|
device: {
|
|
device_code: "expired-device", user_code: "OLD-1234",
|
|
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=OLD-1234",
|
|
expiresAt: Date.now() - 1, interval: 5,
|
|
},
|
|
}));
|
|
const paths: string[] = [];
|
|
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
|
|
const path = new URL(String(input)).pathname;
|
|
paths.push(path);
|
|
if (path === "/oauth/device/authorize") return Response.json({
|
|
device_code: "fresh-device", user_code: "NEW-5678",
|
|
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=NEW-5678",
|
|
expires_in: 600, interval: 5,
|
|
});
|
|
throw new Error(`unexpected ${path}`);
|
|
});
|
|
|
|
const result = await bridge.loginChallenge();
|
|
|
|
assert.deepEqual(paths, ["/oauth/device/authorize"]);
|
|
assert.equal(result.authenticated, false);
|
|
assert.ok("authChallenge" in result);
|
|
assert.equal(result.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=NEW-5678");
|
|
assert.match(result.message, /无需重新安装/);
|
|
assert.doesNotMatch(readFileSync(statePath, "utf8"), /expired-device|OLD-1234/);
|
|
});
|
|
|
|
test("MCP approval_login returns both portable text and a standard resource link", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-bridge-mcp-login-"));
|
|
const bridge = new RemoteBridge("https://approval.example.test/mcp", join(root, "session.json"), async (input) => {
|
|
const path = new URL(String(input)).pathname;
|
|
if (path === "/register") return Response.json({ client_id: "bridge-client" }, { status: 201 });
|
|
if (path === "/oauth/device/authorize") return Response.json({
|
|
device_code: "device-secret", user_code: "LINK-2345",
|
|
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=LINK-2345",
|
|
expires_in: 600, interval: 5,
|
|
});
|
|
throw new Error(`unexpected ${path}`);
|
|
});
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
const server = createBridgeMcpServer(bridge);
|
|
const client = new Client({ name: "bridge-login-test", version: "1.0.0" });
|
|
await server.connect(serverTransport);
|
|
try {
|
|
await client.connect(clientTransport);
|
|
const response = await client.callTool({ name: "approval_login", arguments: {} });
|
|
const content = response.content as Array<{ type: string; text?: string; uri?: string }>;
|
|
const text = content.find((item) => item.type === "text")?.text ?? "";
|
|
const link = content.find((item) => item.type === "resource_link");
|
|
assert.match(text, /\[打开钉钉授权登录\]\(https:\/\/approval\.example\.test\/oauth\/device\?user_code=LINK-2345\)/);
|
|
assert.match(text, /备用原始链接:https:\/\/approval\.example\.test\/oauth\/device\?user_code=LINK-2345/);
|
|
assert.equal(link?.uri, "https://approval.example.test/oauth/device?user_code=LINK-2345");
|
|
assert.equal((response.structuredContent as any).result.authChallenge.directUrl, link?.uri);
|
|
const tools = await client.listTools();
|
|
const instructions = client.getInstructions() ?? "";
|
|
assert.match(instructions, /登录链接过期.*approval_login.*绝不.*重新安装/);
|
|
assert.match(instructions, /审批引用.*调用容量.*内部分批.*不得向用户解释/);
|
|
assert.match(instructions, /提单草稿仅可保存在 Agent 所在机器.*不得上传网关.*实时模板 Schema/s);
|
|
assert.doesNotMatch(instructions, /最多.*20|selectionId|approvalId|targetId/);
|
|
const history = tools.tools.find((tool) => tool.name === "approval_list_history")!;
|
|
assert.equal((history.inputSchema as any).properties.startDate.default, "");
|
|
assert.equal((history.inputSchema as any).properties.endDate.default, "");
|
|
const batch = tools.tools.find((tool) => tool.name === "approval_process_batch")!;
|
|
assert.equal((batch.inputSchema as any).properties.items.maxItems, 20);
|
|
assert.match(batch.description ?? "", /Schema容量.*不向用户解释.*任一无效则零写入/s);
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
}
|
|
});
|
|
|
|
test("stdio bridge refreshes an expired login before creating a new challenge", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-bridge-refresh-"));
|
|
const statePath = join(root, "session.json");
|
|
writeFileSync(statePath, JSON.stringify({
|
|
client: { client_id: "bridge-client" },
|
|
tokens: {
|
|
access_token: "expired-access", refresh_token: "refresh-1",
|
|
savedAt: Date.now() - 120_000, expires_in: 60,
|
|
},
|
|
}));
|
|
const paths: string[] = [];
|
|
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
|
|
const path = new URL(String(input)).pathname;
|
|
paths.push(path);
|
|
if (path === "/token") return Response.json({
|
|
access_token: "fresh-access", refresh_token: "refresh-2", expires_in: 900,
|
|
});
|
|
throw new Error(`unexpected ${path}`);
|
|
});
|
|
const result = await bridge.loginChallenge();
|
|
assert.equal(result.authenticated, true);
|
|
assert.deepEqual(paths, ["/token"]);
|
|
const saved = readFileSync(statePath, "utf8");
|
|
assert.match(saved, /fresh-access/);
|
|
assert.doesNotMatch(saved, /expired-access/);
|
|
});
|
|
|
|
test("stdio bridge preserves refresh credentials across a transient token endpoint failure", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-bridge-transient-refresh-"));
|
|
const statePath = join(root, "session.json");
|
|
writeFileSync(statePath, JSON.stringify({
|
|
client: { client_id: "bridge-client" },
|
|
tokens: { access_token: "expired-access", refresh_token: "keep-refresh", savedAt: Date.now() - 120_000, expires_in: 60 },
|
|
}));
|
|
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
|
|
assert.equal(new URL(String(input)).pathname, "/token");
|
|
return Response.json({ error: "server_error" }, { status: 503 });
|
|
});
|
|
await assert.rejects(bridge.loginChallenge(), /暂时无法刷新登录/);
|
|
const saved = readFileSync(statePath, "utf8");
|
|
assert.match(saved, /keep-refresh/);
|
|
assert.match(saved, /expired-access/);
|
|
});
|
|
|
|
test("stdio bridge adopts a newer token saved by another bridge process", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-bridge-shared-"));
|
|
const statePath = join(root, "session.json");
|
|
const oldSavedAt = Date.now() - 120_000;
|
|
writeFileSync(statePath, JSON.stringify({
|
|
client: { client_id: "bridge-client" },
|
|
tokens: { access_token: "expired-access", refresh_token: "stale-refresh", savedAt: oldSavedAt, expires_in: 60 },
|
|
}));
|
|
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
|
|
throw new Error("newer disk token should avoid refresh");
|
|
});
|
|
writeFileSync(statePath, JSON.stringify({
|
|
client: { client_id: "bridge-client" },
|
|
tokens: { access_token: "shared-fresh-access", refresh_token: "shared-refresh", savedAt: Date.now(), expires_in: 900 },
|
|
}));
|
|
const result = await bridge.loginChallenge();
|
|
assert.equal(result.authenticated, true);
|
|
});
|
|
|
|
test("stdio bridge bounds concurrent remote tool calls", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "ding-bridge-concurrency-"));
|
|
const statePath = join(root, "session.json");
|
|
writeFileSync(statePath, JSON.stringify({
|
|
client: { client_id: "bridge-client" },
|
|
tokens: { access_token: "fresh-access", savedAt: Date.now(), expires_in: 900 },
|
|
}));
|
|
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async () => {
|
|
throw new Error("network should not be used by the injected client");
|
|
});
|
|
let active = 0;
|
|
let maximum = 0;
|
|
(bridge as unknown as { client: { callTool(): Promise<unknown>; close(): Promise<void> } }).client = {
|
|
async callTool() {
|
|
active += 1;
|
|
maximum = Math.max(maximum, active);
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
|
active -= 1;
|
|
return { content: [{ type: "text", text: "ok" }] };
|
|
},
|
|
async close() {},
|
|
};
|
|
await Promise.all(Array.from({ length: 7 }, () => bridge.call("approval_get_actions", { approvalId: "opaque" })));
|
|
assert.equal(maximum, 3);
|
|
});
|