diff --git a/src/commands/claude/__tests__/mcp.test.ts b/src/commands/claude/__tests__/mcp.test.ts index 8dfdda05..8346defd 100644 --- a/src/commands/claude/__tests__/mcp.test.ts +++ b/src/commands/claude/__tests__/mcp.test.ts @@ -8,8 +8,8 @@ This file is part of @p0security/cli You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see . **/ -import { clientPath, spawnClaude } from "../mcp"; -import { describe, expect, it } from "vitest"; +import { clientPath, provisionServer, spawnClaude } from "../mcp"; +import { describe, expect, it, vi } from "vitest"; // Stands in for the `claude` executable: these exercise the real child // process wiring, which is the thing that was broken. Run node rather than a @@ -41,6 +41,47 @@ describe("clientPath", () => { }); }); +describe("MCP client secret debug output", () => { + it("reports that the secret is set without logging its value", async () => { + const secret = "sentinel-oauth-client-secret"; + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const runClaude = vi.fn().mockResolvedValue(undefined); + + try { + await provisionServer( + { + debug: true, + callbackPort: 52566, + scope: undefined, + server: "server", + }, + { + client: { + id: "client-id", + redirectUri: "http://localhost:52566", + secret, + }, + server: { id: "client-id", url: "https://example.com" }, + }, + { server: { id: "server-id", url: "https://example.com" } }, + "claude", + runClaude + ); + + expect(error).toHaveBeenCalledWith("Client secret", "set"); + expect(error.mock.calls.flat().join(" ")).not.toContain(secret); + expect(runClaude).toHaveBeenCalledWith( + "claude", + expect.any(Array), + expect.objectContaining({ MCP_CLIENT_SECRET: secret }) + ); + expect(runClaude.mock.calls[0]![1]).not.toContain(secret); + } finally { + error.mockRestore(); + } + }); +}); + describe("spawnClaude", () => { it("resolves when claude exits 0", async () => { await expect(node("process.exit(0)")).resolves.toBeUndefined(); diff --git a/src/commands/claude/mcp.ts b/src/commands/claude/mcp.ts index 4a516d38..4f8b44af 100644 --- a/src/commands/claude/mcp.ts +++ b/src/commands/claude/mcp.ts @@ -222,12 +222,14 @@ const getClaudeFile = async () => { } }; -const provisionServer = async ( +export const provisionServer = async ( argv: AddMcpServerArgs, { client }: CreateMcpClientResp, - { server }: GetMcpServerResp + { server }: GetMcpServerResp, + claudeFileOverride?: string, + runClaude: typeof spawnClaude = spawnClaude ) => { - const claudeFile = await getClaudeFile(); + const claudeFile = claudeFileOverride ?? (await getClaudeFile()); assert(client.secret, "No client secret"); debug(argv, "Server", server); // Claude Code's `mcp add-json` doesn't accept oauth fields in its JSON @@ -254,12 +256,12 @@ const provisionServer = async ( server.id, server.url, ]; - debug(argv, "Client secret", client.secret); + debug(argv, "Client secret", "set"); debug(argv, ["claude", ...args].join(" ")); // Spread process.env so the spawned `claude` inherits PATH / HOME / // NODE_OPTIONS / etc. (`env: { MCP_CLIENT_SECRET }` alone would replace // the whole environment). - await spawnClaude(claudeFile, args, { + await runClaude(claudeFile, args, { ...process.env, MCP_CLIENT_SECRET: client.secret, });