diff --git a/.changeset/codex-mcp-bearer-token-syntax.md b/.changeset/codex-mcp-bearer-token-syntax.md new file mode 100644 index 0000000000..12aca69f65 --- /dev/null +++ b/.changeset/codex-mcp-bearer-token-syntax.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +fix(mcp): update the Codex CLI install snippet to the current `codex mcp add --url ... --bearer-token-env-var ...` syntax diff --git a/MCP.md b/MCP.md index 312276cb57..0ba75bddcf 100644 --- a/MCP.md +++ b/MCP.md @@ -45,6 +45,18 @@ claude mcp add --transport http clickstack /api/mcp \ --header "Authorization: Bearer " ``` +### Codex CLI + +The Codex CLI reads the bearer token from an environment variable rather than a +`--header` flag. Export your personal access key, then register the server with +`--bearer-token-env-var`: + +```bash +export CLICKSTACK_ACCESS_KEY="" +codex mcp add clickstack --url /api/mcp \ + --bearer-token-env-var CLICKSTACK_ACCESS_KEY +``` + ### OpenCode Add the following to your [OpenCode config](https://opencode.ai/docs/config): diff --git a/packages/app/src/components/ClickStackOnboarding/__tests__/installSnippets.test.ts b/packages/app/src/components/ClickStackOnboarding/__tests__/installSnippets.test.ts index ea5c66d17e..df3920d310 100644 --- a/packages/app/src/components/ClickStackOnboarding/__tests__/installSnippets.test.ts +++ b/packages/app/src/components/ClickStackOnboarding/__tests__/installSnippets.test.ts @@ -1,5 +1,6 @@ import { buildAllSnippets, + CODEX_TOKEN_ENV_VAR, type DeploymentShape, SERVER_NAME, } from '@/components/ClickStackOnboarding/installSnippets'; @@ -20,13 +21,21 @@ describe('buildAllSnippets > Claude Code', () => { }); describe('buildAllSnippets > Codex CLI', () => { - it('emits the OpenAI Codex CLI mcp add command', () => { + it('emits the current OpenAI Codex CLI mcp add command with a bearer-token env var', () => { const { codexCli } = buildAllSnippets(DEPLOYMENT); expect(codexCli).toBe( - `codex mcp add ${SERVER_NAME} --transport http https://hyperdx.example.com/api/mcp --header "Authorization: Bearer k_abcdef123456"`, + `export ${CODEX_TOKEN_ENV_VAR}="k_abcdef123456"\n` + + `codex mcp add ${SERVER_NAME} --url https://hyperdx.example.com/api/mcp --bearer-token-env-var ${CODEX_TOKEN_ENV_VAR}`, ); }); + + it('does NOT use the deprecated --transport or --header flags', () => { + const { codexCli } = buildAllSnippets(DEPLOYMENT); + + expect(codexCli).not.toContain('--transport'); + expect(codexCli).not.toContain('--header'); + }); }); describe('buildAllSnippets > Cursor', () => { @@ -156,6 +165,12 @@ describe('buildAllSnippets > placeholder fallbacks', () => { expect(claudeCode).toContain('Bearer '); }); + it('falls back to in the Codex export when the key is empty', () => { + const { codexCli } = buildAllSnippets({ ...DEPLOYMENT, accessKey: '' }); + + expect(codexCli).toContain(`export ${CODEX_TOKEN_ENV_VAR}=""`); + }); + it('escapes shell metacharacters in header values', () => { // Today's access keys are UUIDv4 with no metacharacters; the // escape is defensive against future formats that allow `"`, @@ -168,6 +183,20 @@ describe('buildAllSnippets > placeholder fallbacks', () => { expect(claudeCode).toContain('Bearer k\\"\\$\\`\\\\suffix"'); }); + + it('escapes shell metacharacters in the Codex export value', () => { + // The bearer token lands on the right-hand side of an `export`, + // so the same shell-injection defense applies to the Codex + // snippet's quoted value. + const { codexCli } = buildAllSnippets({ + ...DEPLOYMENT, + accessKey: 'k"$`\\suffix', + }); + + expect(codexCli).toContain( + 'export ' + CODEX_TOKEN_ENV_VAR + '="k\\"\\$\\`\\\\suffix"', + ); + }); }); describe('buildAllSnippets > host coverage', () => { diff --git a/packages/app/src/components/ClickStackOnboarding/installSnippets.ts b/packages/app/src/components/ClickStackOnboarding/installSnippets.ts index 61689516c6..2285a51b4a 100644 --- a/packages/app/src/components/ClickStackOnboarding/installSnippets.ts +++ b/packages/app/src/components/ClickStackOnboarding/installSnippets.ts @@ -20,6 +20,17 @@ */ export const SERVER_NAME = 'clickstack'; +/** + * Environment variable the Codex CLI snippet reads the bearer token + * from. The current `codex mcp add` command has no `--header` flag; + * for a Streamable HTTP server with bearer auth it accepts + * `--bearer-token-env-var ` and reads the token from that env + * var at connect time (see https://developers.openai.com/codex/mcp). + * We emit an `export` line for the same name so the copy-paste is + * self-contained. + */ +export const CODEX_TOKEN_ENV_VAR = 'CLICKSTACK_ACCESS_KEY'; + export interface DeploymentShape { /** Origin used to build the MCP URL, e.g. `https://example.com/api`. */ apiUrl: string; @@ -106,16 +117,32 @@ function buildUrl(deployment: DeploymentShape): string { return `${base}/mcp`; } +/** + * Escape a value for safe inclusion inside a double-quoted shell + * argument. Defensive: today's `accessKey` is a UUIDv4 with no + * shell metacharacters, but a future format that permits `"`, `$`, + * `\`, or backtick would otherwise turn a copy-paste install into a + * shell-injection vector. + */ +function shellEscape(value: string): string { + return value.replace(/(["\\$`])/g, '\\$1'); +} + +/** + * Wrap a value in double quotes with the metacharacters escaped, + * for use as a standalone shell argument (e.g. the right-hand side + * of an `export`). + */ +function shellQuoteValue(value: string): string { + return `"${shellEscape(value)}"`; +} + /** * Quote and escape a header value for safe inclusion inside a - * double-quoted shell argument. Defensive: today's `accessKey` is - * a UUIDv4 with no shell metacharacters, but a future format that - * permits `"`, `$`, `\`, or backtick would otherwise turn a - * copy-paste install into a shell-injection vector. + * double-quoted `--header "Name: value"` shell argument. */ function shellQuoteHeader(name: string, value: string): string { - const escaped = value.replace(/(["\\$`])/g, '\\$1'); - return `--header "${name}: ${escaped}"`; + return `--header "${name}: ${shellEscape(value)}"`; } function headerArgs(headers: Record): string { @@ -125,17 +152,32 @@ function headerArgs(headers: Record): string { } /** - * Shared one-liner builder for CLI hosts whose `mcp add` primitive - * matches Claude Code's documented shape: ` mcp add - * --transport http --header "..."`. Codex CLI mirrors this - * verbatim (see https://developers.openai.com/codex/mcp), so the - * two hosts share the same generator and differ only in the binary - * name. + * Claude Code `mcp add` one-liner: `claude mcp add + * --transport http --header "..."`. Claude passes the bearer + * token inline as an `Authorization` header. */ -function buildCliOneLiner(binary: string, deployment: DeploymentShape): string { +function buildClaudeOneLiner(deployment: DeploymentShape): string { const url = buildUrl(deployment); const headers = buildHeaders(deployment); - return `${binary} mcp add ${SERVER_NAME} --transport http ${url} ${headerArgs(headers)}`; + return `claude mcp add ${SERVER_NAME} --transport http ${url} ${headerArgs(headers)}`; +} + +/** + * OpenAI Codex CLI `mcp add` command. Unlike Claude Code, the + * current Codex CLI (see https://developers.openai.com/codex/mcp) + * does not accept `--transport` or `--header`; its Streamable HTTP + * form is `codex mcp add --url ` and bearer auth is + * supplied via `--bearer-token-env-var `, which reads the + * token from the named environment variable rather than embedding + * it in the command. We prepend an `export` for that variable so + * the snippet is a self-contained copy-paste. + */ +function buildCodexOneLiner(deployment: DeploymentShape): string { + const url = buildUrl(deployment); + const token = deployment.accessKey || ''; + const exportLine = `export ${CODEX_TOKEN_ENV_VAR}=${shellQuoteValue(token)}`; + const addLine = `codex mcp add ${SERVER_NAME} --url ${url} --bearer-token-env-var ${CODEX_TOKEN_ENV_VAR}`; + return `${exportLine}\n${addLine}`; } /** @@ -221,10 +263,10 @@ function buildMcpJsonBlock(deployment: DeploymentShape): string { */ export function buildAllSnippets(deployment: DeploymentShape): BuiltSnippets { return { - claudeCode: buildCliOneLiner('claude', deployment), + claudeCode: buildClaudeOneLiner(deployment), cursor: buildCursorDeeplink(deployment), vscode: buildVSCodeDeeplink(deployment), - codexCli: buildCliOneLiner('codex', deployment), + codexCli: buildCodexOneLiner(deployment), openCode: buildOpenCodeJsonBlock(deployment), jsonBlock: buildMcpJsonBlock(deployment), }; diff --git a/packages/app/src/components/TeamSettings/__tests__/McpServerSection.test.tsx b/packages/app/src/components/TeamSettings/__tests__/McpServerSection.test.tsx index d8b7484e79..c686675ad6 100644 --- a/packages/app/src/components/TeamSettings/__tests__/McpServerSection.test.tsx +++ b/packages/app/src/components/TeamSettings/__tests__/McpServerSection.test.tsx @@ -114,7 +114,11 @@ describe('McpServerSection', () => { await user.click(screen.getByText('Codex CLI')); - expect(screen.getByText(/codex mcp add clickstack /)).toBeInTheDocument(); + expect( + screen.getByText( + /codex mcp add clickstack --url .*--bearer-token-env-var/s, + ), + ).toBeInTheDocument(); expect( screen.queryByText(/^claude mcp add clickstack /), ).not.toBeInTheDocument();