Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions src/commands/claude/__tests__/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://www.gnu.org/licenses/>.
**/
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
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 7 additions & 5 deletions src/commands/claude/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
});
Expand Down
Loading