From 4c934e63fa4a0389da306e9632019690b0b5b8ad Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Sun, 19 Apr 2026 19:25:01 +1000 Subject: [PATCH 1/5] Handle related oauth MCP flows --- ui/src/services/mcp/oauth.ts | 47 ++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/ui/src/services/mcp/oauth.ts b/ui/src/services/mcp/oauth.ts index 2f4cd9e..5d6b252 100644 --- a/ui/src/services/mcp/oauth.ts +++ b/ui/src/services/mcp/oauth.ts @@ -277,6 +277,8 @@ async function discoverAuthServerMetadata(issuerUrl: string): Promise { - // 1. Discover metadata + // 1. Discover metadata. Prefer RFC 9728 Protected Resource Metadata, but fall + // back to RFC 8414 Authorization Server Metadata served directly from the MCP + // origin for servers that skip RFC 9728. const prm = await discoverProtectedResourceMetadata(serverUrl); - if (!prm?.authorization_servers?.length) { - throw new Error( - "Could not discover OAuth metadata. The server may not support OAuth authorization." - ); - } - - const resource = prm.resource || serverUrl; - const issuerUrl = prm.authorization_servers[0]; - - const asm = await discoverAuthServerMetadata(issuerUrl); - if (!asm) { - throw new Error(`Could not discover authorization server metadata at ${issuerUrl}`); + let resource = prm?.resource || serverUrl; + let asm: AuthServerMetadata | null = null; + + if (prm?.authorization_servers?.length) { + asm = await discoverAuthServerMetadata(prm.authorization_servers[0]); + if (!asm) { + throw new Error( + `Could not discover authorization server metadata at ${prm.authorization_servers[0]}` + ); + } + } else { + asm = await discoverAuthServerMetadata(serverUrl); + if (!asm) { + throw new Error( + "Could not discover OAuth metadata. The server may not support OAuth authorization." + ); + } + resource = serverUrl; } // Validate PKCE S256 support @@ -483,7 +493,7 @@ export async function startOAuthFlow( // Determine scopes const scopes = - oauthConfig?.scopes || prm.scopes_supported?.join(" ") || asm.scopes_supported?.join(" "); + oauthConfig?.scopes || prm?.scopes_supported?.join(" ") || asm.scopes_supported?.join(" "); if (scopes) authParams.set("scope", scopes); const authUrl = `${asm.authorization_endpoint}?${authParams}`; @@ -801,10 +811,17 @@ export async function detectServerAuth(serverUrl: string): Promise Date: Mon, 20 Apr 2026 21:32:24 +1000 Subject: [PATCH 2/5] Add MCP catalog --- ui/.storybook/preview.ts | 7 +- .../MCPConfigModal/MCPCatalog.stories.tsx | 57 ++ .../components/MCPConfigModal/MCPCatalog.tsx | 729 ++++++++++++++++++ .../MCPConfigModal/MCPConfigModal.tsx | 300 +++++-- ui/src/config/defaults.ts | 3 + ui/src/config/types.ts | 15 + ui/src/services/mcpRegistry/client.ts | 201 +++++ ui/src/services/mcpRegistry/types.ts | 110 +++ 8 files changed, 1355 insertions(+), 67 deletions(-) create mode 100644 ui/src/components/MCPConfigModal/MCPCatalog.stories.tsx create mode 100644 ui/src/components/MCPConfigModal/MCPCatalog.tsx create mode 100644 ui/src/services/mcpRegistry/client.ts create mode 100644 ui/src/services/mcpRegistry/types.ts diff --git a/ui/.storybook/preview.ts b/ui/.storybook/preview.ts index 509d414..4509ae6 100644 --- a/ui/.storybook/preview.ts +++ b/ui/.storybook/preview.ts @@ -3,6 +3,7 @@ import React from "react"; import { initialize, mswLoader } from "msw-storybook-addon"; import "../src/index.css"; import { PreferencesProvider } from "../src/preferences/PreferencesProvider"; +import { ConfigProvider } from "../src/config/ConfigProvider"; import { defaultPreferences } from "../src/preferences/types"; // Initialize MSW @@ -55,7 +56,11 @@ const preview: Preview = { JSON.stringify({ ...defaultPreferences, theme }) ); } - return React.createElement(PreferencesProvider, null, React.createElement(Story)); + return React.createElement( + ConfigProvider, + null, + React.createElement(PreferencesProvider, null, React.createElement(Story)) + ); }, ], globalTypes: { diff --git a/ui/src/components/MCPConfigModal/MCPCatalog.stories.tsx b/ui/src/components/MCPConfigModal/MCPCatalog.stories.tsx new file mode 100644 index 0000000..0e7324d --- /dev/null +++ b/ui/src/components/MCPConfigModal/MCPCatalog.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { MCPCatalog } from "./MCPCatalog"; + +const meta = { + title: "Components/MCPCatalog", + component: MCPCatalog, + parameters: { + layout: "padded", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Live: Story = { + args: { + onPick: (p) => alert(`Picked:\n${JSON.stringify(p, null, 2)}`), + onAddManual: () => alert("Add manually"), + onCancel: () => alert("Cancel"), + }, + render: (args) => ( +
+

+ Live view of the catalog against registry.modelcontextprotocol.io. Try + searching for “github”, “slack”, or “atlassian”. +

+ +
+ ), +}; + +export const WithFavorites: Story = { + args: { + onPick: (p) => alert(`Picked:\n${JSON.stringify(p, null, 2)}`), + onAddManual: () => alert("Add manually"), + onCancel: () => alert("Cancel"), + favorites: [ + { name: "Platter", url: "io.github.ScriptSmith/platter" }, + { name: "Atlassian", url: "https://mcp.atlassian.com/v1/mcp" }, + { name: "Notion", url: "https://mcp.notion.com/mcp" }, + { name: "Hugging Face", url: "https://huggingface.co/mcp" }, + { name: "Miro", url: "https://mcp.miro.com/" }, + { name: "Vercel", url: "https://mcp.vercel.com" }, + ], + }, + render: (args) => ( +
+

+ Catalog seeded with the default gateway-favorited servers. The Platter entry is a registry + identifier (resolved via registry.modelcontextprotocol.io), the others are + direct remote URLs. +

+ +
+ ), +}; diff --git a/ui/src/components/MCPConfigModal/MCPCatalog.tsx b/ui/src/components/MCPConfigModal/MCPCatalog.tsx new file mode 100644 index 0000000..1e4aaef --- /dev/null +++ b/ui/src/components/MCPConfigModal/MCPCatalog.tsx @@ -0,0 +1,729 @@ +/** + * MCPCatalog — browse and add MCP servers from the official registry. + * + * Presents two sections: + * - "Connect directly" — remote servers (streamable-http / SSE) + * - "Run locally" — servers that only ship stdio packages; user runs a + * local proxy (e.g. npx mcp-remote) and connects over localhost. + * + * Clicking "Add" on a card hands a prefill payload back to the parent modal, + * which opens the existing add-server form populated from the registry entry. + */ + +import { useEffect, useRef, useState } from "react"; +import { + AlertCircle, + ArrowLeft, + ExternalLink, + Globe, + Link2, + Loader2, + Package, + Plug, + Plus, + Search, + Star, +} from "lucide-react"; + +import { Button } from "@/components/Button/Button"; +import { Input } from "@/components/Input/Input"; +import { cn } from "@/utils/cn"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import { + buildInstallCommand, + categorize, + dedupeLatest, + getRegistryEntry, + materializeHeaders, + pickPreferredPackage, + searchRegistry, + type CategorizedEntry, +} from "@/services/mcpRegistry/client"; +import type { + MCPRegistryEntry, + MCPRegistryPackage, + MCPRegistryRemote, +} from "@/services/mcpRegistry/types"; +import type { FavoriteMcpServer } from "@/config/types"; + +const PAGE_SIZE = 30; + +export interface CatalogPrefill { + url: string; + name?: string; + authType?: "none" | "bearer"; + bearerToken?: string; + headers?: Record; + localInstall?: { + command: string; + envVars: Array<{ + name: string; + description?: string; + isSecret?: boolean; + isRequired?: boolean; + }>; + }; +} + +export interface MCPCatalogProps { + onPick: (prefill: CatalogPrefill) => void; + onAddManual: () => void; + onCancel: () => void; + /** + * Curated favorites shown at the top of the catalog. Each entry's `url` is + * either a direct remote URL (`https://…`) or a registry identifier the + * component resolves against the MCP registry. + */ + favorites?: FavoriteMcpServer[]; +} + +/** Does this favorite's `url` look like a direct HTTP(S) URL? */ +function isHttpUrl(value: string): boolean { + try { + const u = new URL(value); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +} + +/** + * Resolution state for a single favorite. + * URL favorites don't need resolution — they're rendered directly. + * Registry favorites go loading → resolved | error as their registry lookup + * completes. + */ +type FavoriteResolution = + | { kind: "url" } + | { kind: "loading" } + | { kind: "resolved"; categorized: CategorizedEntry } + | { kind: "error" }; + +export function MCPCatalog({ onPick, onAddManual, onCancel, favorites = [] }: MCPCatalogProps) { + const [query, setQuery] = useState(""); + const [pasteUrl, setPasteUrl] = useState(""); + const debouncedQuery = useDebouncedValue(query, 300); + const pasteUrlIsValid = (() => { + try { + const u = new URL(pasteUrl); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } + })(); + + const handlePasteSubmit = () => { + if (!pasteUrlIsValid) return; + onPick({ url: pasteUrl }); + }; + const [entries, setEntries] = useState([]); + const [cursor, setCursor] = useState(); + const [loading, setLoading] = useState(false); + // `loadingMore` tracks which section's "Load more" button was clicked so we + // only show a spinner on that button. The registry has a single shared + // cursor — we loop fetching pages until an entry of the desired kind appears. + const [loadingMore, setLoadingMore] = useState<"remote" | "local" | null>(null); + const [error, setError] = useState(); + const abortRef = useRef(null); + + // Resolution state for registry-ID favorites. URL favorites aren't tracked + // here since they don't need resolving. + const [favoriteResolutions, setFavoriteResolutions] = useState>( + () => new Map() + ); + + // Stable dependency key so the effect only re-runs when the favorites list + // actually changes (by reference values, not array identity). + const favoritesKey = favorites.map((f) => `${f.name}|${f.url}`).join("\n"); + + useEffect(() => { + const ctrl = new AbortController(); + + // Seed URL favorites immediately; they render without a fetch. + const seed = new Map(); + for (const f of favorites) { + seed.set(f.url, isHttpUrl(f.url) ? { kind: "url" } : { kind: "loading" }); + } + setFavoriteResolutions(seed); + + const registryFavorites = favorites.filter((f) => !isHttpUrl(f.url)); + if (registryFavorites.length === 0) return () => ctrl.abort(); + + Promise.all( + registryFavorites.map((f) => + getRegistryEntry(f.url, ctrl.signal) + .then((entry): [string, FavoriteResolution] => { + const c = categorize(entry); + return [f.url, c ? { kind: "resolved", categorized: c } : { kind: "error" }]; + }) + .catch((): [string, FavoriteResolution] => [f.url, { kind: "error" }]) + ) + ).then((pairs) => { + if (ctrl.signal.aborted) return; + setFavoriteResolutions((prev) => { + const next = new Map(prev); + for (const [k, v] of pairs) next.set(k, v); + return next; + }); + }); + + return () => ctrl.abort(); + // favoritesKey captures the meaningful identity of `favorites` for the effect. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [favoritesKey]); + + const hasFavorites = favorites.length > 0; + + // Initial load + search + useEffect(() => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + setLoading(true); + setError(undefined); + + searchRegistry({ + search: debouncedQuery || undefined, + limit: PAGE_SIZE, + signal: ctrl.signal, + }) + .then((res) => { + if (ctrl.signal.aborted) return; + setEntries(res.servers); + setCursor(res.metadata?.nextCursor); + }) + .catch((err: unknown) => { + if (ctrl.signal.aborted || (err instanceof DOMException && err.name === "AbortError")) + return; + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (!ctrl.signal.aborted) setLoading(false); + }); + + return () => ctrl.abort(); + }, [debouncedQuery]); + + const handleLoadMore = async (kind: "remote" | "local") => { + if (!cursor || loadingMore) return; + setLoadingMore(kind); + let nextCursor: string | undefined = cursor; + // The registry returns mixed remote/local entries under one cursor, and + // stdio-only servers are rare. Keep fetching pages until we either pick + // up at least one new entry of the requested kind or run out of pages. + const maxIterations = 10; + try { + for (let i = 0; i < maxIterations; i++) { + const res = await searchRegistry({ + search: debouncedQuery || undefined, + limit: PAGE_SIZE, + cursor: nextCursor, + }); + setEntries((prev) => [...prev, ...res.servers]); + nextCursor = res.metadata?.nextCursor; + + const matched = res.servers.some((e) => { + const c = categorize(e); + return c?.kind === kind; + }); + if (matched || !nextCursor) break; + } + setCursor(nextCursor); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoadingMore(null); + } + }; + + const categorized = dedupeLatest(entries) + .map(categorize) + .filter((c): c is CategorizedEntry => c != null); + const remoteEntries = categorized.filter((c) => c.kind === "remote"); + const localEntries = categorized.filter((c) => c.kind === "local"); + + return ( +
+ {/* Prominent paste-URL card — direct-add for servers not in the registry */} +
+
+ + Have a URL? Add it directly +
+
{ + e.preventDefault(); + handlePasteSubmit(); + }} + className="flex gap-2" + > + setPasteUrl(e.target.value)} + placeholder="https://mcp.example.com" + className="flex-1 font-mono text-sm" + aria-label="Paste MCP server URL" + type="url" + /> + +
+
+ Or{" "} + + . +
+
+ + {hasFavorites && ( +
+
+
+ + Favorites +
+

+ Curated MCP servers recommended by your gateway admin. +

+
+
+ {favorites.map((fav) => { + const resolution = favoriteResolutions.get(fav.url) ?? { kind: "loading" }; + return ( + + ); + })} +
+
+ )} + + {/* Browse the registry */} +
+
+
+ Browse the registry +
+ + registry.modelcontextprotocol.io + + +
+
+ + setQuery(e.target.value)} + placeholder="Search the MCP registry (e.g. github, atlassian)" + className="pl-9" + aria-label="Search MCP registry" + /> + {loading && ( + + )} +
+
+ + {error && ( +
+ +
+
Could not load registry
+
{error}
+
+
+ )} + + {!error && !loading && categorized.length === 0 && ( +
+ +

No servers match “{debouncedQuery}”

+

Try a different search or add one manually.

+
+ )} + + {remoteEntries.length > 0 && ( +
} + hint="Remote servers reachable over HTTP — no install needed." + footer={ + cursor && !loading ? ( + + ) : null + } + > + {remoteEntries.map((c) => ( + + ))} +
+ )} + + {localEntries.length > 0 && ( +
} + hint="Stdio-only servers. You'll install a package and run a proxy like npx mcp-remote, then connect over localhost." + footer={ + cursor && !loading ? ( + + ) : null + } + > + {localEntries.map((c) => ( + + ))} +
+ )} + +
+ +
+
+ ); +} + +function Section({ + title, + icon, + hint, + footer, + children, +}: { + title: string; + icon: React.ReactNode; + hint: string; + footer?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+
+ {icon} + {title} +
+

{hint}

+
+
{children}
+ {footer &&
{footer}
} +
+ ); +} + +function registryEntryUrl(name: string): string { + return `https://registry.modelcontextprotocol.io/v0.1/servers/${encodeURIComponent(name)}/versions/latest`; +} + +function ServerHeader({ entry }: { entry: MCPRegistryEntry }) { + const [expanded, setExpanded] = useState(false); + const title = entry.server.title || entry.server.name; + const iconSrc = entry.server.icons?.[0]?.src; + const description = entry.server.description; + // Rough truncation check — 2 lines at this width is ~120 chars. Under that we + // don't bother showing the "more" toggle. + const isLongDescription = (description?.length ?? 0) > 120; + + return ( +
+
+ {iconSrc ? ( + + ) : ( + + )} +
+
+
+ {title} + {entry.server.version && ( + + v{entry.server.version} + + )} +
+
{entry.server.name}
+ {description && ( +
+

+ {description} +

+ {isLongDescription && ( + + )} +
+ )} + +
+
+ ); +} + +function pickPreferredRemote(remotes: MCPRegistryRemote[]): MCPRegistryRemote { + return remotes.find((r) => r.type === "streamable-http") ?? remotes[0]; +} + +function RemoteCard({ + entry, + remotes, + onPick, +}: { + entry: MCPRegistryEntry; + remotes: MCPRegistryRemote[]; + onPick: (p: CatalogPrefill) => void; +}) { + const remote = pickPreferredRemote(remotes); + const headers = materializeHeaders(remote); + + // Infer a bearer token from a templated Authorization header if present. + let authType: CatalogPrefill["authType"] = "none"; + let bearerToken: string | undefined; + const authValue = headers.Authorization ?? headers.authorization; + const remainingHeaders: Record = { ...headers }; + if (authValue && /^Bearer\s+/i.test(authValue)) { + authType = "bearer"; + bearerToken = authValue.replace(/^Bearer\s+/i, ""); + delete remainingHeaders.Authorization; + delete remainingHeaders.authorization; + } + + const handleAdd = () => { + onPick({ + url: remote.url, + name: entry.server.title || entry.server.name, + authType, + bearerToken, + headers: Object.keys(remainingHeaders).length > 0 ? remainingHeaders : undefined, + }); + }; + + return ( +
+ +
+
+ {remotes.map((r, i) => ( + + {r.type} + + ))} +
+ +
+
+ ); +} + +function LocalCard({ + entry, + packages, + onPick, +}: { + entry: MCPRegistryEntry; + packages: MCPRegistryPackage[]; + onPick: (p: CatalogPrefill) => void; +}) { + const pkg = pickPreferredPackage(packages); + const install = pkg ? buildInstallCommand(pkg) : null; + + const handleAdd = () => { + if (!pkg || !install) return; + onPick({ + url: install.url, + name: entry.server.title || entry.server.name, + authType: "none", + localInstall: { + command: install.command, + envVars: (pkg.environmentVariables ?? []).map((e) => ({ + name: e.name, + description: e.description, + isSecret: e.isSecret, + isRequired: e.isRequired, + })), + }, + }); + }; + + return ( +
+ +
+
+ {Array.from(new Set(packages.map((p) => p.registryType))).map((type) => ( + + {type} + + ))} +
+ +
+
+ ); +} + +/** + * Renders a single favorite entry. URL favorites get a compact card; registry + * favorites delegate to the same RemoteCard / LocalCard components used for + * search results once the registry entry has resolved. + */ +function FavoriteCard({ + fav, + resolution, + onPick, +}: { + fav: FavoriteMcpServer; + resolution: FavoriteResolution; + onPick: (p: CatalogPrefill) => void; +}) { + if (resolution.kind === "url") { + const handleAdd = () => onPick({ url: fav.url, name: fav.name }); + return ( +
+
+
+ +
+
+
{fav.name}
+
{fav.url}
+
+
+
+ +
+
+ ); + } + + if (resolution.kind === "resolved") { + const c = resolution.categorized; + if (c.kind === "remote") { + return ; + } + return ; + } + + // loading / error — show a minimal card so the layout doesn't shift. + return ( +
+
+ {resolution.kind === "loading" ? ( + + ) : ( + + )} +
+
+
{fav.name}
+
+ {resolution.kind === "loading" ? fav.url : `Could not load ${fav.url}`} +
+
+
+ ); +} diff --git a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx index 2d8a4e7..7d9f367 100644 --- a/ui/src/components/MCPConfigModal/MCPConfigModal.tsx +++ b/ui/src/components/MCPConfigModal/MCPConfigModal.tsx @@ -15,9 +15,11 @@ import { z } from "zod"; import { AlertCircle, AlertTriangle, + ArrowLeft, CheckCircle2, ChevronDown, ChevronRight, + Copy, Eye, EyeOff, KeyRound, @@ -26,6 +28,7 @@ import { Plug, Plus, ShieldCheck, + Terminal, Trash2, Wifi, Wrench, @@ -45,6 +48,8 @@ import { } from "@/components/Modal/Modal"; import { Switch } from "@/components/Switch/Switch"; import { cn } from "@/utils/cn"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import { useConfig } from "@/config/ConfigProvider"; import { useMCPStore, useMCPServers } from "@/stores/mcpStore"; import { MCPClient, @@ -59,15 +64,30 @@ import { detectServerAuth, } from "@/services/mcp"; import type { MCPToolDefinition, JSONSchema } from "@/services/mcp"; +import { MCPCatalog, type CatalogPrefill } from "./MCPCatalog"; // ============================================================================= // Types // ============================================================================= -/** Pre-fill data for adding a new server (e.g., from URL query params) */ +/** Pre-fill data for adding a new server (e.g., from URL query params or catalog). */ export interface MCPServerPrefill { url: string; name?: string; + authType?: MCPAuthType; + bearerToken?: string; + /** Additional headers pre-filled into the form's JSON textarea. */ + headers?: Record; + /** If present, show an install banner for a locally-run stdio server. */ + localInstall?: { + command: string; + envVars: Array<{ + name: string; + description?: string; + isSecret?: boolean; + isRequired?: boolean; + }>; + }; } export interface MCPConfigModalProps { @@ -479,9 +499,18 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr Object.entries(existingHeaders).filter(([k]) => k.toLowerCase() !== "authorization") ); - // Infer initial auth type from existing config + // Infer initial auth type from existing config or prefill const initialAuthType: MCPAuthType = - editingServer?.authType ?? (existingBearer ? "bearer" : "none"); + editingServer?.authType ?? prefill?.authType ?? (existingBearer ? "bearer" : "none"); + + // Merge extra headers from editingServer with prefill headers — prefill wins + // when keys collide (prefill is either catalog-supplied or user-confirmed + // via a query-param flow). + const prefillExtraHeaders = prefill?.headers ?? {}; + const mergedExtraHeaders = + Object.keys(prefillExtraHeaders).length > 0 + ? { ...extraHeaders, ...prefillExtraHeaders } + : extraHeaders; const form = useForm({ resolver: zodResolver(serverFormSchema), @@ -489,10 +518,13 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr name: editingServer?.name ?? prefill?.name ?? "", url: editingServer?.url ?? prefill?.url ?? "", authType: initialAuthType, - bearerToken: existingBearer, + bearerToken: prefill?.bearerToken ?? existingBearer, oauthClientId: editingServer?.oauth?.clientId ?? "", oauthScopes: editingServer?.oauth?.scopes ?? "", - headers: Object.keys(extraHeaders).length > 0 ? JSON.stringify(extraHeaders, null, 2) : "", + headers: + Object.keys(mergedExtraHeaders).length > 0 + ? JSON.stringify(mergedExtraHeaders, null, 2) + : "", timeout: Math.round((editingServer?.timeout ?? 300000) / 1000), }, }); @@ -520,6 +552,9 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr const watchedUrl = form.watch("url"); const watchedHeaders = form.watch("headers"); const watchedAuthType = form.watch("authType") as MCPAuthType; + // Debounce URL so network-touching effects (auth probe, template checks) run + // only after the user stops typing. + const debouncedUrl = useDebouncedValue(watchedUrl, 500); // Reset test results when URL or headers change useEffect(() => { @@ -542,16 +577,16 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr } }, [watchedAuthType, watchedUrl]); - // Auto-detect auth requirements when URL changes (new servers only) + // Auto-detect auth requirements when the debounced URL changes (new servers only) useEffect(() => { - if (!isNewServer || !watchedUrl || userOverrodeAuth) { + if (!isNewServer || !debouncedUrl || userOverrodeAuth) { setDetectionStatus("idle"); setDetectionMessage(""); return; } // Validate URL before probing - if (!z.string().url().safeParse(watchedUrl).success) { + if (!z.string().url().safeParse(debouncedUrl).success) { setDetectionStatus("idle"); setDetectionMessage(""); return; @@ -561,27 +596,24 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr setDetectionMessage(""); let cancelled = false; - const timer = setTimeout(() => { - detectServerAuth(watchedUrl).then((result) => { - if (cancelled) return; - setDetectionStatus("detected"); - setDetectionMessage(result.message); - if (result.authType !== watchedAuthType) { - form.setValue("authType", result.authType); - } - // Pre-fill server name from resource metadata if the field is still empty - if (result.serverName && !form.getValues("name")) { - form.setValue("name", result.serverName); - } - }); - }, 600); + detectServerAuth(debouncedUrl).then((result) => { + if (cancelled) return; + setDetectionStatus("detected"); + setDetectionMessage(result.message); + if (result.authType !== watchedAuthType) { + form.setValue("authType", result.authType); + } + // Pre-fill server name from resource metadata if the field is still empty + if (result.serverName && !form.getValues("name")) { + form.setValue("name", result.serverName); + } + }); return () => { - clearTimeout(timer); cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run on URL change - }, [watchedUrl, isNewServer, userOverrodeAuth]); + }, [debouncedUrl, isNewServer, userOverrodeAuth]); const handleAuthorize = useCallback(async () => { const valid = await form.trigger("url"); @@ -683,10 +715,78 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr { value: "oauth" as const, label: "OAuth (PKCE)" }, ]; + // Flag `{placeholder}` tokens in the bearer token or headers JSON — these + // come from catalog prefills with templated values the user must replace. + const watchedBearer = form.watch("bearerToken"); + const hasTemplateTokens = + /\{[^}]+\}/.test(watchedHeaders ?? "") || + (watchedAuthType === "bearer" && /\{[^}]+\}/.test(watchedBearer ?? "")); + + const [copiedInstall, setCopiedInstall] = useState(false); + const handleCopyInstall = useCallback(async () => { + if (!prefill?.localInstall?.command) return; + try { + await navigator.clipboard.writeText(prefill.localInstall.command); + setCopiedInstall(true); + setTimeout(() => setCopiedInstall(false), 1500); + } catch { + // Clipboard may be unavailable (insecure context); ignore silently. + } + }, [prefill?.localInstall?.command]); + return (
+ {/* Local-install banner: shown when the catalog picked a stdio server */} + {prefill?.localInstall && ( +
+
+ +
+
Local setup required
+

+ Run the command below on your machine. Once it's up, the server is reachable at{" "} + {prefill.url}. +

+
+
+
+
+              {prefill.localInstall.command}
+            
+ +
+ {prefill.localInstall.envVars.length > 0 && ( +
+
Required environment:
+
    + {prefill.localInstall.envVars.map((v) => ( +
  • + {v.name} + {v.isRequired && ( + required + )} + {v.description && ( + — {v.description} + )} +
  • + ))} +
+
+ )} +
+ )} + {/* Warning banner when pre-filled from a URL param */} - {prefill && ( + {prefill && !prefill.localInstall && (
Server URL provided via link. Only add servers you trust. @@ -870,33 +970,44 @@ function ServerForm({ editingServer, onSubmit, onCancel, prefill }: ServerFormPr
)} - 0} > -