diff --git a/.changeset/connect-your-ai-assistant.md b/.changeset/connect-your-ai-assistant.md new file mode 100644 index 0000000000..e39775e851 --- /dev/null +++ b/.changeset/connect-your-ai-assistant.md @@ -0,0 +1,11 @@ +--- +"@hyperdx/app": patch +--- + +feat: add a "Connect your AI assistant" section to Team Settings + +A new section on the Team Settings page (Integrations tab, above the API Keys +card) lets a user install the HyperDX MCP server in Claude Code, Cursor, +VS Code + Copilot, Codex CLI, or any MCP-compatible host without hand-rolling +JSON. Per-host snippets carry the user's personal access key so the install +works against the existing `/api/mcp` route without extra setup. diff --git a/packages/app/src/TeamPage.tsx b/packages/app/src/TeamPage.tsx index 4a9c2bffd4..f5dfb6e7f2 100644 --- a/packages/app/src/TeamPage.tsx +++ b/packages/app/src/TeamPage.tsx @@ -20,6 +20,7 @@ import { PageHeader } from './components/PageHeader'; import ApiKeysSection from './components/TeamSettings/ApiKeysSection'; import ConnectionsSection from './components/TeamSettings/ConnectionsSection'; import IntegrationsSection from './components/TeamSettings/IntegrationsSection'; +import McpServerSection from './components/TeamSettings/McpServerSection'; import SecurityPoliciesSection from './components/TeamSettings/SecurityPoliciesSection'; import SourcesSection from './components/TeamSettings/SourcesSection'; import TeamMembersSection from './components/TeamSettings/TeamMembersSection'; @@ -131,6 +132,20 @@ export default function TeamPage() { }, ] : []), + { + value: 'api-agents', + label: 'API & Agents', + sections: [ + { + id: 'team-api-agents-api-keys', + content: , + }, + { + id: 'team-api-agents-mcp-server', + content: , + }, + ], + }, { value: 'integrations', label: 'Integrations', @@ -139,10 +154,6 @@ export default function TeamPage() { id: 'team-integrations-webhooks', content: , }, - { - id: 'team-integrations-api-keys', - content: , - }, ], }, { diff --git a/packages/app/src/components/ClickStackOnboarding/CopySnippet.tsx b/packages/app/src/components/ClickStackOnboarding/CopySnippet.tsx new file mode 100644 index 0000000000..9f4b884ba7 --- /dev/null +++ b/packages/app/src/components/ClickStackOnboarding/CopySnippet.tsx @@ -0,0 +1,49 @@ +import { Button, Code, CopyButton, Group, Stack, Text } from '@mantine/core'; +import { IconCheck, IconCopy } from '@tabler/icons-react'; + +interface CopySnippetProps { + label: string; + snippet: string; +} + +/** + * Pre-formatted snippet with a copy-to-clipboard button. The copy + * affordance handles its own `Copied` affirmation via Mantine's + * ``. + */ +export function CopySnippet({ label, snippet }: CopySnippetProps) { + return ( + + + {label} + + + + {snippet} + + + {({ copied, copy }) => ( + + )} + + + + ); +} diff --git a/packages/app/src/components/ClickStackOnboarding/DeeplinkInstall.tsx b/packages/app/src/components/ClickStackOnboarding/DeeplinkInstall.tsx new file mode 100644 index 0000000000..ea742f7801 --- /dev/null +++ b/packages/app/src/components/ClickStackOnboarding/DeeplinkInstall.tsx @@ -0,0 +1,55 @@ +import { ReactNode, useState } from 'react'; +import { Anchor, Button, Collapse, Group, Stack, Tooltip } from '@mantine/core'; + +import { CopySnippet } from './CopySnippet'; + +interface DeeplinkInstallProps { + buttonLabel: string; + deeplink: string; + fallbackLabel: string; + fallbackSnippet: string; + note?: ReactNode; +} + +/** + * One-click "Add to " deep-link install for hosts that + * support it (Cursor, VS Code + Copilot). The manual fallback + * snippet stays tucked behind a `Manual setup` toggle so the + * primary affordance is always the deep link, with the JSON + * paste-it-yourself path available for users who can't or won't + * use the deep link. + */ +export function DeeplinkInstall({ + buttonLabel, + deeplink, + fallbackLabel, + fallbackSnippet, + note, +}: DeeplinkInstallProps) { + const [manualOpen, setManualOpen] = useState(false); + return ( + + + + + + setManualOpen(v => !v)} + > + {manualOpen ? 'Hide manual setup' : 'Manual setup'} + + + {note} + + + + + ); +} diff --git a/packages/app/src/components/ClickStackOnboarding/McpInstallPanel.tsx b/packages/app/src/components/ClickStackOnboarding/McpInstallPanel.tsx new file mode 100644 index 0000000000..25c6bda9f9 --- /dev/null +++ b/packages/app/src/components/ClickStackOnboarding/McpInstallPanel.tsx @@ -0,0 +1,209 @@ +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, + type BuiltSnippets, + type DeploymentShape, +} from './installSnippets'; + +/** + * Agent hosts the install panel covers. Five named installs (Claude + * Code, Cursor, VS Code, Codex CLI, OpenCode) plus "Other" as the + * JSON-fallback escape hatch for any other MCP-compatible host + * (Claude Desktop, Continue, Cline, ...). + * + * ChatGPT is intentionally absent: native MCP isn't there yet, and + * bridges are a user-side decision better tracked in the docs than + * in this UI surface. + */ +type AgentHost = + | 'claude-code' + | 'cursor' + | 'vscode-copilot' + | 'codex-cli' + | 'opencode' + | 'other'; + +interface HostChoice { + id: AgentHost; + label: string; +} + +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' }, +]; + +const HOST_IDS = new Set(CHOICES.map(c => c.id)); + +function isAgentHost(value: string): value is AgentHost { + return HOST_IDS.has(value); +} + +interface McpInstallPanelProps { + /** + * Deployment shape derived from `useMe()` in the caller. The + * caller is responsible for not mounting this panel until the + * deployment is ready (matching the convention in + * `ApiKeysSection`), so the type here is non-nullable. + */ + deployment: DeploymentShape; +} + +/** + * Renders the host picker plus the install primitive (CLI command, + * deep link, or JSON block) for the chosen host. Presentational; + * the deployment shape comes in via props so the same component + * can render from any surface that resolves a deployment + access + * key. + * + * The access key is inlined in the rendered snippet to match the + * existing API Keys card pattern, which shows the key in plain + * text. A follow-up will introduce a shared mask + reveal-to-copy + * affordance across every credential surface in Team Settings. + */ +export default function McpInstallPanel({ deployment }: McpInstallPanelProps) { + const [host, setHost] = useState('claude-code'); + + const snippets = useMemo(() => buildAllSnippets(deployment), [deployment]); + + return ( + + { + // Narrow the SegmentedControl's `string` callback against + // the CHOICES set so a future out-of-band value cannot + // silently install an invalid host. CHOICES is the source + // of truth for the option list. + if (isAgentHost(value)) { + setHost(value); + } + }} + data={CHOICES.map(c => ({ + value: c.id, + label: ( + + + {c.label} + + ), + }))} + aria-label="MCP host" + /> + + + + ); +} + +function HostIcon({ id }: { id: AgentHost }) { + switch (id) { + case 'claude-code': + return ; + case 'cursor': + return ; + case 'vscode-copilot': + return ; + case 'codex-cli': + return ; + case 'opencode': + return ; + case 'other': + return ; + } + // Exhaustiveness check via `satisfies never`: adding a new + // AgentHost variant without extending the switch fails the + // compile here. Defensive `return null` (instead of throwing) + // keeps a runtime-only unknown variant from crashing the panel. + id satisfies never; + return null; +} + +interface HostInstallProps { + host: AgentHost; + snippets: BuiltSnippets; +} + +function HostInstall({ host, snippets }: HostInstallProps) { + switch (host) { + case 'claude-code': + return ( + + ); + + case 'cursor': + return ( + + ); + + case 'vscode-copilot': + return ( + + Requires VS Code 1.99+ with the Copilot Chat MCP feature enabled. + + } + /> + ); + + case 'codex-cli': + return ( + + ); + + case 'opencode': + return ( + + ); + + case 'other': + return ( + + ); + } + // Exhaustiveness check via `satisfies never`: adding a new + // AgentHost variant without extending the switch fails the + // compile here. Defensive `return null` (instead of throwing) + // keeps a runtime-only unknown variant from crashing the panel. + host satisfies never; + return null; +} diff --git a/packages/app/src/components/ClickStackOnboarding/__tests__/installSnippets.test.ts b/packages/app/src/components/ClickStackOnboarding/__tests__/installSnippets.test.ts new file mode 100644 index 0000000000..4636579c9f --- /dev/null +++ b/packages/app/src/components/ClickStackOnboarding/__tests__/installSnippets.test.ts @@ -0,0 +1,197 @@ +import { + buildAllSnippets, + type DeploymentShape, + SERVER_NAME, +} from '../installSnippets'; + +const DEPLOYMENT: DeploymentShape = { + apiUrl: 'https://hyperdx.example.com/api', + 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 OpenAI Codex CLI mcp add command', () => { + 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"`, + ); + }); +}); + +describe('buildAllSnippets > Cursor', () => { + it('emits a cursor:// URL with a URL-safe base64-encoded config that round-trips', () => { + const { cursor } = buildAllSnippets(DEPLOYMENT); + + expect( + cursor.startsWith( + `cursor://anysphere.cursor-deeplink/mcp/install?name=${SERVER_NAME}&config=`, + ), + ).toBe(true); + + const encoded = cursor.split('config=')[1]; + // base64url accepts both the URL-safe alphabet (`-`, `_`, no + // padding) and standard base64; using it explicitly is the + // documented decoder for the URL-safe variant we emit. + const decoded = JSON.parse( + Buffer.from(encoded, 'base64url').toString('utf8'), + ); + expect(decoded).toMatchObject({ + type: 'http', + url: 'https://hyperdx.example.com/api/mcp', + headers: { Authorization: 'Bearer k_abcdef123456' }, + }); + }); + + it('produces a Cursor config value that only uses the URL-safe alphabet', () => { + // The standard base64 characters `+` / `/` / `=` all carry + // special meaning inside a query-string value (`+` decodes as + // space under form-urlencoded), so the deep link must use the + // URL-safe variant. This guards every input that flows through + // `buildAllSnippets`, not just the canonical fixture. + const inputs: DeploymentShape[] = [ + DEPLOYMENT, + // Inputs chosen to maximise the chance that the standard + // base64 alphabet would emit `+` or `/`. Every byte > 0x3E or + // > 0x3F flips a `+` or `/` somewhere in the encoded output. + { + apiUrl: 'https://hyperdx.example.com/api', + accessKey: '????>>>>????>>>>', + }, + { + apiUrl: 'https://hyperdx.example.com/api', + accessKey: 'ÿÿÿÿÿÿ', + }, + ]; + for (const deployment of inputs) { + const { cursor } = buildAllSnippets(deployment); + const encoded = cursor.split('config=')[1]; + expect(encoded).toMatch(/^[-A-Za-z0-9_]+$/); + } + }); +}); + +describe('buildAllSnippets > VS Code', () => { + it('emits a vscode:mcp/install URL with a URL-encoded JSON config that round-trips', () => { + const { vscode } = buildAllSnippets(DEPLOYMENT); + + expect(vscode.startsWith('vscode:mcp/install?')).toBe(true); + + const encoded = vscode.replace(/^vscode:mcp\/install\?/, ''); + const decoded = JSON.parse(decodeURIComponent(encoded)); + expect(decoded).toMatchObject({ + name: SERVER_NAME, + type: 'http', + url: 'https://hyperdx.example.com/api/mcp', + headers: { Authorization: 'Bearer k_abcdef123456' }, + }); + }); +}); + +describe('buildAllSnippets > OpenCode', () => { + it('emits OpenCode JSON config under an `mcp` block with type: "remote"', () => { + const { openCode } = buildAllSnippets(DEPLOYMENT); + const parsed = JSON.parse(openCode); + + // OpenCode reads MCP servers from the `mcp` key (not + // `mcpServers` like the canonical block) and uses + // `type: "remote"` for HTTP transport. Verified empirically + // against a running ClickStack instance 2026-06-04. + expect(parsed).toMatchObject({ + mcp: { + [SERVER_NAME]: { + type: 'remote', + url: 'https://hyperdx.example.com/api/mcp', + headers: { Authorization: 'Bearer k_abcdef123456' }, + }, + }, + }); + }); + + it('does NOT emit type: "http" or an `mcpServers` key (those would be the wrong shape for OpenCode)', () => { + const { openCode } = buildAllSnippets(DEPLOYMENT); + + expect(openCode).not.toContain('"type": "http"'); + expect(openCode).not.toContain('"mcpServers"'); + }); +}); + +describe('buildAllSnippets > JSON block', () => { + it('emits canonical mcpServers JSON keyed on the fixed server name', () => { + const { jsonBlock } = buildAllSnippets(DEPLOYMENT); + const parsed = JSON.parse(jsonBlock); + + expect(parsed).toMatchObject({ + mcpServers: { + [SERVER_NAME]: { + url: 'https://hyperdx.example.com/api/mcp', + type: 'http', + headers: { Authorization: 'Bearer k_abcdef123456' }, + }, + }, + }); + }); + + it('renders pretty-printed JSON with two-space indent', () => { + const { jsonBlock } = buildAllSnippets(DEPLOYMENT); + + expect(jsonBlock).toContain('\n "mcpServers": {'); + }); +}); + +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('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"'); + }); +}); + +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}"`), + }); + }); + + 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}"`); + expect(all.jsonBlock).toContain(`"${SERVER_NAME}"`); + }); +}); diff --git a/packages/app/src/components/ClickStackOnboarding/installSnippets.ts b/packages/app/src/components/ClickStackOnboarding/installSnippets.ts new file mode 100644 index 0000000000..61689516c6 --- /dev/null +++ b/packages/app/src/components/ClickStackOnboarding/installSnippets.ts @@ -0,0 +1,231 @@ +// 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. +// +// Pure functions only. Component-level state (host picker) lives +// in `McpInstallPanel.tsx`. Keeping the builders here makes unit +// tests cheap and keeps the snippet round-trip stable regardless +// of which surface renders them. + +/** + * MCP server name registered in the host's config. A single fixed + * value works because the access key carries the team context to + * the MCP server: every install snippet reaches the same + * `/api/mcp` endpoint and the server resolves the active team + * from the bearer token. A user with multiple ClickStack tenants + * authenticates as a different identity per host config and the + * server routes accordingly, so we don't need to disambiguate by + * name on the client. + */ +export const SERVER_NAME = 'clickstack'; + +export interface DeploymentShape { + /** Origin used to build the MCP URL, e.g. `https://example.com/api`. */ + apiUrl: string; + /** Per-user access key from `useMe().accessKey`. */ + accessKey: string; +} + +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. */ + jsonBlock: string; +} + +/** + * Encodes a config object the way the Cursor MCP deep link expects: + * `cursor://anysphere.cursor-deeplink/mcp/install?name=...&config=`. + * Runs in both the browser (`btoa`) and Node (`Buffer`) so the + * snippet builders are usable from Jest tests without a JSDOM + * polyfill. + * + * UTF-8-encodes the input before `btoa` so a future access-key or + * origin format that includes a code point > 0xFF cannot raise + * `InvalidCharacterError` and blank the whole panel (since + * `buildAllSnippets` builds every host eagerly). Today's inputs + * (UUIDv4 key + browser-normalised origin) are all Latin1 so the + * defensive path is unreachable, but the encode is cheap. + */ +function base64(value: string): string { + if (typeof window !== 'undefined' && typeof window.btoa === 'function') { + const utf8 = new TextEncoder().encode(value); + let binary = ''; + for (let i = 0; i < utf8.length; i++) { + binary += String.fromCharCode(utf8[i]); + } + return window.btoa(binary); + } + return Buffer.from(value, 'utf8').toString('base64'); +} + +/** + * URL-safe base64. Standard base64 emits `+`, `/`, `=`, all of + * which carry special meaning in a query-string value (`+` decodes + * as space on the deep-link host side, breaking the JSON payload). + * Cursor's deep-link decoder accepts the URL-safe alphabet; emitting + * it directly avoids a separate `encodeURIComponent` round on the + * already-encoded value. + */ +function base64UrlSafe(value: string): string { + return base64(value) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** + * Returns the headers map for the MCP HTTP transport. The + * deployment shape exposed by `useMe()` carries only the bearer + * access key, so `Authorization` is the only header emitted here. + */ +function buildHeaders(deployment: DeploymentShape): Record { + return { + Authorization: `Bearer ${deployment.accessKey || ''}`, + }; +} + +/** + * Returns the MCP URL the host connects to. `apiUrl` is the API + * origin derived from the active page; the MCP transport lives at + * `/mcp` underneath it. + */ +function buildUrl(deployment: DeploymentShape): string { + const base = deployment.apiUrl.endsWith('/') + ? deployment.apiUrl.slice(0, -1) + : deployment.apiUrl; + return `${base}/mcp`; +} + +/** + * 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. + */ +function shellQuoteHeader(name: string, value: string): string { + const escaped = value.replace(/(["\\$`])/g, '\\$1'); + return `--header "${name}: ${escaped}"`; +} + +function headerArgs(headers: Record): string { + return Object.entries(headers) + .map(([key, value]) => shellQuoteHeader(key, value)) + .join(' '); +} + +/** + * 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. + */ +function buildCliOneLiner(binary: string, deployment: DeploymentShape): string { + const url = buildUrl(deployment); + const headers = buildHeaders(deployment); + return `${binary} mcp add ${SERVER_NAME} --transport http ${url} ${headerArgs(headers)}`; +} + +/** + * Cursor `cursor://` deep link. Documented Cursor MCP install + * scheme: name in the query string, config as base64-encoded JSON. + * + * Uses URL-safe base64 (`-`, `_`, no padding) for the `config` value + * so the standard alphabet's `+` / `/` / `=` cannot be re-interpreted + * by the deep-link host's URL parser. Notably, `+` decodes as space + * under `application/x-www-form-urlencoded`, which corrupts the + * embedded JSON. + */ +function buildCursorDeeplink(deployment: DeploymentShape): string { + const config = { + type: 'http', + url: buildUrl(deployment), + headers: buildHeaders(deployment), + }; + const encoded = base64UrlSafe(JSON.stringify(config)); + return `cursor://anysphere.cursor-deeplink/mcp/install?name=${SERVER_NAME}&config=${encoded}`; +} + +/** + * VS Code + Copilot `vscode:mcp/install` deep link. Requires VS + * Code 1.99+ with the Copilot Chat MCP feature enabled. + */ +function buildVSCodeDeeplink(deployment: DeploymentShape): string { + const config = { + name: SERVER_NAME, + type: 'http', + url: buildUrl(deployment), + headers: buildHeaders(deployment), + }; + return `vscode:mcp/install?${encodeURIComponent(JSON.stringify(config))}`; +} + +/** + * OpenCode JSON config block. OpenCode's MCP config lives under an + * `mcp` key (not `mcpServers`) and uses `type: "remote"` for HTTP + * transport (documented at https://opencode.ai/docs/mcp-servers/). + * Verified empirically against a running ClickStack instance on + * 2026-06-04: OpenCode's `type: "remote"` connects successfully to + * our Streamable HTTP server. + * + * Users paste this into `opencode.json` in their project root, or + * into `~/.config/opencode/config.json` for a global install. + */ +function buildOpenCodeJsonBlock(deployment: DeploymentShape): string { + const block = { + mcp: { + [SERVER_NAME]: { + type: 'remote', + url: buildUrl(deployment), + headers: buildHeaders(deployment), + }, + }, + }; + return JSON.stringify(block, null, 2); +} + +/** + * Canonical `mcpServers` JSON block. Covers every MCP-compatible + * host that doesn't have a CLI primitive or deep link yet (Claude + * Desktop, Continue, Cline, and the long tail). + */ +function buildMcpJsonBlock(deployment: DeploymentShape): string { + const block = { + mcpServers: { + [SERVER_NAME]: { + url: buildUrl(deployment), + type: 'http', + headers: buildHeaders(deployment), + }, + }, + }; + return JSON.stringify(block, null, 2); +} + +/** + * Build every host's snippet in one call. The component pulls the + * field matching the current host out of the result; tests assert + * round-trip shape through this entry point. + */ +export function buildAllSnippets(deployment: DeploymentShape): BuiltSnippets { + return { + claudeCode: buildCliOneLiner('claude', deployment), + cursor: buildCursorDeeplink(deployment), + vscode: buildVSCodeDeeplink(deployment), + codexCli: buildCliOneLiner('codex', deployment), + openCode: buildOpenCodeJsonBlock(deployment), + jsonBlock: buildMcpJsonBlock(deployment), + }; +} diff --git a/packages/app/src/components/TeamSettings/McpServerSection.tsx b/packages/app/src/components/TeamSettings/McpServerSection.tsx new file mode 100644 index 0000000000..a100d19ac0 --- /dev/null +++ b/packages/app/src/components/TeamSettings/McpServerSection.tsx @@ -0,0 +1,55 @@ +import { useMemo } from 'react'; +import { Box, Card, Divider, Text } from '@mantine/core'; + +import api from '@/api'; + +import { type DeploymentShape } from '../ClickStackOnboarding/installSnippets'; +import McpInstallPanel from '../ClickStackOnboarding/McpInstallPanel'; + +/** + * Renders the "Connect your AI Agents" section on the Team + * Settings page (Integrations tab). Self-managed OSS deployments + * mount the MCP server at `/api/mcp` with the per-user + * access key as bearer; the MCP server resolves the active team + * from the token, so the install snippet doesn't need to encode + * tenant context on the client. + * + * Structure mirrors `IntegrationsSection.tsx` and + * `ApiKeysSection.tsx`: outer `` with a `` + * header and ``, then a single `` wrapping the + * install panel. No subtitle below the header so the visual + * rhythm matches the rest of the Integrations tab. + * + * Auth gating matches `ApiKeysSection`: render nothing until `me` + * resolves with a non-empty access key. `MeApiResponseSchema` + * declares `accessKey` as `z.string()`, and the User model + * generates one on account creation, so the empty-key branch is + * defensive against a state the schema disallows; we still skip + * mounting the panel in that case rather than rendering a snippet + * with an empty bearer. + */ +export default function McpServerSection() { + const { data: me, isLoading: isLoadingMe } = api.useMe(); + + const deployment = useMemo(() => { + if (!me?.accessKey) return null; + return { + apiUrl: `${window.location.origin}/api`, + accessKey: me.accessKey, + }; + }, [me]); + + if (isLoadingMe || !deployment) { + return null; + } + + return ( + + Connect your AI Agents + + + + + + ); +} diff --git a/packages/app/src/components/TeamSettings/__tests__/McpServerSection.test.tsx b/packages/app/src/components/TeamSettings/__tests__/McpServerSection.test.tsx new file mode 100644 index 0000000000..ab3d636bde --- /dev/null +++ b/packages/app/src/components/TeamSettings/__tests__/McpServerSection.test.tsx @@ -0,0 +1,190 @@ +import { MantineProvider } from '@mantine/core'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import api from '@/api'; + +import McpServerSection from '../McpServerSection'; + +jest.mock('@/api', () => ({ + __esModule: true, + default: { + useMe: jest.fn(), + }, + hdxServer: jest.fn(), +})); + +const mockUseMe = jest.mocked(api.useMe); + +function setMe(accessKey: string | null, isLoading = false) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + mockUseMe.mockReturnValue({ + data: + accessKey === null + ? null + : { + id: 'u1', + email: 'a@b.com', + accessKey, + name: 'User', + createdAt: '', + }, + isLoading, + } as ReturnType); +} + +function renderSection() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + , + ); +} + +describe('McpServerSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + setMe('k_test'); + }); + + it('renders the section header and the install panel', () => { + renderSection(); + + expect(screen.getByTestId('mcp-server-section')).toBeInTheDocument(); + expect(screen.getByText(/^Connect your AI Agents$/)).toBeInTheDocument(); + }); + + it('renders nothing while the me payload is still loading', () => { + setMe(null, true); + + renderSection(); + + expect(screen.queryByTestId('mcp-server-section')).not.toBeInTheDocument(); + }); + + it('renders all six host options when the deployment shape is valid', () => { + 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(); + }); + + // Auth-gate parity with `ApiKeysSection`: when `me` resolves to + // `null` (user not signed in, or `IS_LOCAL_MODE` short-circuit), + // the section silently doesn't render. No "sign in" alert. + it('renders nothing when me is null', () => { + setMe(null); + + renderSection(); + + expect(screen.queryByTestId('mcp-server-section')).not.toBeInTheDocument(); + }); + + it('renders the fixed clickstack server name in the install snippet', () => { + renderSection(); + + expect(screen.getByText(/claude mcp add clickstack /)).toBeInTheDocument(); + }); + + // `MeApiResponseSchema.accessKey` is `z.string()` non-nullable + // and the User model generates one at account creation, so an + // empty access key isn't reachable from the API in practice. + // The defensive guard in `McpServerSection` still skips render + // rather than emitting a snippet with an empty bearer. + it('renders nothing when accessKey is empty', () => { + setMe(''); + + renderSection(); + + 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 /)).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(); + + await user.click(screen.getByText('Cursor')); + + const button = screen.getByRole('link', { name: /Add to Cursor/i }); + const href = button.getAttribute('href') ?? ''; + expect(href).toMatch( + /^cursor:\/\/anysphere\.cursor-deeplink\/mcp\/install\?name=clickstack&config=[-A-Za-z0-9_]+$/, + ); + }); + + it('renders the VS Code deeplink button with a vscode:mcp/install href when VS Code is selected', async () => { + const user = userEvent.setup(); + renderSection(); + + await user.click(screen.getByText('VS Code')); + + const button = screen.getByRole('link', { name: /Add to VS Code/i }); + expect(button.getAttribute('href') ?? '').toMatch(/^vscode:mcp\/install\?/); + }); + + it('renders the canonical JSON block when Other is selected', async () => { + const user = userEvent.setup(); + renderSection(); + + await user.click(screen.getByText('Other')); + + expect(screen.getByText(/"mcpServers":/)).toBeInTheDocument(); + }); + + it('renders the OpenCode JSON block (`mcp` key with `type: "remote"`) when OpenCode is selected', async () => { + const user = userEvent.setup(); + renderSection(); + + await user.click(screen.getByText('OpenCode')); + + // OpenCode's shape diverges from `Other`: outer key is `mcp` + // (not `mcpServers`) and the server entry uses `type: "remote"` + // (not `type: "http"`). + expect(screen.getByText(/"mcp":/)).toBeInTheDocument(); + expect(screen.getByText(/"type": "remote"/)).toBeInTheDocument(); + }); + + it('reveals the manual JSON fallback when the Manual setup toggle is clicked on a deeplink host', async () => { + const user = userEvent.setup(); + renderSection(); + + await user.click(screen.getByText('Cursor')); + // The fallback JSON lives inside Mantine's ``, which + // keeps the child mounted and animates max-height + visibility + // for the open transition. JSDOM does not run CSS transitions, + // so the canonical "is the user seeing it" canary is the toggle + // label: it flips synchronously with React state. + expect(screen.getByText(/^Manual setup$/i)).toBeInTheDocument(); + expect(screen.queryByText(/^Hide manual setup$/i)).not.toBeInTheDocument(); + expect(screen.getByText(/"mcpServers":/)).not.toBeVisible(); + + await user.click(screen.getByText(/^Manual setup$/i)); + expect(screen.getByText(/^Hide manual setup$/i)).toBeInTheDocument(); + expect(screen.queryByText(/^Manual setup$/i)).not.toBeInTheDocument(); + + await user.click(screen.getByText(/^Hide manual setup$/i)); + expect(screen.getByText(/^Manual setup$/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/app/tests/e2e/features/team.spec.ts b/packages/app/tests/e2e/features/team.spec.ts index 2d4ed69262..e7dc837e52 100644 --- a/packages/app/tests/e2e/features/team.spec.ts +++ b/packages/app/tests/e2e/features/team.spec.ts @@ -20,6 +20,7 @@ test.describe('Team Settings Page', { tag: ['@team', '@full-stack'] }, () => { await test.step('Verify team settings tabs are visible', async () => { await expect(teamPage.dataTab).toBeVisible(); await expect(teamPage.teamTab).toBeVisible(); + await expect(teamPage.apiAndAgentsTab).toBeVisible(); await expect(teamPage.integrationsTab).toBeVisible(); await expect(teamPage.advancedTab).toBeVisible(); }); @@ -63,19 +64,30 @@ test.describe('Team Settings Page', { tag: ['@team', '@full-stack'] }, () => { } }); + await test.step('Verify API & Agents tab sections are visible', async () => { + await teamPage.openApiAndAgentsTab(); + await expect(teamPage.apiKeys).toBeVisible(); + await expect(teamPage.mcpServer).toBeVisible(); + }); + + await test.step('Verify API & Agents tab headings exist', async () => { + await expect( + teamPage.apiKeys.getByText('API Keys', { exact: true }), + ).toBeVisible(); + await expect( + teamPage.mcpServer.getByText('Connect your AI Agents', { exact: true }), + ).toBeVisible(); + }); + await test.step('Verify integrations tab sections are visible', async () => { await teamPage.openIntegrationsTab(); await expect(teamPage.integrations).toBeVisible(); - await expect(teamPage.apiKeys).toBeVisible(); }); - await test.step('Verify integrations tab headings exist', async () => { + await test.step('Verify integrations tab heading exists', async () => { await expect( teamPage.integrations.getByText('Integrations', { exact: true }), ).toBeVisible(); - await expect( - teamPage.apiKeys.getByText('API Keys', { exact: true }), - ).toBeVisible(); }); await test.step('Verify advanced tab content is visible', async () => { @@ -142,7 +154,7 @@ test.describe('Team Settings Page', { tag: ['@team', '@full-stack'] }, () => { test('should display API keys', async () => { await test.step('Scroll to API keys section', async () => { - await teamPage.openIntegrationsTab(); + await teamPage.openApiAndAgentsTab(); await teamPage.apiKeys.scrollIntoViewIfNeeded(); }); @@ -162,7 +174,7 @@ test.describe('Team Settings Page', { tag: ['@team', '@full-stack'] }, () => { test('should open and cancel rotate API key modal', async () => { await test.step('Open rotate API key modal', async () => { - await teamPage.openIntegrationsTab(); + await teamPage.openApiAndAgentsTab(); await teamPage.clickRotateApiKey(); }); diff --git a/packages/app/tests/e2e/page-objects/TeamPage.ts b/packages/app/tests/e2e/page-objects/TeamPage.ts index df418ba6e6..07acbf0ef3 100644 --- a/packages/app/tests/e2e/page-objects/TeamPage.ts +++ b/packages/app/tests/e2e/page-objects/TeamPage.ts @@ -13,6 +13,7 @@ export class TeamPage { private readonly dataTabButton: Locator; private readonly teamTabButton: Locator; private readonly accessTabButton: Locator; + private readonly apiAndAgentsTabButton: Locator; private readonly integrationsTabButton: Locator; private readonly advancedTabButton: Locator; @@ -22,6 +23,7 @@ export class TeamPage { private readonly integrationsSection: Locator; private readonly teamNameSection: Locator; private readonly apiKeysSection: Locator; + private readonly mcpServerSection: Locator; private readonly teamMembersSection: Locator; private readonly securityPoliciesHeading: Locator; private readonly querySettingsHeading: Locator; @@ -65,6 +67,10 @@ export class TeamPage { name: 'Access', exact: true, }); + this.apiAndAgentsTabButton = page.getByRole('tab', { + name: 'API & Agents', + exact: true, + }); this.integrationsTabButton = page.getByRole('tab', { name: 'Integrations', exact: true, @@ -79,6 +85,7 @@ export class TeamPage { this.integrationsSection = page.getByTestId('integrations-section'); this.teamNameSection = page.getByTestId('team-name-section'); this.apiKeysSection = page.getByTestId('api-keys-section'); + this.mcpServerSection = page.getByTestId('mcp-server-section'); this.teamMembersSection = page.getByTestId('team-members-section'); this.securityPoliciesHeading = page.getByText('Security Policies', { exact: true, @@ -128,6 +135,10 @@ export class TeamPage { await this.openTab(this.teamTabButton, this.teamMembersSection); } + async openApiAndAgentsTab() { + await this.openTab(this.apiAndAgentsTabButton, this.apiKeysSection); + } + async openIntegrationsTab() { await this.openTab(this.integrationsTabButton, this.integrationsSection); } @@ -329,6 +340,10 @@ export class TeamPage { return this.accessTabButton; } + get apiAndAgentsTab() { + return this.apiAndAgentsTabButton; + } + get integrationsTab() { return this.integrationsTabButton; } @@ -373,6 +388,10 @@ export class TeamPage { return this.apiKeysSection; } + get mcpServer() { + return this.mcpServerSection; + } + get rotateButton() { return this.rotateApiKeyButton; }