From 4a58af6acc9ef6a51ab5e0a467534a087b17f510 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 3 Jun 2026 03:14:40 +0000 Subject: [PATCH 1/8] feat(team-settings): connect your AI assistant Add a "Connect your AI assistant" section to the Team Settings page (Integrations tab), rendered directly above the API Keys card. The section gives users a one-click install path for the ClickStack MCP server in Claude Code, Cursor, VS Code + Copilot, Codex CLI, or any MCP-compatible host without hand-rolling JSON. Per-host install primitives: - Claude Code: `claude mcp add ... --transport http --header "Authorization: Bearer ..."` one-liner. - Codex CLI: `codex mcp add ...` one-liner (mirrors Claude Code). - Cursor: `cursor://anysphere.cursor-deeplink/mcp/install?...` deep link with base64-encoded config; manual JSON fallback behind a `Manual setup` toggle. - VS Code + Copilot: `vscode:mcp/install?` deep link; manual JSON fallback. Requires VS Code 1.99+ with the Copilot Chat MCP feature enabled. - Other: canonical `mcpServers` JSON block that covers Claude Desktop, Continue, Cline, and the long tail. The MCP server resolves the active team from the bearer token, so the install snippet doesn't need to disambiguate by name on the client. A single fixed `clickstack` server name is used across every host primitive. Header values are shell-escaped against `"`, `$`, backtick, and backslash so a future access-key format with metacharacters can't turn a copy-paste install into a shell-injection vector. Tests: 16 component + snippet tests pass. Lint, TS, prose-lint clean. Co-Authored-By: Claude Opus --- .changeset/connect-your-ai-assistant.md | 11 + packages/app/src/TeamPage.tsx | 5 + .../ClickStackOnboarding/CopySnippet.tsx | 51 +++++ .../ClickStackOnboarding/DeeplinkInstall.tsx | 55 +++++ .../ClickStackOnboarding/McpInstallPanel.tsx | 195 ++++++++++++++++++ .../__tests__/installSnippets.test.ts | 135 ++++++++++++ .../ClickStackOnboarding/installSnippets.ts | 185 +++++++++++++++++ .../TeamSettings/McpServerSection.tsx | 56 +++++ .../__tests__/McpServerSection.test.tsx | 105 ++++++++++ .../team-settings-mcp-install.spec.ts | 162 +++++++++++++++ 10 files changed, 960 insertions(+) create mode 100644 .changeset/connect-your-ai-assistant.md create mode 100644 packages/app/src/components/ClickStackOnboarding/CopySnippet.tsx create mode 100644 packages/app/src/components/ClickStackOnboarding/DeeplinkInstall.tsx create mode 100644 packages/app/src/components/ClickStackOnboarding/McpInstallPanel.tsx create mode 100644 packages/app/src/components/ClickStackOnboarding/__tests__/installSnippets.test.ts create mode 100644 packages/app/src/components/ClickStackOnboarding/installSnippets.ts create mode 100644 packages/app/src/components/TeamSettings/McpServerSection.tsx create mode 100644 packages/app/src/components/TeamSettings/__tests__/McpServerSection.test.tsx create mode 100644 packages/app/tests/e2e/features/team-settings-mcp-install.spec.ts 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..6f2deff006 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'; @@ -139,6 +140,10 @@ export default function TeamPage() { id: 'team-integrations-webhooks', content: , }, + { + id: 'team-integrations-mcp-server', + 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..f677b8fa9b --- /dev/null +++ b/packages/app/src/components/ClickStackOnboarding/CopySnippet.tsx @@ -0,0 +1,51 @@ +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 + * ``, matching the rest of the ClickStackOnboarding + * surfaces (`ExporterFormatSelector.tsx`). + */ +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..1ce66bc476 --- /dev/null +++ b/packages/app/src/components/ClickStackOnboarding/McpInstallPanel.tsx @@ -0,0 +1,195 @@ +import { useMemo, useState } from 'react'; +import { Alert, Group, SegmentedControl, Stack, Text } from '@mantine/core'; +import { + 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 fixed options surface + * the most common installs (Claude Code, Cursor, VS Code + Copilot, + * Codex CLI); "Other" is the JSON-fallback escape hatch that + * handles every 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' + | '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 + Copilot' }, + { id: 'codex-cli', label: 'Codex CLI' }, + { id: 'other', label: 'Other' }, +]; + +interface McpInstallPanelProps { + /** + * Deployment shape derived from `useMe()` + `useTeam()` in the + * caller. Passing `null` renders a "sign in to load credentials" + * alert. + */ + deployment: DeploymentShape | null; +} + +/** + * 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 + * renders from both the EE Team Settings page and the onboarding + * `done` step in a follow-up PR. + * + * 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 + * (outcome AC16). + */ +export default function McpInstallPanel({ deployment }: McpInstallPanelProps) { + const [host, setHost] = useState('claude-code'); + + const snippets = useMemo( + () => (deployment ? buildAllSnippets(deployment) : null), + [deployment], + ); + + return ( + + setHost(value as AgentHost)} + data={CHOICES.map(c => ({ + value: c.id, + label: ( + + + {c.label} + + ), + }))} + aria-label="MCP host" + /> + + {!deployment ? ( + + Sign in to load your personal access key before installing. + + ) : !deployment.accessKey ? ( + + No access key on this account yet. Ask an admin to create one and sign + back in. + + ) : snippets ? ( + + ) : null} + + ); +} + +function HostIcon({ id }: { id: AgentHost }) { + switch (id) { + case 'claude-code': + return ; + case 'cursor': + return ; + case 'vscode-copilot': + return ; + case 'codex-cli': + return ; + case 'other': + return ; + } + // Exhaustiveness check: adding a new AgentHost variant without + // extending this switch fails the compile here. + return assertNever(id); +} + +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 'other': + return ( + + ); + } + // Exhaustiveness check: adding a new AgentHost variant without + // extending this switch fails the compile here. + return assertNever(host); +} + +function assertNever(value: never): never { + throw new Error(`Unhandled AgentHost variant: ${String(value)}`); +} 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..2993e4bede --- /dev/null +++ b/packages/app/src/components/ClickStackOnboarding/__tests__/installSnippets.test.ts @@ -0,0 +1,135 @@ +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 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]; + const decoded = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')); + expect(decoded).toMatchObject({ + type: 'http', + url: 'https://hyperdx.example.com/api/mcp', + headers: { Authorization: 'Bearer k_abcdef123456' }, + }); + }); +}); + +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 > 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}`), + 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.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..67d00f6fdd --- /dev/null +++ b/packages/app/src/components/ClickStackOnboarding/installSnippets.ts @@ -0,0 +1,185 @@ +// 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. +// +// Scoped to the self-managed deployment in this PR. The CHC +// managed (BYC) and ClickStack Cloud branches require the CP MCP +// proxy + OAuth-scoped token, tracked as outcome AC18; the +// `DeploymentShape` will gain a `mode` discriminator and CHC +// service-id field in that follow-up. + +/** + * 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; + /** 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. + */ +function base64(value: string): string { + if (typeof window !== 'undefined' && typeof window.btoa === 'function') { + return window.btoa(value); + } + return Buffer.from(value).toString('base64'); +} + +/** + * Returns the headers map for the MCP HTTP transport. Self-managed + * mode uses only the `Authorization` header; CHC modes will add + * `x-service-id` when AC18 (cloud install) lights up. + */ +function buildHeaders(deployment: DeploymentShape): Record { + return { + Authorization: `Bearer ${deployment.accessKey || ''}`, + }; +} + +/** + * Returns the MCP URL the host connects to. Self-managed talks to + * the per-tenant origin; CHC modes will route through the CP MCP + * proxy when AC18 lights up. + */ +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(' '); +} + +/** + * Claude Code one-liner. Documented Claude Code MCP install + * primitive: `claude mcp add --transport http + * --header "..."`. + */ +function buildClaudeCodeOneLiner(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 one-liner. Codex's documented MCP install + * primitive mirrors Claude Code's pattern. See + * https://developers.openai.com/codex/mcp for the full reference. + */ +function buildCodexCliOneLiner(deployment: DeploymentShape): string { + const url = buildUrl(deployment); + const headers = buildHeaders(deployment); + return `codex 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. + */ +function buildCursorDeeplink(deployment: DeploymentShape): string { + const config = { + type: 'http', + url: buildUrl(deployment), + headers: buildHeaders(deployment), + }; + const encoded = base64(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))}`; +} + +/** + * 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: buildClaudeCodeOneLiner(deployment), + cursor: buildCursorDeeplink(deployment), + vscode: buildVSCodeDeeplink(deployment), + codexCli: buildCodexCliOneLiner(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..eaa988cc41 --- /dev/null +++ b/packages/app/src/components/TeamSettings/McpServerSection.tsx @@ -0,0 +1,56 @@ +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'; + +function getApiOrigin(): string { + if (typeof window === 'undefined') return ''; + return `${window.location.origin}/api`; +} + +/** + * Renders the "Connect your AI assistant" 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. + */ +export default function McpServerSection() { + const { data: me, isLoading: isLoadingMe } = api.useMe(); + + const deployment = useMemo(() => { + if (!me) return null; + return { + apiUrl: getApiOrigin(), + accessKey: me.accessKey ?? '', + }; + }, [me]); + + // Wait for `me` before mounting the panel: the deployment shape + // depends on `me.accessKey`, and rendering mid-load would briefly + // emit the "Sign in to load your personal access key" alert path + // before the cache hydrates. + if (isLoadingMe) { + return null; + } + + return ( + + Connect your AI assistant + + + + + + ); +} 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..adbdc5f2cf --- /dev/null +++ b/packages/app/src/components/TeamSettings/__tests__/McpServerSection.test.tsx @@ -0,0 +1,105 @@ +import { MantineProvider } from '@mantine/core'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; + +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 assistant$/)).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 five 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 + Copilot')).toBeInTheDocument(); + expect(screen.getByText('Codex CLI')).toBeInTheDocument(); + expect(screen.getByText('Other')).toBeInTheDocument(); + }); + + it('renders the sign-in alert when no access key is loaded', () => { + setMe(null); + + renderSection(); + + expect( + screen.getByText(/Sign in to load your personal access key/i), + ).toBeInTheDocument(); + }); + + it('renders the fixed clickstack server name in the install snippet', () => { + renderSection(); + + expect(screen.getByText(/claude mcp add clickstack /)).toBeInTheDocument(); + }); + + it('renders the no-access-key alert when me is loaded but accessKey is empty', () => { + setMe(''); + + renderSection(); + + expect( + screen.getByText(/No access key on this account yet/i), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/app/tests/e2e/features/team-settings-mcp-install.spec.ts b/packages/app/tests/e2e/features/team-settings-mcp-install.spec.ts new file mode 100644 index 0000000000..d9b2a37b08 --- /dev/null +++ b/packages/app/tests/e2e/features/team-settings-mcp-install.spec.ts @@ -0,0 +1,162 @@ +// e2e walk-through for the new "Connect your AI assistant" section +// on the Team Settings page (Integrations tab). The section lives +// directly above the API Keys card and lets a user install the +// ClickStack MCP server in Claude Code, Cursor, VS Code + Copilot, +// Codex CLI, or any MCP-compatible host without hand-rolling JSON. + +import { expect, Page, test } from '@playwright/test'; + +const ACCESS_KEY = 'k_test_demo'; + +async function mockTeamSettingsApis(page: Page) { + const team = { + _id: 'team-1', + name: 'Acme', + apiKey: 'team-api-key', + }; + await page.route(/\/api\/(me|team)\b/, async route => { + const url = route.request().url(); + if (url.includes('/api/me')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 'user-1', + email: 'admin@example.com', + name: 'Admin User', + accessKey: ACCESS_KEY, + createdAt: '2026-05-01T00:00:00Z', + team, + teams: [ + { + id: 'team-1', + name: 'Acme', + }, + ], + usageStatsEnabled: false, + aiAssistantEnabled: false, + }), + }); + return; + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(team), + }); + }); +} + +test.describe( + 'Team Settings - Connect your AI assistant', + { tag: ['@team-settings-mcp'] }, + () => { + // The e2e webServer in `playwright.config.ts` runs with + // `NEXT_PUBLIC_IS_LOCAL_MODE=true`, which short-circuits + // `useMe`/`useTeam` to `return null` in `packages/app/src/api.ts` + // before the React Query cache can pick up the `page.route` + // mocks. Skip in local mode until the e2e build flag is wired + // up; component coverage in `__tests__/McpServerSection.test.tsx` + // exercises the render branches in the meantime. + test.fixme( + true, + 'Requires the full-stack e2e build (non-local mode); component tests cover the render branches today.', + ); + + test.beforeEach(async ({ page }) => { + page.on('console', msg => { + if (msg.type() === 'error') { + console.log('browser error:', msg.text()); + } + }); + await mockTeamSettingsApis(page); + await page + .context() + .grantPermissions(['clipboard-read', 'clipboard-write']); + }); + + test('renders the section above API Keys on the Integrations tab', async ({ + page, + }) => { + await page.goto('/team?tab=integrations'); + + const section = page.getByTestId('mcp-server-section'); + await expect(section).toBeVisible(); + + // Assert document order rather than viewport geometry: the + // section element should be followed (anywhere in the tree) + // by the API Keys section element. A Mantine spacing tweak + // can't flip this without flipping source order. + const apiKeysAfterSection = section.locator( + 'xpath=following::*[@data-testid="api-keys-section"]', + ); + await expect(apiKeysAfterSection).toHaveCount(1); + }); + + test('exposes a copyable JSON config under the Other host', async ({ + page, + }) => { + await page.goto('/team?tab=integrations'); + + // Mantine SegmentedControl is a radiogroup with one radio per + // option; clicking the visible label flips the active item. + // Scope to the section to avoid colliding with similarly + // named text elsewhere on the page. + const section = page.getByTestId('mcp-server-section'); + await section.getByText('Other', { exact: true }).click(); + + await section.getByRole('button', { name: /^Copy$/ }).click(); + + const clipboardText = await page.evaluate(() => + navigator.clipboard.readText(), + ); + const parsed = JSON.parse(clipboardText); + expect(parsed).toMatchObject({ + mcpServers: { + clickstack: { + type: 'http', + headers: { Authorization: `Bearer ${ACCESS_KEY}` }, + }, + }, + }); + expect(parsed.mcpServers.clickstack.url).toMatch(/\/api\/mcp$/); + }); + + test('builds a Cursor deep link with the access key embedded', async ({ + page, + }) => { + await page.goto('/team?tab=integrations'); + + const section = page.getByTestId('mcp-server-section'); + await section.getByText('Cursor', { exact: true }).click(); + + const addToCursor = section.getByRole('link', { name: /Add to Cursor/i }); + const href = await addToCursor.getAttribute('href'); + expect(href).toMatch( + /^cursor:\/\/anysphere\.cursor-deeplink\/mcp\/install\?name=clickstack&config=/, + ); + + const encoded = href!.split('config=')[1]; + const decoded = JSON.parse( + Buffer.from(encoded, 'base64').toString('utf8'), + ); + expect(decoded).toMatchObject({ + type: 'http', + headers: { Authorization: `Bearer ${ACCESS_KEY}` }, + }); + }); + + test('emits a Codex CLI one-liner', async ({ page }) => { + await page.goto('/team?tab=integrations'); + + const section = page.getByTestId('mcp-server-section'); + await section.getByText('Codex CLI', { exact: true }).click(); + + // The codex CLI snippet renders inline; assert the documented + // form is visible to the user with the fixed server name. + await expect( + section.getByText(/codex mcp add clickstack /), + ).toBeVisible(); + }); + }, +); From 9e6e5fa528350abcb3933492bd2a626790385dcf Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:03:52 +0000 Subject: [PATCH 2/8] fix(team-settings): address deep-review feedback on #2407 - Cursor deeplink: emit URL-safe base64 (-, _, no padding) for the config query value so the standard alphabet's + / / / = cannot be re-interpreted by the host's URL parser. + decodes as space under form-urlencoded, which would corrupt the embedded JSON config. Updated round-trip test to base64url and added a regression case asserting the encoded value only uses the URL-safe alphabet across inputs that would have produced + or / in standard base64. - base64(): UTF-8-encode before btoa so a future access-key or origin format with code points > 0xFF cannot raise InvalidCharacterError and blank the panel (buildAllSnippets builds every host eagerly). - installSnippets: extract shared buildCliOneLiner(binary, deployment) helper; Claude Code and Codex CLI now share the generator and differ only in the binary name. - McpServerSection: drop the unreachable typeof window === 'undefined' guard; the section renders client-only via useMe(). Drop the defensive me.accessKey ?? '' coalesce now that MeApiResponseSchema pins accessKey to z.string(); the downstream empty-string guard in the panel still fires. - McpInstallPanel: replace the value as AgentHost cast on SegmentedControl.onChange with an isAgentHost(value) type guard derived from CHOICES so a future out-of-band value cannot silently install an invalid host. - CopySnippet: fix the stale JSDoc reference (the cited ExporterFormatSelector.tsx is not in the repo) and drop the redundant color="gray" on the subtle Button; variant="subtle" is the canonical pattern per agent_docs/code_style.md. - Add component tests for host-switching, the Cursor / VS Code deep link href shape, the canonical JSON branch on Other, and the Manual setup toggle in DeeplinkInstall. Closes the host-coverage gap without enabling the e2e fixme (the e2e webServer still runs in IS_LOCAL_MODE, which short-circuits useMe / useTeam before the route mocks land). 22 tests pass. Co-Authored-By: Claude Opus --- .../ClickStackOnboarding/CopySnippet.tsx | 4 +- .../ClickStackOnboarding/McpInstallPanel.tsx | 16 ++++- .../__tests__/installSnippets.test.ts | 36 +++++++++- .../ClickStackOnboarding/installSnippets.ts | 67 +++++++++++++------ .../TeamSettings/McpServerSection.tsx | 12 ++-- .../__tests__/McpServerSection.test.tsx | 67 +++++++++++++++++++ 6 files changed, 168 insertions(+), 34 deletions(-) diff --git a/packages/app/src/components/ClickStackOnboarding/CopySnippet.tsx b/packages/app/src/components/ClickStackOnboarding/CopySnippet.tsx index f677b8fa9b..9f4b884ba7 100644 --- a/packages/app/src/components/ClickStackOnboarding/CopySnippet.tsx +++ b/packages/app/src/components/ClickStackOnboarding/CopySnippet.tsx @@ -9,8 +9,7 @@ interface CopySnippetProps { /** * Pre-formatted snippet with a copy-to-clipboard button. The copy * affordance handles its own `Copied` affirmation via Mantine's - * ``, matching the rest of the ClickStackOnboarding - * surfaces (`ExporterFormatSelector.tsx`). + * ``. */ export function CopySnippet({ label, snippet }: CopySnippetProps) { return ( @@ -35,7 +34,6 @@ export function CopySnippet({ label, snippet }: CopySnippetProps) {