From 1ebe1c7fcfcf56bcd7c6fe3520033ec604945622 Mon Sep 17 00:00:00 2001 From: GallaGit <230304929+GallaGit@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:38:34 +0200 Subject: [PATCH 1/2] feat(frontend): add README install badges to MCP server page (#1012) Move the Cursor/VS Code deep-link builders into lib/mcp-install-links.ts, add https install-link variants, and a 'Share this server' card with a locally rendered preview, Copy Markdown/HTML, OAuth-only gating and a localhost warning. Adds Playwright coverage and a docs paragraph. Closes #1012 --- docs/guides.md | 4 + .../frontend/src/app/mcp-server/[id]/page.tsx | 86 +++++++++++++++-- .../frontend/src/lib/mcp-install-links.ts | 92 +++++++++++++++++++ .../e2e/mcp-server-connect-clients.spec.ts | 73 ++++++++++++++- 4 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 packages/frontend/src/lib/mcp-install-links.ts diff --git a/docs/guides.md b/docs/guides.md index cd9ead920..3d11265e4 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -18,6 +18,10 @@ Per-adapter setup guides (English, German, Italian and more) live at **[anything | **Cursor** | [Setup →](integrations/claude.md#cursor) | Streamable HTTP | | **Any MCP client** | [Setup →](integrations/claude.md#any-mcp-client) | Streamable HTTP | +### Share a server with your team + +On an MCP server's page, the **Share this server** card gives you "Add to Cursor" and "Add to VS Code" badges to paste into a team README, wiki or onboarding page (**Copy Markdown** or **Copy HTML**). They use the editors' https install links, so they survive GitHub's Markdown sanitizer. A badge carries only the server's MCP URL, never an API key: whoever clicks it signs in with OAuth and must be a member of the workspace, so badges are shown only when `MCP_AUTH_MODE` is `oauth2` or `both`. If the URL is `localhost` or `127.0.0.1`, the badges only work on that machine. The preview on the page is rendered locally; only the copied snippet references remote badge images. + --- ## Connector guides diff --git a/packages/frontend/src/app/mcp-server/[id]/page.tsx b/packages/frontend/src/app/mcp-server/[id]/page.tsx index e028e6f2c..980f01161 100644 --- a/packages/frontend/src/app/mcp-server/[id]/page.tsx +++ b/packages/frontend/src/app/mcp-server/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; -import { mcpServers, connectors as connectorsApi, mcpKeys, productEvents } from '@/lib/api'; +import { mcpServers, connectors as connectorsApi, mcpKeys, productEvents, server as serverApi } from '@/lib/api'; import { AppShell } from '@/components/app-shell'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; @@ -15,6 +15,13 @@ import { copyText } from '@/lib/clipboard'; import { getApiOrigin } from '@/lib/origin'; import { CLAUDE_DIRECTORY_URL } from '@/lib/marketing'; import { INSPECTOR_URL, inspectorCommand } from '@/lib/inspector-url'; +import { + cursorDeepLink as buildCursorDeepLink, + vscodeDeepLink as buildVscodeDeepLink, + installBadgesHtml, + installBadgesMarkdown, + isLocalOnlyUrl, +} from '@/lib/mcp-install-links'; // Opens claude.ai straight on its "Add custom connector" dialog. Connectors // moved from Settings to Customize → Connectors; the old settings URL now only @@ -34,6 +41,8 @@ export default function McpServerDetailPage() { const [offerKey, setOfferKey] = useState(0); const [allConnectors, setAllConnectors] = useState([]); const [loading, setLoading] = useState(true); + // MCP auth mode of this instance; install badges only work with OAuth. + const [mcpAuthMode, setMcpAuthMode] = useState(''); // Edit state const [editName, setEditName] = useState(''); @@ -104,6 +113,10 @@ export default function McpServerDetailPage() { }).catch(() => {}).finally(() => setLoading(false)); }, [token, id]); + useEffect(() => { + serverApi.info().then((info) => setMcpAuthMode(info?.mcpAuthMode || '')).catch(() => {}); + }, []); + const apiUrl = getApiOrigin(); const handleSave = async () => { @@ -336,15 +349,12 @@ export default function McpServerDetailPage() { } }`; - const cursorDeepLink = () => { - const config = btoa(JSON.stringify({ url: endpointUrl })); - return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(slug)}&config=${config}`; - }; - - const vscodeDeepLink = () => { - const config = { name: slug, type: 'http', url: endpointUrl }; - return `vscode:mcp/install?${encodeURIComponent(JSON.stringify(config))}`; - }; + const installTarget = { name: slug, url: endpointUrl }; + const cursorDeepLink = () => buildCursorDeepLink(installTarget); + const vscodeDeepLink = () => buildVscodeDeepLink(installTarget); + const badgesMarkdown = installBadgesMarkdown(installTarget); + const badgesHtml = installBadgesHtml(installTarget); + const badgesSupported = mcpAuthMode === 'oauth2' || mcpAuthMode === 'both'; const aiClients: { id: string; name: string; init: string; tone: Tone }[] = [ { id: 'cursor', name: 'Cursor', init: 'Cu', tone: 'info' }, @@ -802,6 +812,62 @@ Auth: OAuth`; + +
Share this server
+

+ Put one-click install badges in your team README, wiki or onboarding page. + They contain only the MCP URL; whoever clicks still signs in. +

+ {!mcpAuthMode ? null : !badgesSupported ? ( +

+ One-click installs need OAuth: set MCP_AUTH_MODE=oauth2 or both. +

+ ) : ( +
+ {isLocalOnlyUrl(endpointUrl) && ( +

+ This MCP URL points to localhost, so the badges only work on this machine. +

+ )} + {/* Local preview: styled links only, no remote images (offline installs). */} +
+ + Cursor + Add to Cursor + + + VS Code + Install MCP server + +
+
+ + +
+ {codeBlock(badgesMarkdown, 'Markdown')} +
+ )} +
{/* Connect Modal */} diff --git a/packages/frontend/src/lib/mcp-install-links.ts b/packages/frontend/src/lib/mcp-install-links.ts new file mode 100644 index 000000000..f4d8216c5 --- /dev/null +++ b/packages/frontend/src/lib/mcp-install-links.ts @@ -0,0 +1,92 @@ +/** + * One-click MCP install links for Cursor and VS Code. + * + * The in-app "Open in …" buttons use the editors' custom-scheme deep links; + * the README badges use the https variants, because GitHub's Markdown + * sanitizer (and most wikis) drop links that are not http(s). All builders + * share the same config so a button and a badge always install the same thing. + * + * Docs: + * - Cursor: https://cursor.com/docs/context/mcp/install-links + * - VS Code: https://code.visualstudio.com/docs/copilot/customization/mcp-servers + */ + +export interface McpInstallTarget { + /** Server name shown in the editor (the server slug). */ + name: string; + /** Streamable-HTTP MCP endpoint. */ + url: string; +} + +// UTF-8 safe base64 (btoa alone throws on non-Latin-1 characters). +function toBase64(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ''; + bytes.forEach((b) => { + binary += String.fromCharCode(b); + }); + return btoa(binary); +} + +function cursorConfig({ url }: McpInstallTarget): string { + return toBase64(JSON.stringify({ url })); +} + +function vscodeConfig({ name, url }: McpInstallTarget): string { + return JSON.stringify({ name, type: 'http', url }); +} + +export function cursorDeepLink(target: McpInstallTarget): string { + return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(target.name)}&config=${cursorConfig(target)}`; +} + +export function vscodeDeepLink(target: McpInstallTarget): string { + return `vscode:mcp/install?${encodeURIComponent(vscodeConfig(target))}`; +} + +export function cursorInstallUrl(target: McpInstallTarget): string { + return `https://cursor.com/en/install-mcp?name=${encodeURIComponent(target.name)}&config=${encodeURIComponent(cursorConfig(target))}`; +} + +export function vscodeInstallUrl(target: McpInstallTarget): string { + return `https://vscode.dev/redirect/mcp/install?name=${encodeURIComponent(target.name)}&config=${encodeURIComponent(vscodeConfig(target))}`; +} + +// Remote badge images. Only ever placed in the copied snippet, never loaded +// by the app itself (self-hosted instances may run fully offline). +// Cursor documents an official button; VS Code does not, so it uses a +// shields.io static badge in the README's style. +export const CURSOR_BADGE_IMAGE = 'https://cursor.com/deeplink/mcp-install-dark.svg'; +export const VSCODE_BADGE_IMAGE = + 'https://img.shields.io/badge/VS_Code-Install_MCP_server-0098FF?logo=visualstudiocode&logoColor=white&labelColor=0b1220'; + +const CURSOR_ALT = 'Add to Cursor'; +const VSCODE_ALT = 'Add to VS Code'; + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +} + +export function installBadgesMarkdown(target: McpInstallTarget): string { + return [ + `[![${CURSOR_ALT}](${CURSOR_BADGE_IMAGE})](${cursorInstallUrl(target)})`, + `[![${VSCODE_ALT}](${VSCODE_BADGE_IMAGE})](${vscodeInstallUrl(target)})`, + ].join(' '); +} + +export function installBadgesHtml(target: McpInstallTarget): string { + return [ + `${CURSOR_ALT}`, + `${VSCODE_ALT}`, + ].join('\n'); +} + +/** True when the endpoint only resolves on the machine that serves it. */ +export function isLocalOnlyUrl(url: string): boolean { + try { + const host = new URL(url).hostname.replace(/^\[|\]$/g, ''); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.localhost'); + } catch { + return false; + } +} diff --git a/packages/frontend/tests/e2e/mcp-server-connect-clients.spec.ts b/packages/frontend/tests/e2e/mcp-server-connect-clients.spec.ts index c8501e5b0..7fad3404b 100644 --- a/packages/frontend/tests/e2e/mcp-server-connect-clients.spec.ts +++ b/packages/frontend/tests/e2e/mcp-server-connect-clients.spec.ts @@ -18,7 +18,7 @@ const USER = { const DIRECTORY_URL = 'https://claude.ai/directory/anythingmcp'; -async function openServerPage(page: Page, deploymentMode: 'cloud' | 'self-hosted') { +async function openServerPage(page: Page, deploymentMode: 'cloud' | 'self-hosted', mcpAuthMode = 'oauth2') { const origin = test.info().project.use.baseURL ?? 'http://localhost:3100'; await page.context().addCookies([{ name: 'amcp_token', value: 'test-token', url: origin }]); await page.addInitScript((user) => { @@ -35,7 +35,7 @@ async function openServerPage(page: Page, deploymentMode: 'cloud' | 'self-hosted if (p === '/health/server-info') { serverInfoServed = true; - return json({ deploymentMode, mcpAuthMode: 'oauth2', hasUsers: true, registrationEnabled: true, ssoProviders: [] }); + return json({ deploymentMode, mcpAuthMode, hasUsers: true, registrationEnabled: true, ssoProviders: [] }); } if (p.endsWith('/mcp-servers/s1')) { return json({ id: 's1', name: 'Search', slug: 'search', isActive: true, apiKeys: [], connectors: [] }); @@ -115,3 +115,72 @@ test.describe('MCP server Quick Connect', () => { }); } }); + +test.describe('MCP server Share card', () => { + test('copies README badges with https install links and loads nothing third-party', async ({ page, context }) => { + const origin = test.info().project.use.baseURL ?? 'http://localhost:3100'; + await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin }); + const foreign: string[] = []; + const appHost = new URL(origin).hostname; + page.on('request', (req) => { + const host = new URL(req.url()).hostname; + if (!req.url().startsWith('data:') && host !== appHost && host !== 'localhost' && host !== '127.0.0.1') { + foreign.push(req.url()); + } + }); + + await openServerPage(page, 'self-hosted'); + const card = page.getByTestId('share-server'); + await expect(card.getByText('Share this server')).toBeVisible(); + await expect(card.getByTestId('share-preview').getByRole('link')).toHaveCount(2); + await expect(card.locator('img')).toHaveCount(0); + + const endpoint = (await page.locator('code', { hasText: /\/mcp\/s1$/ }).first().textContent())!.trim(); + // The dev server runs on localhost, so the warning must show. + await expect(card.getByTestId('share-localhost-warning')).toBeVisible(); + + await card.getByRole('button', { name: 'Copy Markdown' }).click(); + await expect(card.getByRole('button', { name: 'Copied!' })).toBeVisible(); + const md = await page.evaluate(() => navigator.clipboard.readText()); + + expect(md).not.toMatch(/cursor:\/\/|vscode:|X-API-Key|token|cookie/i); + const links = [...md.matchAll(/\]\((https:\/\/[^)\s]+)\)/g)].map((m) => m[1]); + const cursorUrl = new URL(links.find((l) => l.startsWith('https://cursor.com/en/install-mcp'))!); + const vscodeUrl = new URL(links.find((l) => l.startsWith('https://vscode.dev/redirect/mcp/install'))!); + expect(cursorUrl.searchParams.get('name')).toBe('search'); + expect(JSON.parse(Buffer.from(cursorUrl.searchParams.get('config')!, 'base64').toString())).toEqual({ url: endpoint }); + expect(JSON.parse(vscodeUrl.searchParams.get('config')!)).toEqual({ name: 'search', type: 'http', url: endpoint }); + + await card.getByRole('button', { name: 'Copy HTML' }).click(); + const html = await page.evaluate(() => navigator.clipboard.readText()); + expect(html).toContain(' { + await openServerPage(page, 'self-hosted'); + await openClient(page, 'Cursor'); + const href = await page.getByRole('link', { name: 'Open in Cursor' }).getAttribute('href'); + const endpoint = (await page.locator('code', { hasText: /\/mcp\/s1$/ }).first().textContent())!.trim(); + expect(href).toBe(`cursor://anysphere.cursor-deeplink/mcp/install?name=search&config=${btoa(JSON.stringify({ url: endpoint }))}`); + await page.keyboard.press('Escape'); + await page.goto('/mcp-server/s1'); + await openClient(page, 'VS Code'); + await expect(page.getByRole('link', { name: 'Open in VS Code' })).toHaveAttribute( + 'href', + `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: 'search', type: 'http', url: endpoint }))}`, + ); + }); + + test('explains that badges need OAuth in legacy mode', async ({ page }) => { + await openServerPage(page, 'self-hosted', 'legacy'); + const card = page.getByTestId('share-server'); + await expect(card.getByTestId('share-oauth-required')).toContainText('MCP_AUTH_MODE=oauth2'); + await expect(card.getByRole('button', { name: 'Copy Markdown' })).toHaveCount(0); + await expect(card.getByTestId('share-preview')).toHaveCount(0); + }); +}); From cb936a7ecf8e946e90371819c18f1ee12376061f Mon Sep 17 00:00:00 2001 From: GallaGit <230304929+GallaGit@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:08:26 +0200 Subject: [PATCH 2/2] fix(frontend): address review on install badges (#1012) - percent-encode ( ) ! * ' in https install URLs so slugs cannot break Markdown links - preview badges link to the shared https install URLs - hide Share card until auth mode is known - plain
 for the snippet (no duplicate copy button)
- VS Code badge reads 'Add to VS Code' on #0066CC (5.57:1)
---
 docs/guides.md                                |  2 +-
 .../frontend/src/app/mcp-server/[id]/page.tsx | 14 +++---
 .../frontend/src/lib/mcp-install-links.ts     | 15 +++++--
 .../e2e/mcp-server-connect-clients.spec.ts    | 44 ++++++++++++++++++-
 4 files changed, 63 insertions(+), 12 deletions(-)

diff --git a/docs/guides.md b/docs/guides.md
index 3d11265e4..46da9d890 100644
--- a/docs/guides.md
+++ b/docs/guides.md
@@ -20,7 +20,7 @@ Per-adapter setup guides (English, German, Italian and more) live at **[anything
 
 ### Share a server with your team
 
-On an MCP server's page, the **Share this server** card gives you "Add to Cursor" and "Add to VS Code" badges to paste into a team README, wiki or onboarding page (**Copy Markdown** or **Copy HTML**). They use the editors' https install links, so they survive GitHub's Markdown sanitizer. A badge carries only the server's MCP URL, never an API key: whoever clicks it signs in with OAuth and must be a member of the workspace, so badges are shown only when `MCP_AUTH_MODE` is `oauth2` or `both`. If the URL is `localhost` or `127.0.0.1`, the badges only work on that machine. The preview on the page is rendered locally; only the copied snippet references remote badge images.
+On an MCP server's page, the **Share this server** card gives you "Add to Cursor" and "Add to VS Code" badges to paste into a team README, wiki or onboarding page (**Copy Markdown** or **Copy HTML**). They use the editors' https install links, so they survive GitHub's Markdown sanitizer. A badge carries only the server's MCP URL, never an API key: whoever clicks it signs in with OAuth and must be a member of the workspace, so badges are shown only when `MCP_AUTH_MODE` is `oauth2` or `both`. If the URL is `localhost` or `127.0.0.1`, the badges only work on that machine. The card appears once the server's auth mode is known. The preview on the page is rendered locally and links to the same https install URLs as the snippet; only the copied snippet references remote badge images.
 
 ---
 
diff --git a/packages/frontend/src/app/mcp-server/[id]/page.tsx b/packages/frontend/src/app/mcp-server/[id]/page.tsx
index 980f01161..9a62c7088 100644
--- a/packages/frontend/src/app/mcp-server/[id]/page.tsx
+++ b/packages/frontend/src/app/mcp-server/[id]/page.tsx
@@ -18,6 +18,8 @@ import { INSPECTOR_URL, inspectorCommand } from '@/lib/inspector-url';
 import {
   cursorDeepLink as buildCursorDeepLink,
   vscodeDeepLink as buildVscodeDeepLink,
+  cursorInstallUrl,
+  vscodeInstallUrl,
   installBadgesHtml,
   installBadgesMarkdown,
   isLocalOnlyUrl,
@@ -812,13 +814,14 @@ Auth: OAuth`;
             
           
         
+        {mcpAuthMode && (
         
           
Share this server

Put one-click install badges in your team README, wiki or onboarding page. They contain only the MCP URL; whoever clicks still signs in.

- {!mcpAuthMode ? null : !badgesSupported ? ( + {!badgesSupported ? (

One-click installs need OAuth: set MCP_AUTH_MODE=oauth2 or both.

@@ -836,18 +839,18 @@ Auth: OAuth`; {/* Local preview: styled links only, no remote images (offline installs). */}
Cursor Add to Cursor VS Code - Install MCP server + Add to VS Code
@@ -864,10 +867,11 @@ Auth: OAuth`; {copied === 'badge-html' ? 'Copied!' : 'Copy HTML'}
- {codeBlock(badgesMarkdown, 'Markdown')} +
{badgesMarkdown}
)} + )} {/* Connect Modal */} diff --git a/packages/frontend/src/lib/mcp-install-links.ts b/packages/frontend/src/lib/mcp-install-links.ts index f4d8216c5..c97f66fa6 100644 --- a/packages/frontend/src/lib/mcp-install-links.ts +++ b/packages/frontend/src/lib/mcp-install-links.ts @@ -44,21 +44,28 @@ export function vscodeDeepLink(target: McpInstallTarget): string { return `vscode:mcp/install?${encodeURIComponent(vscodeConfig(target))}`; } +// encodeURIComponent leaves ( ) ! * ' alone; a raw ")" would end a Markdown +// link early, so the https install URLs encode them too. +function encodeQueryValue(text: string): string { + return encodeURIComponent(text).replace(/[()!*']/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); +} + export function cursorInstallUrl(target: McpInstallTarget): string { - return `https://cursor.com/en/install-mcp?name=${encodeURIComponent(target.name)}&config=${encodeURIComponent(cursorConfig(target))}`; + return `https://cursor.com/en/install-mcp?name=${encodeQueryValue(target.name)}&config=${encodeQueryValue(cursorConfig(target))}`; } export function vscodeInstallUrl(target: McpInstallTarget): string { - return `https://vscode.dev/redirect/mcp/install?name=${encodeURIComponent(target.name)}&config=${encodeURIComponent(vscodeConfig(target))}`; + return `https://vscode.dev/redirect/mcp/install?name=${encodeQueryValue(target.name)}&config=${encodeQueryValue(vscodeConfig(target))}`; } // Remote badge images. Only ever placed in the copied snippet, never loaded // by the app itself (self-hosted instances may run fully offline). // Cursor documents an official button; VS Code does not, so it uses a -// shields.io static badge in the README's style. +// shields.io static badge in the README's style. #0066CC keeps white text +// at 5.57:1 contrast (WCAG AA). export const CURSOR_BADGE_IMAGE = 'https://cursor.com/deeplink/mcp-install-dark.svg'; export const VSCODE_BADGE_IMAGE = - 'https://img.shields.io/badge/VS_Code-Install_MCP_server-0098FF?logo=visualstudiocode&logoColor=white&labelColor=0b1220'; + 'https://img.shields.io/badge/VS_Code-Add_to_VS_Code-0066CC?logo=visualstudiocode&logoColor=white&labelColor=0b1220'; const CURSOR_ALT = 'Add to Cursor'; const VSCODE_ALT = 'Add to VS Code'; diff --git a/packages/frontend/tests/e2e/mcp-server-connect-clients.spec.ts b/packages/frontend/tests/e2e/mcp-server-connect-clients.spec.ts index 7fad3404b..8b68a422e 100644 --- a/packages/frontend/tests/e2e/mcp-server-connect-clients.spec.ts +++ b/packages/frontend/tests/e2e/mcp-server-connect-clients.spec.ts @@ -18,7 +18,7 @@ const USER = { const DIRECTORY_URL = 'https://claude.ai/directory/anythingmcp'; -async function openServerPage(page: Page, deploymentMode: 'cloud' | 'self-hosted', mcpAuthMode = 'oauth2') { +async function openServerPage(page: Page, deploymentMode: 'cloud' | 'self-hosted', mcpAuthMode = 'oauth2', slug = 'search') { const origin = test.info().project.use.baseURL ?? 'http://localhost:3100'; await page.context().addCookies([{ name: 'amcp_token', value: 'test-token', url: origin }]); await page.addInitScript((user) => { @@ -38,7 +38,7 @@ async function openServerPage(page: Page, deploymentMode: 'cloud' | 'self-hosted return json({ deploymentMode, mcpAuthMode, hasUsers: true, registrationEnabled: true, ssoProviders: [] }); } if (p.endsWith('/mcp-servers/s1')) { - return json({ id: 's1', name: 'Search', slug: 'search', isActive: true, apiKeys: [], connectors: [] }); + return json({ id: 's1', name: 'Search', slug, isActive: true, apiKeys: [], connectors: [] }); } if (p.endsWith('/users/me/onboarding-state')) return json({ onboardingCompletedAt: '2026-01-01T00:00:00Z' }); if (p.endsWith('/users/me')) return json(USER); @@ -134,6 +134,7 @@ test.describe('MCP server Share card', () => { await expect(card.getByText('Share this server')).toBeVisible(); await expect(card.getByTestId('share-preview').getByRole('link')).toHaveCount(2); await expect(card.locator('img')).toHaveCount(0); + await expect(card.getByRole('button', { name: /copy/i })).toHaveCount(2); const endpoint = (await page.locator('code', { hasText: /\/mcp\/s1$/ }).first().textContent())!.trim(); // The dev server runs on localhost, so the warning must show. @@ -151,6 +152,12 @@ test.describe('MCP server Share card', () => { expect(JSON.parse(Buffer.from(cursorUrl.searchParams.get('config')!, 'base64').toString())).toEqual({ url: endpoint }); expect(JSON.parse(vscodeUrl.searchParams.get('config')!)).toEqual({ name: 'search', type: 'http', url: endpoint }); + const previewHrefs = await card.getByTestId('share-preview').getByRole('link').evaluateAll((els) => els.map((e) => e.getAttribute('href'))); + expect(previewHrefs).toEqual([ + links.find((l) => l.startsWith('https://cursor.com/en/install-mcp')), + links.find((l) => l.startsWith('https://vscode.dev/redirect/mcp/install')), + ]); + await card.getByRole('button', { name: 'Copy HTML' }).click(); const html = await page.evaluate(() => navigator.clipboard.readText()); expect(html).toContain(' navigator.clipboard.readText()); + + const links = [...md.matchAll(/\]\((https:\/\/[^)\s]+)\)/g)].map((m) => m[1]); + expect(links).toHaveLength(4); // 2 images + 2 install links + const installLinks = links.filter((l) => /install-mcp|mcp\/install/.test(l)); + expect(installLinks).toHaveLength(2); + for (const link of installLinks) { + expect(link).not.toMatch(/[()!*']/); + expect(md).toContain(`(${link})`); + } + const cursorUrl = new URL(installLinks.find((l) => l.startsWith('https://cursor.com/en/install-mcp'))!); + const vscodeUrl = new URL(installLinks.find((l) => l.startsWith('https://vscode.dev/redirect/mcp/install'))!); + expect(cursorUrl.searchParams.get('name')).toBe(slug); + expect(vscodeUrl.searchParams.get('name')).toBe(slug); + expect(JSON.parse(vscodeUrl.searchParams.get('config')!).name).toBe(slug); + + // Preview badges link to the same https URLs that get shared. + const previewHrefs = await card.getByTestId('share-preview').getByRole('link').evaluateAll((els) => els.map((e) => e.getAttribute('href'))); + expect(previewHrefs).toEqual(installLinks); + }); + + test('hides the Share card until the auth mode is known', async ({ page }) => { + await openServerPage(page, 'self-hosted', ''); + await expect(page.getByTestId('share-server')).toHaveCount(0); + }); + test('in-app Cursor and VS Code buttons keep their deep links', async ({ page }) => { await openServerPage(page, 'self-hosted'); await openClient(page, 'Cursor');