diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71bca32..9d3fd8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,15 @@ jobs: package-manager-cache: false - name: Verify release tag run: node -e "const p=require('./package.json'); if (process.env.GITHUB_REF_NAME !== 'v'+p.version) throw new Error('tag must equal v'+p.version)" + - name: Check out proofable/mcp for canonical skill parity + uses: actions/checkout@v7 + with: + repository: proofable/mcp + path: mcp + - name: Published skills match proofable/mcp canonical skills + run: node scripts/check-mcp-skill-parity.mjs --require + env: + PROOFABLE_MCP_SKILLS_ROOT: ${{ github.workspace }}/mcp/skills - run: npm ci - run: npm run lint - run: npm test --silent diff --git a/cli/proofable.mjs b/cli/proofable.mjs index e01a936..c43e454 100644 --- a/cli/proofable.mjs +++ b/cli/proofable.mjs @@ -323,9 +323,25 @@ function hostConnectHint(clients) { }; } if (hostClients.length) { + // Refresh-first: a stored refresh token rotates the access token in place, + // with no browser round trip. Logout → Connect re-runs the full consent flow + // and should be the last resort, not the first instruction. Refresh tokens + // rotate on use and last 30 days; the host's own OAuth is the primary path, + // this is the recovery path when the host silently stops refreshing. + const stored = readTokenStore(); + if (stored?.refreshToken) { + return { + hint: + 'Session dropped? Run `npx -y @proofable/sdk refresh` to reconnect with the saved token, then retry. If the host still shows Logout and Unauthorized, sign out in the host, then start the sign-in again.' + + (hasCodex ? codexHint : ''), + nextCommand: 'npx -y @proofable/sdk refresh' + }; + } return { hint: - 'Click Connect on proofable in the host MCP panel. If the host shows Logout and Unauthorized, click Logout, then Connect.' + + // Not every host renders a control called "Connect" — Cursor, VS Code, + // Claude Code and Codex do not. Describe the step, not a button name. + 'Start the sign-in for proofable in the host MCP panel. If the host shows Logout and Unauthorized, sign out, then start it again.' + (hasCodex ? codexHint : ''), nextCommand: null }; diff --git a/mcp-install-paths.js b/mcp-install-paths.js new file mode 100644 index 0000000..5d3fc8d --- /dev/null +++ b/mcp-install-paths.js @@ -0,0 +1,356 @@ +/** + * MCP install-path truth table — the single source of truth for how a user + * actually gets Proofable into a given client. + * + * Why this file exists + * -------------------- + * The same four sentences used to be hand-written in the product page, the + * Connect menus, the CLI hints, the docs, and five READMEs. They drifted, and + * they were wrong in the same way: they told every client to "Click Connect". + * Only two clients render a button by that name. Everywhere else the user goes + * looking for a control that does not exist (Cursor, VS Code, Claude Code, + * Codex) or is named something else entirely. + * + * The rule this table encodes: never name a control. Describe the step the + * client actually performs, using the client's own vocabulary. + * + * Data-only and browser-safe on purpose: no IO, no imports, and every field + * serializable. The product page, the CLI, the docs and the marketplace + * manifests all read this one table, and the mirror into the product repo is a + * copy rather than a re-implementation. + * + * Verified against vendor docs on 2026-09-26. When a vendor changes their + * install flow, change it here and it changes everywhere. + */ + +/** + * How a client receives the server. + * + * - `deeplink` — the client has a URL scheme that installs the server on click + * (Cursor, VS Code). One step; the client handles the rest. + * - `plugin` — the client has a plugin/marketplace bundle that carries skills + * and (sometimes) the server (Claude Code, Codex, Cursor, Devin, Antigravity, + * Hermes, OpenClaw, ChatGPT). + * - `command` — a CLI/config write is the honest full path (Codex, Gemini CLI, + * Warp, OpenClaw, Hermes, Cline, JetBrains). + * - `url` — paste the endpoint into a settings UI; the host runs its own + * sign-in (ChatGPT, Claude web/Desktop connectors, Zed, n8n). + */ +export const MCP_INSTALL_CLASSES = ['deeplink', 'plugin', 'command', 'url']; + +/** + * Who completes authentication after the server is registered. + * + * - `host` — the client opens its own browser sign-in. This is the default + * and the only interactive path. Never describe the trigger as a button + * unless `connectLabel` is set. + * - `manual` — no browser sign-in; the user must supply an access key header. + * Honest only for servers, CI, containers, and scheduled jobs. Used as the + * primary path only where the client documents no OAuth (Cline, JetBrains). + * - `none` — nothing to authenticate (discovery/metadata only). + */ +export const MCP_AUTH_OWNERS = ['host', 'manual', 'none']; + +/** + * One record per client. + * + * @typedef {object} McpInstallPath + * @property {string} id Stable id; also the `--client` value where one exists. + * @property {string} label Product name the user sees. + * @property {'ide'|'cli'|'web'|'desktop'|'terminal'|'automation'} kind + * @property {number} tier 1 = first-class on /install; 2 = long tail. + * @property {string} installClass One of MCP_INSTALL_CLASSES. + * @property {'cursor'|'vscode'|null} deeplinkKind Which builder produces the install href. + * @property {string} afterInstall What the user does next. Never names a control + * unless the client really renders it. + * @property {string} authOwner One of MCP_AUTH_OWNERS. + * @property {string|null} connectLabel The literal button text, ONLY where the + * client renders such a button (Claude, Devin). + * @property {boolean} pluginSupport Whether the client accepts a plugin bundle. + * @property {string} markKey Key into the official brand-mark registry. + * @property {string} docsUrl Vendor documentation for the add-server step. + */ + +/** @type {ReadonlyArray} */ +export const MCP_INSTALL_PATHS = [ + { + id: 'cursor', + label: 'Cursor', + kind: 'ide', + tier: 1, + installClass: 'deeplink', + deeplinkKind: 'cursor', + // Cursor has no Connect button. The deeplink registers the server; the user + // enables it in Customize and Cursor runs OAuth itself. + afterInstall: 'Turn Proofable on in Customize. Cursor opens the sign-in.', + authOwner: 'host', + connectLabel: null, + pluginSupport: true, + markKey: 'cursor', + docsUrl: 'https://cursor.com/docs/context/mcp/install-links' + }, + { + id: 'vscode', + label: 'VS Code', + kind: 'ide', + tier: 1, + installClass: 'deeplink', + deeplinkKind: 'vscode', + // No Connect button. The server appears in the MCP list and must be started. + afterInstall: 'Start it from “MCP: List Servers”. VS Code opens the sign-in.', + authOwner: 'host', + connectLabel: null, + pluginSupport: true, + markKey: 'vscode', + docsUrl: 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers' + }, + { + id: 'claude-code', + label: 'Claude Code', + kind: 'cli', + tier: 1, + installClass: 'plugin', + deeplinkKind: null, + // The Proofable plugin is skill-only: it ships the workflow skills and does + // NOT register the server (see mcp/plugins/proofable-mcp). So this is two + // honest steps, and the sign-in is a slash command, not a button. + afterInstall: 'Installs the Proofable skills. Add the server, then run /mcp and sign in.', + authOwner: 'host', + connectLabel: null, + pluginSupport: true, + markKey: 'claude-code', + docsUrl: 'https://code.claude.com/docs/en/mcp' + }, + { + id: 'claude-connectors', + label: 'Claude (connectors)', + kind: 'web', + tier: 1, + installClass: 'url', + deeplinkKind: null, + // The one client where "Connect" is the real button label. Owners add the + // connector once for the whole organization; members then authenticate. + afterInstall: 'Add the connector, then click Connect to sign in.', + authOwner: 'host', + connectLabel: 'Connect', + pluginSupport: true, + markKey: 'anthropic', + docsUrl: 'https://support.claude.com/en/articles/11175166-getting-started-with-custom-connectors' + }, + { + id: 'devin', + label: 'Devin', + kind: 'desktop', + tier: 1, + installClass: 'plugin', + deeplinkKind: null, + // Devin renders a real Connect button after the custom MCP is added. + afterInstall: 'Add the MCP, then click Connect to sign in.', + authOwner: 'host', + connectLabel: 'Connect', + pluginSupport: true, + markKey: 'devin', + docsUrl: 'https://docs.devin.ai/work-with-devin/mcp' + }, + { + id: 'codex', + label: 'Codex', + kind: 'cli', + tier: 2, + installClass: 'command', + deeplinkKind: null, + // No Connect button: the client's own login subcommand owns the sign-in. + afterInstall: 'Then run codex mcp login proofable.', + authOwner: 'host', + connectLabel: null, + pluginSupport: true, + markKey: 'codex', + docsUrl: 'https://developers.openai.com/codex/mcp' + }, + { + id: 'chatgpt', + label: 'ChatGPT', + kind: 'web', + tier: 2, + installClass: 'url', + deeplinkKind: null, + afterInstall: 'Turn on developer mode, add the connection, and sign in when ChatGPT asks.', + authOwner: 'host', + connectLabel: null, + pluginSupport: true, + markKey: 'openai', + docsUrl: 'https://developers.openai.com/plugins/deploy/connect-chatgpt' + }, + { + id: 'gemini-cli', + label: 'Gemini CLI', + kind: 'cli', + tier: 2, + installClass: 'command', + deeplinkKind: null, + afterInstall: 'Then run /mcp auth proofable.', + authOwner: 'host', + connectLabel: null, + pluginSupport: false, + markKey: 'gemini', + docsUrl: 'https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md' + }, + { + id: 'antigravity', + label: 'Antigravity', + kind: 'desktop', + tier: 2, + installClass: 'plugin', + deeplinkKind: null, + afterInstall: 'Then sign in when Antigravity asks.', + authOwner: 'host', + connectLabel: null, + pluginSupport: true, + markKey: 'antigravity', + docsUrl: 'https://antigravity.google/docs/mcp' + }, + { + id: 'warp', + label: 'Warp', + kind: 'terminal', + tier: 2, + installClass: 'command', + deeplinkKind: null, + afterInstall: 'Start it and Warp opens the sign-in.', + authOwner: 'host', + connectLabel: null, + pluginSupport: true, + markKey: 'warp', + docsUrl: 'https://docs.warp.dev/knowledge-and-collaboration/mcp' + }, + { + id: 'zed', + label: 'Zed', + kind: 'ide', + tier: 2, + installClass: 'url', + deeplinkKind: null, + afterInstall: 'Add it as a remote server, then sign in when Zed prompts you.', + authOwner: 'host', + connectLabel: null, + pluginSupport: false, + markKey: 'zed', + docsUrl: 'https://zed.dev/docs/assistant/model-context-protocol' + }, + { + id: 'openclaw', + label: 'OpenClaw', + kind: 'cli', + tier: 2, + installClass: 'command', + deeplinkKind: null, + afterInstall: 'Then run openclaw mcp login proofable.', + authOwner: 'host', + connectLabel: null, + pluginSupport: true, + markKey: 'openclaw', + docsUrl: 'https://docs.openclaw.ai/tools/mcp' + }, + { + id: 'hermes', + label: 'Hermes', + kind: 'cli', + tier: 2, + installClass: 'command', + deeplinkKind: null, + afterInstall: 'Then run hermes mcp login proofable.', + authOwner: 'host', + connectLabel: null, + pluginSupport: true, + markKey: 'hermes', + docsUrl: 'https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp' + }, + { + id: 'n8n', + label: 'n8n', + kind: 'automation', + tier: 2, + installClass: 'url', + deeplinkKind: null, + afterInstall: 'Use the MCP Client node with OAuth2 and dynamic client registration on.', + authOwner: 'host', + connectLabel: null, + pluginSupport: false, + markKey: 'n8n', + docsUrl: 'https://docs.n8n.io/integrations/builtin/credentials/mcp/' + }, + { + id: 'cline', + label: 'Cline', + kind: 'ide', + tier: 2, + installClass: 'command', + deeplinkKind: null, + // Cline documents no browser sign-in: an access-key header is the honest path. + afterInstall: 'Cline needs an access key header; it has no browser sign-in.', + authOwner: 'manual', + connectLabel: null, + pluginSupport: false, + markKey: 'cline', + docsUrl: 'https://docs.cline.bot/mcp/connecting-to-a-remote-server' + }, + { + id: 'jetbrains', + label: 'JetBrains IDEs', + kind: 'ide', + tier: 2, + installClass: 'command', + deeplinkKind: null, + // No documented MCP OAuth in JetBrains docs: do not promise a sign-in. + afterInstall: 'No documented sign-in yet; use an access key header.', + authOwner: 'manual', + connectLabel: null, + pluginSupport: false, + markKey: 'jetbrains', + docsUrl: 'https://www.jetbrains.com/help/ai-assistant/mcp.html' + } +]; + +/** Clients shown as first-class on the install surface. */ +export const MCP_PRIMARY_CLIENT_IDS = MCP_INSTALL_PATHS + .filter((entry) => entry.tier === 1) + .map((entry) => entry.id); + +/** Everything else, for the long-tail list. */ +export const MCP_SECONDARY_CLIENT_IDS = MCP_INSTALL_PATHS + .filter((entry) => entry.tier === 2) + .map((entry) => entry.id); + +/** Look up one client's install path. */ +export function mcpInstallPath(id) { + const key = String(id || '').trim(); + return MCP_INSTALL_PATHS.find((entry) => entry.id === key) || null; +} + +/** + * True when the client really renders a "Connect" control. + * Callers that want to name a control must ask this first, so the old + * "Click Connect" copy cannot come back by habit. + */ +export function mcpClientHasConnectButton(id) { + return Boolean(mcpInstallPath(id)?.connectLabel); +} + +/** + * True when a browser sign-in is the honest interactive path. + * False means an access key is the only documented route. + */ +export function mcpClientSignsInInBrowser(id) { + return mcpInstallPath(id)?.authOwner === 'host'; +} + +/** + * The honest next-step sentence for a client, safe to render verbatim. + * Falls back to the generic any-client instruction for an unknown id rather + * than inventing a host-specific step. + */ +export function mcpAfterInstallCopy(id) { + const entry = mcpInstallPath(id); + if (entry) return entry.afterInstall; + return 'Then complete the sign-in your client opens.'; +} diff --git a/package.json b/package.json index 3d604b0..242710e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,12 @@ "import": "./errors.js", "require": "./cjs/errors.cjs" }, + "./mcp-hosts": { + "import": "./mcp-hosts.js" + }, + "./mcp-install-paths": { + "import": "./mcp-install-paths.js" + }, "./gates": { "import": "./gates.js", "require": "./cjs/gates.cjs" @@ -150,6 +156,7 @@ "skills/**", "cli-commands.js", "mcp-hosts.js", + "mcp-install-paths.js", "index.js", "client.js", "utils.js", diff --git a/scripts/check-mcp-skill-parity.mjs b/scripts/check-mcp-skill-parity.mjs index c14563c..0584b8a 100644 --- a/scripts/check-mcp-skill-parity.mjs +++ b/scripts/check-mcp-skill-parity.mjs @@ -4,15 +4,23 @@ * skills in proofable/mcp (skills/ there is the single source of truth; the * copies here are generated by that repo's scripts/sync-plugin-skills.mjs). * - * Run locally from the sdk root (proofable/mcp must exist as a sibling): - * node scripts/check-mcp-skill-parity.mjs - * CI sets PROOFABLE_MCP_SKILLS_ROOT to the checked-out proofable/mcp repo. + * The two live in different repositories, so they can only agree once both + * carry the same content. While the paired pull requests are open, the sibling + * is on its default branch and a mismatch is expected — not a defect here. + * + * node scripts/check-mcp-skill-parity.mjs informational (exit 0) + * node scripts/check-mcp-skill-parity.mjs --require gate: exit 1 on mismatch + * + * Default is informational so pull-request CI never blocks on an unlandable + * cross-repository comparison. Release runs with --require, where both sides are + * settled. CI sets PROOFABLE_MCP_SKILLS_ROOT to the checked-out proofable/mcp. */ import { createHash } from 'node:crypto'; import { readdirSync, readFileSync, existsSync } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +const requireMatch = process.argv.includes('--require'); const sdkRoot = process.cwd(); const mcpSkillsRoot = process.env.PROOFABLE_MCP_SKILLS_ROOT ? path.resolve(process.env.PROOFABLE_MCP_SKILLS_ROOT) @@ -76,6 +84,14 @@ for (const name of sdkSkills) { } if (errors.length > 0) { + if (!requireMatch) { + console.warn('skill parity: not yet aligned with proofable/mcp (informational):'); + for (const error of errors) console.warn(` - ${error}`); + console.warn( + 'Expected while the paired pull requests are open. Release enforces this with --require.', + ); + process.exit(0); + } console.error('skill parity: out of sync with proofable/mcp:'); for (const error of errors) console.error(` - ${error}`); process.exit(1); diff --git a/skills/proofable-setup/SKILL.md b/skills/proofable-setup/SKILL.md index c115b3e..ec78c63 100644 --- a/skills/proofable-setup/SKILL.md +++ b/skills/proofable-setup/SKILL.md @@ -15,11 +15,13 @@ Give AI agents verified identity, scoped permissions, and reusable proof through Add Proofable to any app, chat, or agent that speaks MCP. Cursor, Claude, Codex, and VS Code are shortcuts. -Install Proofable, then click **Connect**: +Add Proofable, then finish sign-in in your client: `https://mcp.proofable.me/mcp` -If the host offers the Proofable plugin, install it and click **Connect** instead of adding the URL by hand. Do not add a second `proofable` entry. +There is no universal Connect button. Cursor, VS Code, Claude Code, and Codex each run their own sign-in after the server is registered; only Claude connectors and Devin show a control called Connect. + +If the client offers the Proofable plugin, install that instead of adding the URL by hand. It ships these skills, and in Cursor it registers the server too. Do not add a second `proofable` entry. Have the CLI? @@ -27,7 +29,7 @@ Have the CLI? proofable setup ``` -After Connect, ask: +After sign-in, ask: ```text Show my Proofable profile and current proofs. diff --git a/skills/proofable-trust-workflow/references/setup.md b/skills/proofable-trust-workflow/references/setup.md index 81a84e5..497bd47 100644 --- a/skills/proofable-trust-workflow/references/setup.md +++ b/skills/proofable-trust-workflow/references/setup.md @@ -4,13 +4,15 @@ Load this file only when the user needs install, sign-in, access keys, or projec ## Install -Install Proofable on this host, then click **Connect**: +Install Proofable on this host, then finish sign-in in the client itself: `https://mcp.proofable.me/mcp` -If the host already has a Proofable plugin, use that Connect path. Do not also write a second `proofable` entry. +There is no universal Connect button. Cursor, VS Code, Claude Code, and Codex each run their own sign-in once the server is registered; only Claude connectors and Devin render a control named Connect. -If Connect is missing, use the host’s own MCP login after the URL is registered. +If the host already has a Proofable plugin, use the plugin path. Do not also write a second `proofable` entry. + +If the client shows no sign-in, use its own MCP login command after the URL is registered. Have the CLI? @@ -28,7 +30,7 @@ Create access keys under **Account → Access keys** on [proofable.me](https://p Hosted MCP: **`https://mcp.proofable.me/mcp`** -After Connect, call `proofable_context`. To sell: set payouts at https://proofable.me/profile?tab=credits, then create a listing at https://proofable.me/profile/portals/new. Full page: https://docs.proofable.me/mcp/setup +After sign-in, call `proofable_context`. To sell: set payouts at https://proofable.me/profile?tab=credits, then create a listing at https://proofable.me/profile/portals/new. Full page: https://docs.proofable.me/mcp/setup ## Connect an agent to a project @@ -44,4 +46,4 @@ proofable mount --apply | **Project** | `proofable mount --apply ` | | **Session** | `proofable_context` → `proofable_agent_mount` when acting as the agent | -Use `proofable mount` only when acting as a registered profile agent. For proofs and secrets, Connect plus `proofable_context` is enough. +Use `proofable mount` only when acting as a registered profile agent. For proofs and secrets, a completed sign-in plus `proofable_context` is enough. diff --git a/test/cli.test.js b/test/cli.test.js index 086561e..0709d7d 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -422,8 +422,9 @@ describe('proofable CLI', () => { expect(payload.accessKeyConfigured).toBe(false); expect(payload.authRequired).toBe(true); expect(payload.nextCommand).toBeNull(); - expect(payload.hostSignInHint).toContain('Logout'); - expect(payload.hostSignInHint).toContain('Connect'); + // Cursor renders no Connect button, so the hint must not name one. + expect(payload.hostSignInHint).not.toMatch(/click\s+connect/i); + expect(payload.hostSignInHint).toMatch(/sign-in/i); expect(payload.hostSignInHint).not.toContain('Do not run proofable auth'); const cursorConfig = JSON.parse( @@ -432,6 +433,33 @@ describe('proofable CLI', () => { expect(cursorConfig.mcpServers.proofable.headers?.Authorization).toBeUndefined(); }); + it('prefers silent refresh over logout when a stored refresh token exists', async () => { + const context = await makeCliContext(); + // A prior `auth --oauth` leaves a rotating refresh token behind. + await fs.mkdir(path.join(context.homeDir, '.proofable'), { recursive: true }); + await fs.writeFile( + path.join(context.homeDir, '.proofable', 'mcp-tokens.json'), + JSON.stringify({ + accessToken: 'expired-access-token', + refreshToken: 'rt_stored_rotating_token', + expiresAt: Date.now() - 1000, + clientId: 'proofable-cli', + resource: 'https://mcp.proofable.me/mcp', + }), + 'utf8', + ); + + const { stdout } = await runCli(['setup', '--client', 'cursor', '--json'], context); + const payload = JSON.parse(stdout); + + // Reconnection is one command that reuses the saved token, not a full + // browser re-consent, and it never names a control the client lacks. + expect(payload.authRequired).toBe(true); + expect(payload.nextCommand).toBe('npx -y @proofable/sdk refresh'); + expect(payload.hostSignInHint).toContain('refresh'); + expect(payload.hostSignInHint).not.toMatch(/click\s+connect/i); + }); + it('applies PROOFABLE_ACCESS_KEY from the shell on setup', async () => { const context = await makeCliContext(); context.env.PROOFABLE_ACCESS_KEY = 'npk_from_env_auto'; @@ -586,13 +614,15 @@ describe('proofable CLI', () => { expect(payload.prompts).toHaveLength(6); }); - it('tells host-connect clients to Logout then Connect instead of proofable auth', async () => { + it('points host-connect clients at their own sign-in instead of a Connect control', async () => { const context = await makeCliContext(); const { stderr } = await runCli(['setup', '--client', 'cursor'], context); - expect(stderr).toContain('Logout'); - expect(stderr).toContain('Connect'); + // Cursor has no Connect button, so the hint describes the client's own + // sign-in rather than naming a control that does not exist. + expect(stderr).not.toMatch(/click\s+connect/i); + expect(stderr).toMatch(/sign-in/i); expect(stderr).not.toContain('Do not run proofable auth'); expect(stderr).not.toContain('or click Connect in your host'); }); @@ -605,7 +635,9 @@ describe('proofable CLI', () => { expect(stderr).toBe(''); expect(payload.authMethod).toBe('host-oauth'); - expect(payload.hostSignInHint).toContain('Connect'); + // Cursor renders no Connect button; the hint must describe the sign-in. + expect(payload.hostSignInHint).not.toMatch(/click\s+connect/i); + expect(payload.hostSignInHint).toMatch(/sign-in/i); expect(payload.hostSignInHint).not.toContain('Do not run proofable auth'); expect(payload.results[0].authConfigured).toBe(false); diff --git a/test/mcp-install-paths.test.js b/test/mcp-install-paths.test.js new file mode 100644 index 0000000..0a404bd --- /dev/null +++ b/test/mcp-install-paths.test.js @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; + +import { + MCP_AUTH_OWNERS, + MCP_INSTALL_CLASSES, + MCP_INSTALL_PATHS, + MCP_PRIMARY_CLIENT_IDS, + MCP_SECONDARY_CLIENT_IDS, + mcpAfterInstallCopy, + mcpClientHasConnectButton, + mcpClientSignsInInBrowser, + mcpInstallPath +} from '../mcp-install-paths.js'; +import { buildCursorMcpInstallHref, buildVsCodeMcpInstallHref } from '../mcp-hosts.js'; + +/** + * The regression this file locks out. + * + * Every client used to be handed the same sentence — "Click Connect and sign + * in" — in the product page, the Connect menus, the CLI hint and five READMEs. + * Only two clients render a control by that name. For Cursor, VS Code, Claude + * Code and Codex the user went looking for a button that does not exist. + */ +const FORBIDDEN_CONTROL_CLAIM = /click\s+(\*\*)?connect/i; + +describe('MCP install-path truth table', () => { + it('gives every entry a valid class, auth owner and vendor doc link', () => { + expect(MCP_INSTALL_PATHS.length).toBeGreaterThan(0); + for (const entry of MCP_INSTALL_PATHS) { + expect(MCP_INSTALL_CLASSES, entry.id).toContain(entry.installClass); + expect(MCP_AUTH_OWNERS, entry.id).toContain(entry.authOwner); + expect(entry.label, entry.id).toBeTruthy(); + expect(entry.afterInstall, entry.id).toBeTruthy(); + expect(entry.docsUrl, entry.id).toMatch(/^https:\/\//); + expect(entry.tier === 1 || entry.tier === 2, entry.id).toBe(true); + } + }); + + it('never names a Connect control unless the client really renders one', () => { + // "Connect" is the literal button label only in Claude connectors and Devin. + const withButton = MCP_INSTALL_PATHS.filter((entry) => entry.connectLabel !== null); + expect(withButton.map((entry) => entry.id).sort()).toEqual(['claude-connectors', 'devin']); + + for (const entry of MCP_INSTALL_PATHS) { + expect(mcpClientHasConnectButton(entry.id), entry.id).toBe(entry.connectLabel !== null); + } + }); + + it('does not tell a client without a Connect button to click Connect', () => { + for (const entry of MCP_INSTALL_PATHS) { + if (mcpClientHasConnectButton(entry.id)) continue; + expect(entry.afterInstall, entry.id).not.toMatch(FORBIDDEN_CONTROL_CLAIM); + } + }); + + it('only claims a browser sign-in where the client actually runs one', () => { + // Manual-key clients document no OAuth; promising a sign-in would be the + // same class of lie as the Connect copy. + const manual = MCP_INSTALL_PATHS.filter((entry) => entry.authOwner === 'manual'); + expect(manual.map((entry) => entry.id).sort()).toEqual(['cline', 'jetbrains']); + + for (const entry of manual) { + expect(mcpClientSignsInInBrowser(entry.id), entry.id).toBe(false); + expect(entry.afterInstall, entry.id).toMatch(/access key/i); + } + }); + + it('backs every deeplink entry with a real builder', () => { + const deeplink = MCP_INSTALL_PATHS.filter((entry) => entry.installClass === 'deeplink'); + expect(deeplink.length).toBe(2); + for (const entry of deeplink) { + expect(entry.deeplinkKind, entry.id).toBeTruthy(); + } + expect(buildCursorMcpInstallHref().startsWith('cursor://')).toBe(true); + expect(buildVsCodeMcpInstallHref().startsWith('vscode:mcp/install?')).toBe(true); + }); + + it('splits first-class clients from the long tail without dropping any', () => { + expect(MCP_PRIMARY_CLIENT_IDS).toEqual([ + 'cursor', + 'vscode', + 'claude-code', + 'claude-connectors', + 'devin' + ]); + expect(MCP_PRIMARY_CLIENT_IDS.length + MCP_SECONDARY_CLIENT_IDS.length).toBe( + MCP_INSTALL_PATHS.length + ); + // Codex has no deeplink and no Connect button, so it is long-tail, not + // first-class. This is the swap decision, locked. + expect(MCP_SECONDARY_CLIENT_IDS).toContain('codex'); + expect(MCP_PRIMARY_CLIENT_IDS).not.toContain('codex'); + }); + + it('falls back to generic wording for an unknown client instead of inventing a step', () => { + const copy = mcpAfterInstallCopy('something-that-does-not-exist'); + expect(copy).not.toMatch(FORBIDDEN_CONTROL_CLAIM); + expect(copy).toMatch(/sign-in/i); + expect(mcpInstallPath('')).toBeNull(); + expect(mcpInstallPath('nope')).toBeNull(); + expect(mcpClientHasConnectButton('nope')).toBe(false); + }); +});