Files
fusion/packages/pi-claude-cli/tests/mcp-config.test.ts
gsxdsm f4e0850f9d feat: add "Anthropic — via Claude CLI" as a first-class provider
Replaces the stray useClaudeCli settings checkbox + onboarding question
with a proper provider-card UX. The card lives next to OAuth + API-key
cards in onboarding and settings, with Enable/Disable + Test actions.

Backend:
 - Vendors rchern/pi-claude-cli@0.3.1 as packages/pi-claude-cli
   (MIT, attribution in UPSTREAM.md). Lets us bump peer-dep on
   pi-coding-agent in lockstep with Fusion (upstream pinned ^0.52.0
   vs ours ^0.62.0) and fix bugs without waiting on upstream.
 - Adds @fusion/pi-claude-cli as a workspace dep of @runfusion/fusion
   so users don't have to `npm install -g pi-claude-cli` manually.
 - serve/daemon/dashboard conditionally load the extension via
   discoverAndLoadExtensions() when GlobalSettings.useClaudeCli is on;
   no side-effects on user ~/.fusion/agent/settings.json.
 - New GET /api/providers/claude-cli/status: claude --version probe
   + toggle state + cached extension resolution.
 - New POST /api/auth/claude-cli: flips useClaudeCli, refuses if the
   claude binary is missing, fires the existing skill-backfill hook.
 - /api/auth/status now injects a synthetic {id:"claude-cli", type:"cli"}
   provider entry so onboarding + settings see a consistent list.

Frontend:
 - New ClaudeCliProviderCard component shared between ModelOnboardingModal
   and SettingsModal's Authentication section.
 - New AuthProvider.type = "cli" variant.
 - Removed the old "Route AI calls through the Claude CLI" checkbox from
   Global Models settings and the opt-in step from the onboarding wizard.
 - ProviderIcon gets a composite Anthropic-mark-plus-terminal glyph for
   the claude-cli provider id.

Tests:
 - 8 unit tests for extension resolution (@fusion/pi-claude-cli is
   workspace-linked so these run in-tree).
 - 2 unit tests for the binary probe.
 - Existing /auth/status tests filter out the new synthetic entry so
   they keep asserting structural OAuth/API-key behavior in isolation.
 - The vendored package's own 296 tests still pass unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:02:34 -07:00

