diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index bb106dfdf1..5b285e8bf4 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -3453,7 +3453,10 @@ function getApiToken() { } function getEnvApiToken() { - return process.env.LOOPOVER_API_TOKEN ?? process.env.LOOPOVER_TOKEN ?? process.env.LOOPOVER_MCP_TOKEN; + // Precedence matches the documented order (README, printHelp, the missing-auth error, and the + // sanitizer list): the MCP-specific token wins over the generic LOOPOVER_TOKEN, which previously + // took priority here and contradicted every other reference to this order. + return process.env.LOOPOVER_API_TOKEN ?? process.env.LOOPOVER_MCP_TOKEN ?? process.env.LOOPOVER_TOKEN; } function selectedProfileName(options = {}) { diff --git a/test/unit/mcp-cli-env-token-precedence.test.ts b/test/unit/mcp-cli-env-token-precedence.test.ts new file mode 100644 index 0000000000..7b77f1331e --- /dev/null +++ b/test/unit/mcp-cli-env-token-precedence.test.ts @@ -0,0 +1,64 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, runAsync, startFixtureServer } from "./support/mcp-cli-harness"; + +// #6262: getEnvApiToken() resolved LOOPOVER_TOKEN ahead of LOOPOVER_MCP_TOKEN, the opposite of the order +// documented everywhere else (README, printHelp, the missing-auth error, the diagnostic sanitizer list all +// read "LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, LOOPOVER_TOKEN"). With both LOOPOVER_MCP_TOKEN and +// LOOPOVER_TOKEN set, the code picked LOOPOVER_TOKEN — so a user setting the MCP-specific token got the +// generic one instead. These tests pin the exact runtime precedence by observing which token the CLI +// actually sends as `Authorization: Bearer `, so the code can never silently drift from the docs again. +describe("loopover-mcp CLI — env token precedence", () => { + let tempDir: string | null = null; + + afterEach(async () => { + await closeFixtureServer(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + }); + + async function whoamiWith(tokens: Record) { + tempDir = mkdtempSync(join(tmpdir(), "loopover-cli-")); + const requests: Array<{ url: string | undefined; authorization: string | undefined }> = []; + const url = await startFixtureServer({ + onApiRequest: (request) => requests.push({ url: request.url, authorization: request.headers.authorization }), + }); + const whoami = JSON.parse( + await runAsync(["whoami", "--json"], { + LOOPOVER_API_URL: url, + LOOPOVER_CONFIG_DIR: tempDir, + LOOPOVER_SKIP_NPM_VERSION_CHECK: "true", + ...tokens, + }), + ) as { login: string }; + const sessionRequest = requests.find((request) => request.url === "/v1/auth/session"); + return { login: whoami.login, authorization: sessionRequest?.authorization }; + } + + it("prefers LOOPOVER_API_TOKEN over LOOPOVER_MCP_TOKEN and LOOPOVER_TOKEN", async () => { + const result = await whoamiWith({ + LOOPOVER_API_TOKEN: "session-jsonbored", + LOOPOVER_MCP_TOKEN: "session-okto", + LOOPOVER_TOKEN: "session-token", + }); + expect(result.authorization).toBe("Bearer session-jsonbored"); + expect(result.login).toBe("JSONbored"); + }); + + it("prefers LOOPOVER_MCP_TOKEN over LOOPOVER_TOKEN when LOOPOVER_API_TOKEN is absent", async () => { + const result = await whoamiWith({ + LOOPOVER_MCP_TOKEN: "session-okto", + LOOPOVER_TOKEN: "session-token", + }); + expect(result.authorization).toBe("Bearer session-okto"); + expect(result.login).toBe("oktofeesh1"); + }); + + it("falls back to LOOPOVER_TOKEN when it is the only token set", async () => { + const result = await whoamiWith({ LOOPOVER_TOKEN: "session-token" }); + expect(result.authorization).toBe("Bearer session-token"); + expect(result.login).toBe("JSONbored"); + }); +});