diff --git a/.changeset/mcp-install-command.md b/.changeset/mcp-install-command.md new file mode 100644 index 0000000000..46d05018d6 --- /dev/null +++ b/.changeset/mcp-install-command.md @@ -0,0 +1,12 @@ +--- +'@hyperdx/cli': minor +'@hyperdx/app': patch +--- + +feat: add `hdx mcp install` to connect AI agents without pasting a key + +`npx @hyperdx/cli mcp install` asks for a personal access key or an +organization API key with hidden input, checks it, and adds the ClickStack MCP +server to Claude Code, Codex, Cursor, and VS Code. Team Settings now leads with +this command, so the key no longer has to go into a copied command or an agent +chat. The per-agent snippets remain for setting up one agent by hand. diff --git a/MCP.md b/MCP.md index c3693ed2d6..8f88349434 100644 --- a/MCP.md +++ b/MCP.md @@ -38,21 +38,34 @@ The MCP server uses the **Streamable HTTP** transport with Bearer token authentication. In the examples below, replace `` with your instance URL (e.g. `http://localhost:8080`). +### Install with the CLI (recommended) + +This asks for a personal access key or an organization API key with hidden +input, checks it, and adds the server to Claude Code, Codex, Cursor and VS Code. +The key stays out of your shell history and agent chats. + +```bash +npx @hyperdx/cli mcp install --app-url +``` + ### Claude Code +`read -rs` waits for you to paste the key and press Enter, without echoing it, +so the key isn't saved in your shell history: + ```bash -claude mcp add --transport http clickstack /api/mcp \ - --header "Authorization: Bearer " +read -rs CLICKSTACK_ACCESS_KEY +claude mcp add --scope user --transport http clickstack /api/mcp \ + --header "Authorization: Bearer $CLICKSTACK_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`: +`--header` flag, so the variable must be set wherever Codex runs: ```bash -export CLICKSTACK_ACCESS_KEY="" +read -rs CLICKSTACK_ACCESS_KEY && export CLICKSTACK_ACCESS_KEY codex mcp add clickstack --url /api/mcp \ --bearer-token-env-var CLICKSTACK_ACCESS_KEY ``` diff --git a/knip.json b/knip.json index 7d88c3d288..919f8a2343 100644 --- a/knip.json +++ b/knip.json @@ -33,6 +33,9 @@ "ignoreDependencies": ["@stryker-mutator/core"], "ignoreBinaries": ["stryker"] }, + "packages/cli": { + "ignoreBinaries": ["claude"] + }, "packages/hdx-eval": { "project": ["src/**/*.ts"], "ignoreDependencies": ["tsx"], diff --git a/packages/app/src/components/ClickStackOnboarding/McpInstallPanel.tsx b/packages/app/src/components/ClickStackOnboarding/McpInstallPanel.tsx index a4e36d77ec..6851a0a1db 100644 --- a/packages/app/src/components/ClickStackOnboarding/McpInstallPanel.tsx +++ b/packages/app/src/components/ClickStackOnboarding/McpInstallPanel.tsx @@ -2,30 +2,25 @@ import { useMemo, useState } from 'react'; import { Group, SegmentedControl, Stack, Text } from '@mantine/core'; import { IconBraces, - IconBrandOpenai, IconBrandVisualStudio, IconCode, IconRobot, - IconTerminal2, } from '@tabler/icons-react'; import { CopySnippet } from './CopySnippet'; import { DeeplinkInstall } from './DeeplinkInstall'; import { buildAllSnippets, + buildCliInstallCommand, type BuiltSnippets, type DeploymentShape, } from './installSnippets'; // ChatGPT is intentionally absent: no native MCP yet, and bridges are a -// user-side decision better tracked in docs than in this UI. -type AgentHost = - | 'claude-code' - | 'cursor' - | 'vscode-copilot' - | 'codex-cli' - | 'opencode' - | 'other'; +// user-side decision better tracked in docs than in this UI. Claude Code +// and Codex are configured through shell commands, which would put the key +// in shell history, so the CLI installer above covers them instead. +type AgentHost = 'cursor' | 'vscode-copilot' | 'opencode' | 'other'; interface HostChoice { id: AgentHost; @@ -33,10 +28,8 @@ interface HostChoice { } const CHOICES: HostChoice[] = [ - { id: 'claude-code', label: 'Claude Code' }, { id: 'cursor', label: 'Cursor' }, { id: 'vscode-copilot', label: 'VS Code' }, - { id: 'codex-cli', label: 'Codex CLI' }, { id: 'opencode', label: 'OpenCode' }, { id: 'other', label: 'Other' }, ]; @@ -57,12 +50,19 @@ interface McpInstallPanelProps { * block) for the chosen host. The access key is masked in every snippet. */ export default function McpInstallPanel({ deployment }: McpInstallPanelProps) { - const [host, setHost] = useState('claude-code'); + const [host, setHost] = useState('cursor'); const snippets = useMemo(() => buildAllSnippets(deployment), [deployment]); return ( + + + Or set up one agent by hand: + ; case 'cursor': return ; case 'vscode-copilot': return ; - case 'codex-cli': - return ; case 'opencode': return ; case 'other': @@ -124,15 +120,6 @@ interface HostInstallProps { function HostInstall({ host, snippets, accessKey }: HostInstallProps) { switch (host) { - case 'claude-code': - return ( - - ); - case 'cursor': return ( ); - case 'codex-cli': - return ( - - ); - case 'opencode': return ( { + it('shows the npx command, which carries no access key', () => { + renderPanel(); + expect( + screen.getByText( + 'npx @hyperdx/cli mcp install --app-url https://app.example.com', + ), + ).toBeInTheDocument(); + }); +}); + function codeText(): string { // Every masked host renders its snippet in the shared code block. return screen.getByTestId('reveal-snippet-code').textContent ?? ''; } describe('McpInstallPanel secret masking', () => { - it('masks the access key in the default (Claude Code) command snippet', () => { - renderPanel(); - - const text = codeText(); - // The raw key must never appear in the rendered snippet... - expect(text).not.toContain(ACCESS_KEY); - // ...but the masked stand-in (visible prefix + dots) should. - expect(text).toContain(defaultRedact(ACCESS_KEY)); - }); - it('masks the access key in the JSON fallback (Other) snippet', async () => { const user = userEvent.setup(); renderPanel(); @@ -53,6 +54,7 @@ describe('McpInstallPanel secret masking', () => { it('reveals the raw key only after an explicit reveal action', async () => { const user = userEvent.setup(); renderPanel(); + await user.click(screen.getByRole('radio', { name: /other/i })); expect(codeText()).not.toContain(ACCESS_KEY); diff --git a/packages/app/src/components/ClickStackOnboarding/__tests__/installSnippets.test.ts b/packages/app/src/components/ClickStackOnboarding/__tests__/installSnippets.test.ts index df3920d310..fae76810e7 100644 --- a/packages/app/src/components/ClickStackOnboarding/__tests__/installSnippets.test.ts +++ b/packages/app/src/components/ClickStackOnboarding/__tests__/installSnippets.test.ts @@ -1,6 +1,5 @@ import { buildAllSnippets, - CODEX_TOKEN_ENV_VAR, type DeploymentShape, SERVER_NAME, } from '@/components/ClickStackOnboarding/installSnippets'; @@ -10,34 +9,6 @@ const DEPLOYMENT: DeploymentShape = { accessKey: 'k_abcdef123456', }; -describe('buildAllSnippets > Claude Code', () => { - it('emits the documented Claude Code MCP install one-liner', () => { - const { claudeCode } = buildAllSnippets(DEPLOYMENT); - - expect(claudeCode).toBe( - `claude mcp add ${SERVER_NAME} --transport http https://hyperdx.example.com/api/mcp --header "Authorization: Bearer k_abcdef123456"`, - ); - }); -}); - -describe('buildAllSnippets > Codex CLI', () => { - it('emits the current OpenAI Codex CLI mcp add command with a bearer-token env var', () => { - const { codexCli } = buildAllSnippets(DEPLOYMENT); - - expect(codexCli).toBe( - `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', () => { it('emits a cursor:// URL with a URL-safe base64-encoded config that round-trips', () => { const { cursor } = buildAllSnippets(DEPLOYMENT); @@ -158,56 +129,13 @@ describe('buildAllSnippets > JSON block', () => { }); }); -describe('buildAllSnippets > placeholder fallbacks', () => { - it('falls back to in the snippet when the key is empty', () => { - const { claudeCode } = buildAllSnippets({ ...DEPLOYMENT, accessKey: '' }); - - 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 `"`, - // `$`, `\`, or backtick. Any of those would otherwise turn a - // copy-paste install into a shell-injection vector. - const { claudeCode } = buildAllSnippets({ - ...DEPLOYMENT, - accessKey: 'k"$`\\suffix', - }); - - 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', () => { it('returns a populated string for every supported host', () => { const all = buildAllSnippets(DEPLOYMENT); expect(all).toMatchObject({ - claudeCode: expect.stringContaining(`claude mcp add ${SERVER_NAME}`), cursor: expect.stringContaining('cursor://'), vscode: expect.stringContaining('vscode:mcp/install'), - codexCli: expect.stringContaining(`codex mcp add ${SERVER_NAME}`), openCode: expect.stringContaining(`"${SERVER_NAME}"`), jsonBlock: expect.stringContaining(`"${SERVER_NAME}"`), }); @@ -216,8 +144,6 @@ describe('buildAllSnippets > host coverage', () => { it('keys the JSON block on the same fixed server name in every output', () => { const all = buildAllSnippets(DEPLOYMENT); - expect(all.claudeCode).toContain(SERVER_NAME); - expect(all.codexCli).toContain(SERVER_NAME); expect(all.cursor).toContain(`name=${SERVER_NAME}`); expect(all.vscode).toContain(SERVER_NAME); expect(all.openCode).toContain(`"${SERVER_NAME}"`); diff --git a/packages/app/src/components/ClickStackOnboarding/installSnippets.ts b/packages/app/src/components/ClickStackOnboarding/installSnippets.ts index 7f440196c0..713f7bfc67 100644 --- a/packages/app/src/components/ClickStackOnboarding/installSnippets.ts +++ b/packages/app/src/components/ClickStackOnboarding/installSnippets.ts @@ -1,7 +1,9 @@ // Per-host snippet builders for the "Connect your AI assistant" // section. Each builder takes a `DeploymentShape` (URL + access -// key) and returns the install primitive for one host: a CLI -// one-liner, a deep link, or a JSON config block. +// key) and returns the install primitive for one host: a deep link +// or a JSON config block. Nothing here emits a shell command with the +// key in it, since that lands in shell history; `hdx mcp install` +// covers the CLI-configured hosts instead. // // Pure functions only. Component-level state (host picker) lives // in `McpInstallPanel.tsx`. Keeping the builders here makes unit @@ -20,17 +22,6 @@ */ 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; @@ -39,14 +30,10 @@ export interface DeploymentShape { } export interface BuiltSnippets { - /** Claude Code CLI one-liner. */ - claudeCode: string; /** Cursor `cursor://` deep link. */ cursor: string; /** VS Code + Copilot `vscode:mcp/install` deep link. */ vscode: string; - /** OpenAI Codex CLI one-liner. */ - codexCli: string; /** OpenCode JSON config under an `mcp` block (uses `type: "remote"`). */ openCode: string; /** Canonical `mcpServers` JSON block for any other host. */ @@ -117,69 +104,6 @@ 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 `--header "Name: value"` shell argument. - */ -function shellQuoteHeader(name: string, value: string): string { - return `--header "${name}: ${shellEscape(value)}"`; -} - -function headerArgs(headers: Record): string { - return Object.entries(headers) - .map(([key, value]) => shellQuoteHeader(key, value)) - .join(' '); -} - -/** - * Claude Code `mcp add` one-liner: `claude mcp add - * --transport http --header "..."`. Claude passes the bearer - * token inline as an `Authorization` header. - */ -function buildClaudeOneLiner(deployment: DeploymentShape): string { - const url = buildUrl(deployment); - const headers = buildHeaders(deployment); - 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}`; -} - /** * Cursor `cursor://` deep link. Documented Cursor MCP install * scheme: name in the query string, config as base64-encoded JSON. @@ -262,6 +186,15 @@ function buildMcpJsonBlock(deployment: DeploymentShape): string { return JSON.stringify(block, null, 2); } +/** + * `npx` command for the CLI installer. It prompts for the API key itself, so + * the key never appears in a copied command or shell history. + */ +export function buildCliInstallCommand(deployment: DeploymentShape): string { + const appUrl = deployment.apiUrl.replace(/\/api\/?$/, ''); + return `npx @hyperdx/cli mcp install --app-url ${appUrl}`; +} + /** * Build every host's snippet in one call. The component pulls the * field matching the current host out of the result; tests assert @@ -269,10 +202,8 @@ function buildMcpJsonBlock(deployment: DeploymentShape): string { */ export function buildAllSnippets(deployment: DeploymentShape): BuiltSnippets { return { - claudeCode: buildClaudeOneLiner(deployment), cursor: buildCursorDeeplink(deployment), vscode: buildVSCodeDeeplink(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 c686675ad6..49c835d505 100644 --- a/packages/app/src/components/TeamSettings/__tests__/McpServerSection.test.tsx +++ b/packages/app/src/components/TeamSettings/__tests__/McpServerSection.test.tsx @@ -67,15 +67,16 @@ describe('McpServerSection', () => { expect(screen.queryByTestId('mcp-server-section')).not.toBeInTheDocument(); }); - it('renders all six host options when the deployment shape is valid', () => { + it('offers only file and deep-link hosts for manual setup', () => { renderSection(); - expect(screen.getByText('Claude Code')).toBeInTheDocument(); expect(screen.getByText('Cursor')).toBeInTheDocument(); expect(screen.getByText('VS Code')).toBeInTheDocument(); - expect(screen.getByText('Codex CLI')).toBeInTheDocument(); expect(screen.getByText('OpenCode')).toBeInTheDocument(); expect(screen.getByText('Other')).toBeInTheDocument(); + // Their setup is a shell command with the key in it; the CLI covers them. + expect(screen.queryByText('Claude Code')).not.toBeInTheDocument(); + expect(screen.queryByText('Codex CLI')).not.toBeInTheDocument(); }); // Auth-gate parity with `ApiKeysSection`: when `me` resolves to @@ -89,10 +90,12 @@ describe('McpServerSection', () => { expect(screen.queryByTestId('mcp-server-section')).not.toBeInTheDocument(); }); - it('renders the fixed clickstack server name in the install snippet', () => { + it('leads with the CLI installer command', () => { renderSection(); - expect(screen.getByText(/claude mcp add clickstack /)).toBeInTheDocument(); + expect( + screen.getByText(/^npx @hyperdx\/cli mcp install --app-url /), + ).toBeInTheDocument(); }); // `MeApiResponseSchema.accessKey` is `z.string()` non-nullable @@ -108,22 +111,6 @@ describe('McpServerSection', () => { expect(screen.queryByTestId('mcp-server-section')).not.toBeInTheDocument(); }); - it('switches to the Codex CLI snippet when the host picker selects Codex CLI', async () => { - const user = userEvent.setup(); - renderSection(); - - await user.click(screen.getByText('Codex CLI')); - - expect( - screen.getByText( - /codex mcp add clickstack --url .*--bearer-token-env-var/s, - ), - ).toBeInTheDocument(); - expect( - screen.queryByText(/^claude mcp add clickstack /), - ).not.toBeInTheDocument(); - }); - it('renders the Cursor deeplink button with a cursor:// href when Cursor is selected', async () => { const user = userEvent.setup(); renderSection(); diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index aa1bc2c657..a2414af7da 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -34,6 +34,7 @@ hdx auth logout # Clear saved session hdx team list # List teams the user belongs to (multi-team) hdx team current # Show the active team hdx team use # Switch the active team (kubectx-style) +hdx mcp install # Add the MCP server to Claude Code, Codex, Cursor, VS Code (interactive only: agents hand it to the user) hdx upload-sourcemaps # Upload JS source maps ``` @@ -73,6 +74,7 @@ src/ │ ├── ColumnValues.tsx # Shared key-value renderer (used by Column Values tab + Event Details) │ ├── LoginForm.tsx # Email/password login form (used inside TUI App) │ └── SourcePicker.tsx # Arrow-key source selector +├── mcp/ # `hdx mcp install`: key check, per-agent config writers, Ink prompt ├── shared/ │ ├── tileConfig.ts # Tile → queryable chart config resolution + per-displayType │ │ # transforms (ports of web DBDashboardPage / ChartUtils) diff --git a/packages/cli/README.md b/packages/cli/README.md index 5a26803960..7d3f6da7a7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -69,6 +69,7 @@ hdx connections # List ClickHouse connections hdx dashboards # List dashboards with tile summaries hdx auth login|status|logout # Manage authentication hdx team list|current|use # Switch between teams +hdx mcp install # Add the MCP server to your AI agents hdx upload-sourcemaps # Upload JS source maps ``` @@ -214,6 +215,20 @@ hdx team use "My Team" # Switch by name or ID The choice persists across CLI invocations. On single-team deployments these commands are effectively no-ops. +### `hdx mcp install` — Connect AI agents + +Adds the ClickStack MCP server to every supported agent it finds: Claude Code, +Codex, Cursor, and VS Code. It asks for a personal access key or an +organization API key with hidden input and checks it before changing any +config, so the key stays out of shell history and agent chats. + +```bash +npx @hyperdx/cli mcp install -a https://your-hyperdx.example +``` + +The key is stored in plain text in each agent's user config, which the +command lists when it finishes. Running it again replaces the existing entry. + ### `hdx upload-sourcemaps` — Source maps Upload JavaScript source maps to HyperDX for stack trace de-obfuscation. Run it diff --git a/packages/cli/src/__tests__/mcpInstall.test.ts b/packages/cli/src/__tests__/mcpInstall.test.ts new file mode 100644 index 0000000000..f86f227b98 --- /dev/null +++ b/packages/cli/src/__tests__/mcpInstall.test.ts @@ -0,0 +1,169 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + mcpUrl, + upsertCodexServer, + upsertJsonServer, + verifyKey, +} from '@/mcp/install'; + +const server = { url: 'https://example.com/api/mcp', key: 'hdx_org_abc' }; + +let dir: string; +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hdx-mcp-')); +}); +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('mcpUrl', () => { + it('appends /api/mcp without doubling slashes', () => { + expect(mcpUrl('https://example.com/')).toBe('https://example.com/api/mcp'); + }); +}); + +describe('upsertJsonServer', () => { + it('adds the server and keeps other servers and settings', () => { + const file = path.join(dir, 'mcp.json'); + fs.writeFileSync( + file, + JSON.stringify({ theme: 'dark', mcpServers: { other: { url: 'x' } } }), + ); + upsertJsonServer(file, 'mcpServers', { url: server.url }); + expect(JSON.parse(fs.readFileSync(file, 'utf-8'))).toEqual({ + theme: 'dark', + mcpServers: { other: { url: 'x' }, clickstack: { url: server.url } }, + }); + }); + + it('creates the file and its directory with owner-only permissions', () => { + const file = path.join(dir, 'nested', 'mcp.json'); + upsertJsonServer(file, 'servers', { url: server.url }); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + }); + + it('tightens an existing config to owner-only before writing the key', () => { + const file = path.join(dir, 'mcp.json'); + fs.writeFileSync(file, '{}', { mode: 0o644 }); + upsertJsonServer(file, 'servers', { url: server.url }); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + }); + + it('refuses JSON that is not an object', () => { + const file = path.join(dir, 'mcp.json'); + fs.writeFileSync(file, 'null'); + expect(() => upsertJsonServer(file, 'servers', {})).toThrow( + /Could not parse/, + ); + }); + + it('refuses to rewrite a file it cannot parse', () => { + const file = path.join(dir, 'mcp.json'); + fs.writeFileSync(file, '{ // comment\n "servers": {} }'); + expect(() => upsertJsonServer(file, 'servers', {})).toThrow( + /Could not parse/, + ); + expect(fs.readFileSync(file, 'utf-8')).toContain('// comment'); + }); +}); + +describe('upsertCodexServer', () => { + it('replaces an existing clickstack table and keeps the rest', () => { + const file = path.join(dir, 'config.toml'); + fs.writeFileSync( + file, + [ + 'model = "gpt-5"', + '', + '[mcp_servers.clickstack]', + 'url = "https://old/api/mcp"', + '', + '[mcp_servers.clickstack.env]', + 'A = "1"', + '', + '[mcp_servers.other]', + 'command = "other"', + '', + ].join('\n'), + ); + upsertCodexServer(file, server); + const out = fs.readFileSync(file, 'utf-8'); + expect(out).toContain('model = "gpt-5"'); + expect(out).toContain('[mcp_servers.other]\ncommand = "other"'); + expect(out).not.toContain('https://old'); + expect(out).not.toContain('[mcp_servers.clickstack.env]'); + expect( + out + .trimEnd() + .endsWith( + [ + '[mcp_servers.clickstack]', + 'url = "https://example.com/api/mcp"', + 'http_headers = { Authorization = "Bearer hdx_org_abc" }', + ].join('\n'), + ), + ).toBe(true); + }); + + it('creates the config when Codex has none yet', () => { + const file = path.join(dir, '.codex', 'config.toml'); + upsertCodexServer(file, server); + expect(fs.readFileSync(file, 'utf-8')).toBe( + '[mcp_servers.clickstack]\nurl = "https://example.com/api/mcp"\nhttp_headers = { Authorization = "Bearer hdx_org_abc" }\n', + ); + }); +}); + +describe('verifyKey', () => { + const realFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = realFetch; + }); + + const respond = (status: number, body: unknown = {}) => { + const fetchMock = jest.fn(async (_url: string, _init?: RequestInit) => + Response.json(body, { status }), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; + }; + + it('names an org key by its description and checks through the app proxy', async () => { + const fetchMock = respond(200, { + user: { name: 'terraform', email: 'sa-1@x.invalid', isVirtual: true }, + }); + await expect(verifyKey('https://example.com/', 'k')).resolves.toEqual({ + name: 'terraform', + isServiceAccount: true, + }); + expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/api/v2', { + headers: { Authorization: 'Bearer k' }, + }); + }); + + it('names a personal key by email', async () => { + respond(200, { user: { name: 'Ada', email: 'ada@example.com' } }); + await expect(verifyKey('https://example.com', 'k')).resolves.toEqual({ + name: 'ada@example.com', + isServiceAccount: false, + }); + }); + + it('rejects a revoked or wrong key', async () => { + respond(401); + await expect(verifyKey('https://example.com', 'k')).rejects.toThrow( + /rejected/, + ); + }); +}); diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index e0dfb2ff74..8061522254 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -30,20 +30,13 @@ import { fetchTileData } from '@/shared/tileQuery'; import { renderTileContent } from '@/shared/tileRender'; import { clearSession, loadSession, setActiveTeam } from '@/utils/config'; import { parseTimeValue } from '@/utils/editor'; +import { isValidUrl } from '@/utils/url'; import { uploadSourcemaps } from '@/sourcemaps'; +import { mcpClients } from '@/mcp/install'; +import McpInstallPrompt, { type InstallResult } from '@/mcp/McpInstallPrompt'; // ---- Standalone interactive login for `hdx auth login` ------------- -/** Returns true if the string is a valid HTTP(S) URL. */ -function isValidUrl(value: string): boolean { - try { - const url = new URL(value); - return url.protocol === 'http:' || url.protocol === 'https:'; - } catch { - return false; - } -} - // Add new login methods here to extend the login flow. const LOGIN_METHODS = [ { id: 'password', label: 'Email / Password' }, @@ -547,6 +540,68 @@ function resolveActiveTeam( return me.team ?? null; } +// ---- MCP ----------------------------------------------------------- + +const mcp = program + .command('mcp') + .description('Connect AI agents to ClickStack over MCP') + .enablePositionalOptions() + .passThroughOptions(); + +mcp + .command('install') + .description( + 'Add the ClickStack MCP server to Claude Code, Codex, Cursor, and VS Code', + ) + .option('-a, --app-url ', 'ClickStack app URL') + .action(async opts => { + if (!process.stdin.isTTY) { + _origError( + chalk.red( + 'hdx mcp install needs an interactive terminal so the key stays out of logs and agent chats. Run it yourself in a terminal.\n', + ), + ); + process.exit(1); + } + const clients = mcpClients().filter(c => c.detect()); + if (clients.length === 0) { + _origError( + chalk.red( + 'No supported agent found (Claude Code, Codex, Cursor, VS Code).\n', + ), + ); + process.exit(1); + } + + let results: InstallResult[] | null = null; + const { waitUntilExit } = render( + { + results = r; + }} + />, + ); + await waitUntilExit(); + + const done = results as InstallResult[] | null; + if (done == null) { + process.stdout.write('Cancelled. No configs were changed.\n'); + return; + } + for (const { client, error } of done) { + process.stdout.write( + error + ? chalk.red(`✗ ${client.label}: ${error}\n`) + : chalk.green(`✓ ${client.label}`) + + chalk.dim(` (key stored in ${client.location})\n`), + ); + } + process.stdout.write('Restart your agents to pick up the new server.\n'); + if (done.some(r => r.error)) process.exit(1); + }); + // ---- Teams --------------------------------------------------------- const team = program diff --git a/packages/cli/src/mcp/McpInstallPrompt.tsx b/packages/cli/src/mcp/McpInstallPrompt.tsx new file mode 100644 index 0000000000..a368355613 --- /dev/null +++ b/packages/cli/src/mcp/McpInstallPrompt.tsx @@ -0,0 +1,164 @@ +import React, { useState } from 'react'; +import { Box, Text, useApp, useInput } from 'ink'; +import TextInput from 'ink-text-input'; +import Spinner from 'ink-spinner'; + +import { + type KeyIdentity, + type McpClient, + mcpUrl, + verifyKey, +} from '@/mcp/install'; +import { isValidUrl } from '@/utils/url'; + +export interface InstallResult { + client: McpClient; + error?: string; +} + +type Step = 'appUrl' | 'key' | 'checking' | 'confirm'; + +/** + * Collects the app URL and API key, checks the key, then asks before writing + * any client config. Calls `onDone` with the results, or with null if the + * user cancels. + */ +export default function McpInstallPrompt({ + initialAppUrl, + clients, + onDone, +}: { + initialAppUrl?: string; + clients: McpClient[]; + onDone: (results: InstallResult[] | null) => void; +}) { + const { exit } = useApp(); + const [step, setStep] = useState(initialAppUrl ? 'key' : 'appUrl'); + const [appUrl, setAppUrl] = useState(initialAppUrl ?? ''); + const [key, setKey] = useState(''); + const [identity, setIdentity] = useState(null); + const [error, setError] = useState(null); + + const finish = (results: InstallResult[] | null) => { + exit(); + onDone(results); + }; + + useInput( + (input, inkKey) => { + if (inkKey.return || input.toLowerCase() === 'y') { + const server = { url: mcpUrl(appUrl), key }; + finish( + clients.map(client => { + try { + client.install(server); + return { client }; + } catch (e) { + return { + client, + error: e instanceof Error ? e.message : String(e), + }; + } + }), + ); + } else if (inkKey.escape || input.toLowerCase() === 'n') { + finish(null); + } + }, + { isActive: step === 'confirm' }, + ); + + const submitAppUrl = () => { + const trimmed = appUrl.trim().replace(/\/+$/, ''); + if (!isValidUrl(trimmed)) { + setError('Enter a valid http:// or https:// URL.'); + return; + } + setError(null); + setAppUrl(trimmed); + setStep('key'); + }; + + const submitKey = async () => { + // Keys never contain whitespace; a paste can bring a trailing newline. + const cleaned = key.replace(/\s/g, ''); + if (!cleaned) return; + setKey(cleaned); + setError(null); + setStep('checking'); + try { + setIdentity(await verifyKey(appUrl, cleaned)); + setStep('confirm'); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setKey(''); + setStep('key'); + } + }; + + return ( + + + ClickStack MCP server install + + {step !== 'appUrl' && Server: {appUrl}} + + {error && {error}} + + {step === 'appUrl' && ( + + ClickStack URL: + + + )} + + {step === 'key' && ( + + + Paste a personal access key or an organization API key from Team + Settings > API & Agents. + + + API key: + + + + )} + + {step === 'checking' && ( + + Checking the key… + + )} + + {step === 'confirm' && identity && ( + + + Key works for{' '} + {identity.isServiceAccount + ? `organization key "${identity.name}"` + : identity.name} + . + + + Add the ClickStack MCP server to: + {clients.map(c => ( + - {c.label} + ))} + + Enter or y to continue, Esc or n to cancel + + )} + + ); +} diff --git a/packages/cli/src/mcp/install.ts b/packages/cli/src/mcp/install.ts new file mode 100644 index 0000000000..c2db869ace --- /dev/null +++ b/packages/cli/src/mcp/install.ts @@ -0,0 +1,230 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +// Same server name the Team Settings snippets use, so a reinstall replaces +// an entry added by hand instead of adding a second one. +const MCP_SERVER_NAME = 'clickstack'; + +export interface McpServer { + url: string; + key: string; +} + +export interface McpClient { + id: 'claude-code' | 'codex' | 'cursor' | 'vscode'; + label: string; + /** Where the key ends up, shown to the user after install. */ + location: string; + detect: () => boolean; + install: (server: McpServer) => void; +} + +export interface KeyIdentity { + name: string; + isServiceAccount: boolean; +} + +export function mcpUrl(appUrl: string): string { + return `${appUrl.replace(/\/+$/, '')}/api/mcp`; +} + +/** Returns who the key authenticates as, or throws with a message for the user. */ +export async function verifyKey( + appUrl: string, + key: string, +): Promise { + let res: Response; + try { + // The app's /api proxy strips its prefix, and the external API itself is + // mounted at /api/v2, hence the doubled segment. + res = await fetch(`${appUrl.replace(/\/+$/, '')}/api/api/v2`, { + headers: { Authorization: `Bearer ${key}` }, + }); + } catch (e) { + throw new Error( + `Could not reach ${appUrl}: ${e instanceof Error ? e.message : e}`, + ); + } + if (res.status === 401 || res.status === 403) { + throw new Error( + 'The key was rejected. It may be wrong, revoked, or expired.', + ); + } + if (!res.ok) { + throw new Error(`Could not check the key (HTTP ${res.status}).`); + } + const body = (await res.json()) as { + user?: { name?: string; email?: string; isVirtual?: boolean }; + }; + const user = body.user ?? {}; + return { + // A service account's email is synthetic; its name is the key description. + name: (user.isVirtual ? user.name : user.email) ?? user.name ?? 'unknown', + isServiceAccount: !!user.isVirtual, + }; +} + +function onPath(command: string): boolean { + return !spawnSync(command, ['--version'], { stdio: 'ignore' }).error; +} + +function authHeaders(key: string) { + return { Authorization: `Bearer ${key}` }; +} + +/** Sets `.` in a JSON config, keeping everything else. */ +export function upsertJsonServer( + file: string, + topKey: string, + entry: Record, +): void { + let config: Record = {}; + if (fs.existsSync(file)) { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch { + // Rewriting a file we can't parse (e.g. JSONC comments) would lose them. + } + if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Could not parse ${file}. Add the server by hand.`); + } + config = parsed as Record; + } + const servers = (config[topKey] ?? {}) as Record; + config[topKey] = { ...servers, [MCP_SERVER_NAME]: entry }; + writePrivate(file, `${JSON.stringify(config, null, 2)}\n`); +} + +// writeFileSync's `mode` only applies on create, so tighten existing files too. +function writePrivate(file: string, content: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content, { mode: 0o600 }); + fs.chmodSync(file, 0o600); +} + +function tomlString(value: string): string { + return JSON.stringify(value); +} + +/** Replaces the `[mcp_servers.clickstack]` table (and its subtables) in Codex's config.toml. */ +export function upsertCodexServer(file: string, server: McpServer): void { + const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''; + const header = new RegExp(`^\\[mcp_servers\\.${MCP_SERVER_NAME}(\\.|\\])`); + const kept: string[] = []; + let skipping = false; + // shortcut: line-based table removal, no TOML parser. Fine for tables we + // wrote; a hand-written multi-line value inside ours could be split. + for (const line of existing.split('\n')) { + if (line.trimStart().startsWith('[')) { + skipping = header.test(line.trim()); + } + if (!skipping) kept.push(line); + } + const body = kept.join('\n').trimEnd(); + const table = [ + `[mcp_servers.${MCP_SERVER_NAME}]`, + `url = ${tomlString(server.url)}`, + `http_headers = { Authorization = ${tomlString(`Bearer ${server.key}`)} }`, + ].join('\n'); + writePrivate(file, `${body ? `${body}\n\n` : ''}${table}\n`); +} + +function vscodeUserDir(home: string, platform: NodeJS.Platform): string { + if (platform === 'darwin') { + return path.join(home, 'Library', 'Application Support', 'Code', 'User'); + } + if (platform === 'win32') { + return path.join( + process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), + 'Code', + 'User', + ); + } + return path.join(home, '.config', 'Code', 'User'); +} + +function runClaude(args: string[]): void { + const res = spawnSync('claude', args, { encoding: 'utf-8' }); + if (res.error || res.status !== 0) { + throw new Error( + (res.stderr || res.error?.message || 'claude mcp add failed').trim(), + ); + } +} + +export function mcpClients( + home = os.homedir(), + platform = process.platform, +): McpClient[] { + const codexConfig = path.join(home, '.codex', 'config.toml'); + const cursorConfig = path.join(home, '.cursor', 'mcp.json'); + const vscodeDir = vscodeUserDir(home, platform); + const vscodeConfig = path.join(vscodeDir, 'mcp.json'); + + return [ + { + id: 'claude-code', + label: 'Claude Code', + location: 'Claude Code user config (~/.claude.json)', + detect: () => onPath('claude'), + install: ({ url, key }) => { + // `mcp add` refuses to overwrite, so drop any earlier entry first. A + // local-scope entry for this directory would also shadow the new one. + for (const scope of ['local', 'user']) { + spawnSync( + 'claude', + ['mcp', 'remove', '--scope', scope, MCP_SERVER_NAME], + { + stdio: 'ignore', + }, + ); + } + runClaude([ + 'mcp', + 'add', + '--scope', + 'user', + '--transport', + 'http', + MCP_SERVER_NAME, + url, + '--header', + `Authorization: Bearer ${key}`, + ]); + }, + }, + { + id: 'codex', + label: 'Codex', + location: codexConfig, + detect: () => fs.existsSync(path.dirname(codexConfig)) || onPath('codex'), + install: server => upsertCodexServer(codexConfig, server), + }, + { + id: 'cursor', + label: 'Cursor', + location: cursorConfig, + detect: () => fs.existsSync(path.dirname(cursorConfig)), + install: ({ url, key }) => + upsertJsonServer(cursorConfig, 'mcpServers', { + url, + headers: authHeaders(key), + }), + }, + { + id: 'vscode', + label: 'VS Code', + location: vscodeConfig, + detect: () => fs.existsSync(vscodeDir), + install: ({ url, key }) => + upsertJsonServer(vscodeConfig, 'servers', { + type: 'http', + url, + headers: authHeaders(key), + }), + }, + ]; +} diff --git a/packages/cli/src/utils/url.ts b/packages/cli/src/utils/url.ts new file mode 100644 index 0000000000..4f83b41e3a --- /dev/null +++ b/packages/cli/src/utils/url.ts @@ -0,0 +1,9 @@ +/** Returns true if the string is a valid HTTP(S) URL. */ +export function isValidUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}