Files
nocobase-mcp/test/tools.test.ts
T
nac 9f3d3ad5fc
CI / test (macos-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
feat: harden install and resumable OAuth login
2026-08-28 07:01:20 +00:00

198 lines
7.1 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 { 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<void>;
}> {
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<string, unknown> };
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<string, unknown>;
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<string, unknown> };
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();
}
});