Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/guides.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

---

## Connector guides
Expand Down
90 changes: 80 additions & 10 deletions packages/frontend/src/app/mcp-server/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,6 +15,15 @@ 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,
cursorInstallUrl,
vscodeInstallUrl,
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
Expand All @@ -34,6 +43,8 @@ export default function McpServerDetailPage() {
const [offerKey, setOfferKey] = useState(0);
const [allConnectors, setAllConnectors] = useState<any[]>([]);
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('');
Expand Down Expand Up @@ -104,6 +115,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 () => {
Expand Down Expand Up @@ -336,15 +351,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' },
Expand Down Expand Up @@ -802,6 +814,64 @@ Auth: OAuth`;
</div>
</details>
</Card>
{mcpAuthMode && (
<Card className="p-[22px]" data-testid="share-server">
<div className="mb-[6px] text-sm font-semibold">Share this server</div>
<p className="mb-[14px] text-[12px] text-[var(--text-3)]">
Put one-click install badges in your team README, wiki or onboarding page.
They contain only the MCP URL; whoever clicks still signs in.
</p>
{!badgesSupported ? (
<p className="text-[12px] text-[var(--text-2)]" data-testid="share-oauth-required">
One-click installs need OAuth: set <code className="rounded bg-[var(--surface-2)] px-1 font-mono">MCP_AUTH_MODE=oauth2</code> or <code className="rounded bg-[var(--surface-2)] px-1 font-mono">both</code>.
</p>
) : (
<div className="space-y-3">
{isLocalOnlyUrl(endpointUrl) && (
<p
className="rounded-[9px] px-3 py-2 text-[12px]"
style={{ background: 'var(--t-warn-bg)', color: 'var(--t-warn-fg)' }}
data-testid="share-localhost-warning"
>
This MCP URL points to localhost, so the badges only work on this machine.
</p>
)}
{/* Local preview: styled links only, no remote images (offline installs). */}
<div className="flex flex-wrap gap-2" data-testid="share-preview">
<a
href={cursorInstallUrl(installTarget)}
className="inline-flex items-center overflow-hidden rounded-[6px] bg-[#0b1220] text-[12px] font-semibold text-white"
>
<span className="px-2 py-1">Cursor</span>
<span className="bg-[#1f2937] px-2 py-1">Add to Cursor</span>
</a>
<a
href={vscodeInstallUrl(installTarget)}
className="inline-flex items-center overflow-hidden rounded-[6px] bg-[#0b1220] text-[12px] font-semibold text-white"
>
<span className="px-2 py-1">VS Code</span>
<span className="bg-[#0066CC] px-2 py-1">Add to VS Code</span>
</a>
</div>
<div className="flex gap-2">
<button
onClick={() => handleCopy(badgesMarkdown, 'badge-markdown')}
className={buttonVariants({ variant: 'secondary', size: 'md' })}
>
{copied === 'badge-markdown' ? 'Copied!' : 'Copy Markdown'}
</button>
<button
onClick={() => handleCopy(badgesHtml, 'badge-html')}
className={buttonVariants({ variant: 'secondary', size: 'md' })}
>
{copied === 'badge-html' ? 'Copied!' : 'Copy HTML'}
</button>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-all rounded-[10px] bg-[#0c0f16] p-[13px] font-mono text-[12px] leading-[1.6] text-[#cdd6e8]">{badgesMarkdown}</pre>
</div>
)}
</Card>
)}
</aside>

{/* Connect Modal */}
Expand Down
99 changes: 99 additions & 0 deletions packages/frontend/src/lib/mcp-install-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* 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))}`;
}

// 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=${encodeQueryValue(target.name)}&config=${encodeQueryValue(cursorConfig(target))}`;
}

export function vscodeInstallUrl(target: McpInstallTarget): string {
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. #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-Add_to_VS_Code-0066CC?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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

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 [
`<a href="${escapeHtml(cursorInstallUrl(target))}"><img src="${escapeHtml(CURSOR_BADGE_IMAGE)}" alt="${CURSOR_ALT}" height="32"></a>`,
`<a href="${escapeHtml(vscodeInstallUrl(target))}"><img src="${escapeHtml(VSCODE_BADGE_IMAGE)}" alt="${VSCODE_ALT}" height="32"></a>`,
].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;
}
}
115 changes: 112 additions & 3 deletions packages/frontend/tests/e2e/mcp-server-connect-clients.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', 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) => {
Expand All @@ -35,10 +35,10 @@ 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: [] });
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);
Expand Down Expand Up @@ -115,3 +115,112 @@ 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);
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.
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 });

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('<a href="https://cursor.com/en/install-mcp?');
expect(html).toContain('<a href="https://vscode.dev/redirect/mcp/install?');
expect(html).not.toMatch(/cursor:\/\/|vscode:|X-API-Key/);

await card.screenshot({ path: process.env.SHARE_SCREENSHOT || test.info().outputPath('share-preview.png') });
expect(foreign).toEqual([]);
});

test('parentheses in the slug cannot break out of the Markdown badge link', async ({ page, context }) => {
const origin = test.info().project.use.baseURL ?? 'http://localhost:3100';
await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin });
const slug = "x)(www.evil.example!*'";
await openServerPage(page, 'self-hosted', 'oauth2', slug);
const card = page.getByTestId('share-server');
await card.getByRole('button', { name: 'Copy Markdown' }).click();
const md = await page.evaluate(() => 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');
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);
});
});
Loading