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 }) => (
+ :
+ }
+ >
+ {copied ? '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;
}