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 { AuthService } from "../src/auth-service.js"; import type { CrmService } from "../src/crm-service.js"; import { NocoBaseError } from "../src/nocobase-client.js"; import type { ProjectService } from "../src/project-service.js"; import { createServer } from "../src/tools.js"; function fakeService(): CrmService { return new Proxy( {}, { get: (_target, property) => async (...args: unknown[]) => ({ operation: String(property), args }), }, ) as CrmService; } function fakeAuthService(): AuthService { return { start: async () => ({ status: "authorization_pending", authorization_type: "oauth_device_user_verification", login_url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE", verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE", user_code: "TEST-CODE", intended_recipient: "requesting_user", delivery_channel: "current_private_conversation", shareable_with_requesting_user: true, private_conversation_required: true, public_sharing_allowed: false, contains_bearer_credentials: false, contains_device_code: false, remote_desktop_required: false, }), status: async () => ({ status: "login_required" }), }; } async function connected( readonly: boolean, crmService: CrmService = fakeService(), ): Promise<{ client: Client; close: () => Promise; }> { const server = createServer({ authService: fakeAuthService(), crmService, projectService: fakeService() as unknown as ProjectService, readonly, }); const client = new Client({ name: "test", version: "1" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); return { client, close: async () => await Promise.all([client.close(), server.close()]).then(() => undefined), }; } test("read-only server exposes CRM tools plus browser login tools", async () => { const connection = await connected(true); try { const result = await connection.client.listTools(); assert.deepEqual( new Set(result.tools.map((tool) => tool.name)), new Set([ "nocobase_login", "nocobase_login_status", "crm_health", "crm_schema", "crm_overview", "crm_filter_options", "resolve_owner", "list_teams", "resolve_team", "get_team", "team_crm_overview", "list_customers", "get_customer", "list_contacts", "list_followups", "list_projects", "get_project", "get_project_timeline", "list_stale_customers", ]), ); } finally { await connection.close(); } }); test("login tools require a private user handoff instead of headless remote desktop", async () => { const connection = await connected(true); try { const tools = (await connection.client.listTools()).tools; const login = tools.find((tool) => tool.name === "nocobase_login"); const status = tools.find((tool) => tool.name === "nocobase_login_status"); assert.ok(login?.description); assert.ok(status?.description); assert.match(login.description, /MUST deliver login_url and user_code/); assert.match(login.description, /current private conversation/); assert.match(login.description, /headless server/); assert.match(login.description, /remote desktop is not required/); assert.match(login.description, /Never post them publicly/); assert.match(status.description, /private-delivery policy/); } finally { await connection.close(); } }); test("authentication failures tell the Agent to use the MCP browser login flow", async () => { const service = { crmHealth: async () => { throw new NocoBaseError(401, "NocoBase credentials are unavailable", { authenticationFailed: true, }); }, } as unknown as CrmService; const connection = await connected(true, service); try { const result = await connection.client.callTool({ name: "crm_health", arguments: {} }); const structured = result.structuredContent as { error: Record }; assert.equal(structured.error.code, "nocobase_auth_failed"); assert.match(String(structured.error.hint), /nocobase_login/); assert.match(String(structured.error.hint), /nocobase_login_status/); } finally { await connection.close(); } }); test("browser login tool returns only the safe user-facing authorization fields", async () => { const connection = await connected(true); try { const result = await connection.client.callTool({ name: "nocobase_login", arguments: {}, }); assert.deepEqual(result.structuredContent, { status: "authorization_pending", authorization_type: "oauth_device_user_verification", login_url: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE", verification_uri_complete: "https://justconn.top/idpOAuth/device?user_code=TEST-CODE", user_code: "TEST-CODE", intended_recipient: "requesting_user", delivery_channel: "current_private_conversation", shareable_with_requesting_user: true, private_conversation_required: true, public_sharing_allowed: false, contains_bearer_credentials: false, contains_device_code: false, remote_desktop_required: false, }); const payload = result.structuredContent as Record; assert.equal(Object.hasOwn(payload, "access_token"), false); assert.equal(Object.hasOwn(payload, "refresh_token"), false); assert.equal(Object.hasOwn(payload, "device_code"), false); assert.equal(Object.hasOwn(payload, "password"), false); } finally { await connection.close(); } }); test("write mode adds only fixed project create and update tools", async () => { const connection = await connected(false); try { const names = (await connection.client.listTools()).tools.map((tool) => tool.name); assert.ok(names.includes("create_project")); assert.ok(names.includes("update_project")); assert.ok(!names.some((name) => name.includes("destroy") || name.includes("delete"))); } finally { await connection.close(); } }); test("raw rows require explicit local policy opt-in", async () => { const previous = process.env.NOCOBASE_MCP_ENABLE_RAW_DATA; delete process.env.NOCOBASE_MCP_ENABLE_RAW_DATA; const connection = await connected(true); try { const result = await connection.client.callTool({ name: "list_customers", arguments: { include_raw: true }, }); const structured = result.structuredContent as { error: Record }; assert.equal(structured.error.code, "raw_data_disabled"); } finally { if (previous === undefined) delete process.env.NOCOBASE_MCP_ENABLE_RAW_DATA; else process.env.NOCOBASE_MCP_ENABLE_RAW_DATA = previous; await connection.close(); } });