Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/codex-mcp-bearer-token-syntax.md
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ claude mcp add --transport http clickstack <your-hyperdx-url>/api/mcp \
--header "Authorization: Bearer <your-personal-access-key>"
```

### 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="<your-personal-access-key>"
codex mcp add clickstack --url <your-hyperdx-url>/api/mcp \
--bearer-token-env-var CLICKSTACK_ACCESS_KEY
```

### OpenCode

Add the following to your [OpenCode config](https://opencode.ai/docs/config):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
buildAllSnippets,
CODEX_TOKEN_ENV_VAR,
type DeploymentShape,
SERVER_NAME,
} from '@/components/ClickStackOnboarding/installSnippets';
Expand All @@ -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', () => {
Expand Down Expand Up @@ -156,6 +165,12 @@ describe('buildAllSnippets > placeholder fallbacks', () => {
expect(claudeCode).toContain('Bearer <accessKey>');
});

it('falls back to <accessKey> in the Codex export when the key is empty', () => {
const { codexCli } = buildAllSnippets({ ...DEPLOYMENT, accessKey: '' });

expect(codexCli).toContain(`export ${CODEX_TOKEN_ENV_VAR}="<accessKey>"`);
});

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 `"`,
Expand All @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <NAME>` 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;
Expand Down Expand Up @@ -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, string>): string {
Expand All @@ -125,17 +152,32 @@ function headerArgs(headers: Record<string, string>): string {
}

/**
* Shared one-liner builder for CLI hosts whose `mcp add` primitive
* matches Claude Code's documented shape: `<binary> mcp add <name>
* --transport http <url> --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 <name>
* --transport http <url> --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 <name> --url <url>` and bearer auth is
* supplied via `--bearer-token-env-var <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 || '<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}`;
}

/**
Expand Down Expand Up @@ -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),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading