198 lines
7.1 KiB
TypeScript
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();
|
|
}
|
|
});
|