273 lines
7.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
// Hoist mock references so they survive vi.mock hoisting
const mocks = vi.hoisted(() => ({
writeFileSync: vi.fn(),
tmpdir: vi.fn(() => "/tmp"),
}));
// Mock node:fs writeFileSync to avoid disk I/O
vi.mock("node:fs", () => ({
writeFileSync: mocks.writeFileSync,
}));
// Mock node:os tmpdir
vi.mock("node:os", () => ({
tmpdir: mocks.tmpdir,
}));
import { getCustomToolDefs, writeMcpConfig } from "../src/mcp-config";
import type { McpToolDef } from "../src/mcp-config";
describe("getCustomToolDefs", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("filters out all 6 built-in tools and returns only custom tools", () => {
const mockPi = {
getAllTools: vi.fn(() => [
{
name: "read",
description: "Read file",
parameters: { type: "object" },
},
{
name: "write",
description: "Write file",
parameters: { type: "object" },
},
{
name: "edit",
description: "Edit file",
parameters: { type: "object" },
},
{
name: "bash",
description: "Run bash",
parameters: { type: "object" },
},
{ name: "grep", description: "Search", parameters: { type: "object" } },
{
name: "find",
description: "Find files",
parameters: { type: "object" },
},
{
name: "search",
description: "Custom search tool",
parameters: {
type: "object",
properties: { query: { type: "string" } },
},
},
{
name: "deploy",
description: "Deploy app",
parameters: {
type: "object",
properties: { target: { type: "string" } },
},
},
]),
};
const result = getCustomToolDefs(mockPi);
expect(result).toHaveLength(2);
expect(result[0].name).toBe("search");
expect(result[1].name).toBe("deploy");
});
it("returns empty array when all tools are built-in", () => {
const mockPi = {
getAllTools: vi.fn(() => [
{
name: "read",
description: "Read file",
parameters: { type: "object" },
},
{
name: "write",
description: "Write file",
parameters: { type: "object" },
},
{
name: "edit",
description: "Edit file",
parameters: { type: "object" },
},
{
name: "bash",
description: "Run bash",
parameters: { type: "object" },
},
{ name: "grep", description: "Search", parameters: { type: "object" } },
{
name: "find",
description: "Find files",
parameters: { type: "object" },
},
]),
};
const result = getCustomToolDefs(mockPi);
expect(result).toEqual([]);
});
it("includes custom tool with correct name, description, inputSchema from parameters", () => {
const customParams = {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
limit: { type: "number" },
},
required: ["query"],
};
const mockPi = {
getAllTools: vi.fn(() => [
{
name: "custom_search",
description: "Search the codebase",
parameters: customParams,
},
]),
};
const result = getCustomToolDefs(mockPi);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("custom_search");
expect(result[0].description).toBe("Search the codebase");
expect(result[0].inputSchema).toBe(customParams);
});
it("handles pi.getAllTools() returning empty array", () => {
const mockPi = {
getAllTools: vi.fn(() => []),
};
const result = getCustomToolDefs(mockPi);
expect(result).toEqual([]);
});
it("returns empty array when pi.getAllTools() returns undefined", () => {
const mockPi = {
getAllTools: vi.fn(() => undefined),
};
const result = getCustomToolDefs(mockPi);
expect(result).toEqual([]);
});
it("returns empty array when pi.getAllTools() returns null", () => {
const mockPi = {
getAllTools: vi.fn(() => null),
};
const result = getCustomToolDefs(mockPi);
expect(result).toEqual([]);
});
});
describe("writeMcpConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.tmpdir.mockReturnValue("/tmp");
});
it("writes schema file to tmpdir with correct content (JSON array of tool defs)", () => {
const toolDefs: McpToolDef[] = [
{
name: "search",
description: "Search",
inputSchema: { type: "object" },
},
];
writeMcpConfig(toolDefs);
// First writeFileSync call is the schema file
const schemaCall = mocks.writeFileSync.mock.calls[0];
expect(schemaCall[0]).toMatch(/pi-claude-mcp-schemas/);
expect(JSON.parse(schemaCall[1])).toEqual(toolDefs);
});
it("writes config file to tmpdir with mcpServers.custom-tools entry", () => {
const toolDefs: McpToolDef[] = [
{
name: "search",
description: "Search",
inputSchema: { type: "object" },
},
];
writeMcpConfig(toolDefs);
// Second writeFileSync call is the config file
const configCall = mocks.writeFileSync.mock.calls[1];
const config = JSON.parse(configCall[1]);
expect(config).toHaveProperty("mcpServers");
expect(config.mcpServers).toHaveProperty("custom-tools");
});
it("config uses 'command': 'node' format (not 'type': 'http')", () => {
const toolDefs: McpToolDef[] = [
{
name: "search",
description: "Search",
inputSchema: { type: "object" },
},
];
writeMcpConfig(toolDefs);
const configCall = mocks.writeFileSync.mock.calls[1];
const config = JSON.parse(configCall[1]);
const server = config.mcpServers["custom-tools"];
expect(server.command).toBe("node");
expect(server).not.toHaveProperty("type");
});
it("config args include path to mcp-schema-server.cjs and schema file path", () => {
const toolDefs: McpToolDef[] = [
{
name: "search",
description: "Search",
inputSchema: { type: "object" },
},
];
writeMcpConfig(toolDefs);
const configCall = mocks.writeFileSync.mock.calls[1];
const config = JSON.parse(configCall[1]);
const server = config.mcpServers["custom-tools"];
expect(server.args).toHaveLength(2);
// First arg should be the server .cjs path (normalize separators for Windows)
expect(server.args[0].replace(/\\/g, "/")).toContain(
"mcp-schema-server.cjs",
);
// Second arg should be the schema file path
expect(server.args[1]).toMatch(/pi-claude-mcp-schemas/);
});
it("returns the config file path", () => {
const toolDefs: McpToolDef[] = [
{
name: "search",
description: "Search",
inputSchema: { type: "object" },
},
];
const result = writeMcpConfig(toolDefs);
expect(result).toMatch(/pi-claude-mcp-config/);
expect(result).toMatch(/\.json$/);
});
});