diff --git a/backend/app/gateway/capabilities.py b/backend/app/gateway/capabilities.py index f9e9d27f8..61425e42c 100644 --- a/backend/app/gateway/capabilities.py +++ b/backend/app/gateway/capabilities.py @@ -54,6 +54,37 @@ class CapabilityAdapter(Protocol): async def install(self, context: AdapterContext, manifest: PluginManifest, name: str, configuration: dict[str, Any]) -> None: ... +def validate_mcp_connection(configuration: dict[str, Any]) -> None: + """Validate the normalized transport definition, not manifest form fields.""" + from urllib.parse import urlsplit + + transport = configuration.get("type", configuration.get("transport", "stdio")) + if not isinstance(transport, str): + raise HTTPException(422, "Supply a supported MCP transport") + if transport in {"http", "sse"}: + url = configuration.get("url") + valid = False + try: + if isinstance(url, str): + parsed = urlsplit(url) + _ = parsed.port # Validate malformed ports too. + has_http_scheme = parsed.scheme in {"https", "http"} + has_host = bool(parsed.hostname) + has_credentials = parsed.username is not None or parsed.password is not None + has_whitespace = any(c.isspace() for c in url) + valid = has_http_scheme and has_host and not has_credentials and not parsed.fragment and not has_whitespace + except ValueError: + valid = False + if not valid: + raise HTTPException(422, "Supply an HTTP(S) MCP server URL without embedded credentials") + return + if transport != "stdio": + raise HTTPException(422, "Supply a supported MCP transport and its required connection fields") + command = configuration.get("command") + if not isinstance(command, str) or not command.strip(): + raise HTTPException(422, "Supply a supported MCP transport and its required connection fields") + + class MCPAdapter: async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]: servers = await asyncio.to_thread(mcp._load_raw_mcp_server_responses) @@ -97,26 +128,7 @@ class MCPAdapter: if not name.strip(): raise HTTPException(422, "Installation name is required") if manifest.adapter == "mcp": - # The manifest form is normalized to a transport definition by the - # UI. Validate that wire contract, not form-only name/auth fields. - from urllib.parse import urlsplit - - transport = configuration.get("type", configuration.get("transport", "stdio")) - if not isinstance(transport, str): - raise HTTPException(422, "Supply a supported MCP transport") - if transport in {"http", "sse"}: - url = configuration.get("url") - try: - parsed = urlsplit(url) if isinstance(url, str) else None - valid = parsed is not None and parsed.scheme in {"https", "http"} and bool(parsed.hostname) and parsed.username is None and parsed.password is None and not parsed.fragment and not any(c.isspace() for c in url) - if parsed is not None: - _ = parsed.port # Validate malformed ports too. - except ValueError: - valid = False - if not valid: - raise HTTPException(422, "Supply an HTTP(S) MCP server URL without embedded credentials") - elif transport != "stdio" or not isinstance(configuration.get("command"), str) or not configuration["command"].strip(): - raise HTTPException(422, "Supply a supported MCP transport and its required connection fields") + validate_mcp_connection(configuration) definition = {**configuration, "capability": {"id": str(uuid4()), "plugin_id": manifest.id, "version": manifest.version}} try: body = mcp.McpConfigUpdateRequest(mcp_servers={name: mcp.McpServerConfigResponse.model_validate(definition)}) diff --git a/frontend/src/components/workspace/capabilities/mcp-plugin-manager.tsx b/frontend/src/components/workspace/capabilities/mcp-plugin-manager.tsx index de0c24984..1fd0869b3 100644 --- a/frontend/src/components/workspace/capabilities/mcp-plugin-manager.tsx +++ b/frontend/src/components/workspace/capabilities/mcp-plugin-manager.tsx @@ -99,11 +99,7 @@ function MCPServerList({ const previewEntry = previewEntries[0]; const previewName = editor?.mode === "edit" ? editor.name : (previewEntry?.[0] ?? ""); - const previewMetadata = catalogForServer( - previewName, - previewEntry?.[1], - definitions, - ); + const previewMetadata = catalogForServer(previewEntry?.[1], definitions); const previewIcon = draftIcon === undefined && previewEntry ? readPluginIcon(previewEntry[1]) @@ -276,13 +272,13 @@ function MCPServerList({ ...catalog.filter( (item) => !entries.some( - ([name, server]) => - catalogForServer(name, server, definitions)?.id === item.id, + ([, server]) => + catalogForServer(server, definitions)?.id === item.id, ), ), ...entries.map(([name, config]): PluginDirectoryEntry => { const displayName = displayServerName(name); - const metadata = catalogForServer(name, config, definitions); + const metadata = catalogForServer(config, definitions); return { id: `mcp:${name}`, category: metadata?.category ?? "custom", diff --git a/frontend/src/components/workspace/capabilities/plugin-catalog.ts b/frontend/src/components/workspace/capabilities/plugin-catalog.ts index 8dbf2dba4..be68428fa 100644 --- a/frontend/src/components/workspace/capabilities/plugin-catalog.ts +++ b/frontend/src/components/workspace/capabilities/plugin-catalog.ts @@ -15,7 +15,6 @@ export function catalogText(text: LocalizedText, locale: string) { } /** Identity is explicit metadata. A display name never claims an official provider. */ export function catalogForServer( - _name: string, config?: MCPServerConfig, catalog: PluginManifest[] = [], ) { diff --git a/frontend/src/components/workspace/capabilities/plugin-gallery.tsx b/frontend/src/components/workspace/capabilities/plugin-gallery.tsx index 81e3250cc..1e0479ce2 100644 --- a/frontend/src/components/workspace/capabilities/plugin-gallery.tsx +++ b/frontend/src/components/workspace/capabilities/plugin-gallery.tsx @@ -20,7 +20,9 @@ import { installationQuery, useCapabilityCatalog, } from "@/core/capabilities/hooks"; +import type { CapabilityInstallation } from "@/core/capabilities/types"; import { useI18n } from "@/core/i18n/hooks"; +import type { Translations } from "@/core/i18n/locales/types"; import { isStaticWebsiteOnly } from "@/core/static-mode"; import { cn } from "@/lib/utils"; @@ -38,6 +40,37 @@ import { } from "./plugin-directory"; import { PluginIcon } from "./plugin-icon"; +function getPluginStatusLabel( + adapter: string, + status: CapabilityInstallation | undefined, + unavailable: boolean | undefined, + t: Translations, + labels: ReturnType, +) { + if (unavailable) return labels.adapterError; + if (status) { + if (status.auth_status === "connected") return labels.connected; + if (status.auth_status === "required") return labels.required; + if (status.auth_status === "configured") return labels.configured; + return labels.installed; + } + if (adapter === "guide") return t.capabilities.directory.candidate; + if (adapter === "lark") return t.capabilities.notInstalled; + return labels.notConfigured; +} + +function getPluginActionLabel( + adapter: string, + installed: boolean, + canManage: boolean, + t: Translations, +) { + if (installed) return t.capabilities.manage; + if (adapter === "guide" || !canManage) return t.capabilities.directory.view; + if (adapter === "lark") return t.common.install; + return t.capabilities.configure; +} + export function PluginGallery({ query }: { query: string }) { const { t, locale } = useI18n(); const copy = t.capabilities.directory; @@ -109,23 +142,13 @@ export function PluginGallery({ query }: { query: string }) { capabilityId={plugin.id} /> } - label={ - unavailable - ? labels.adapterError - : status - ? status.auth_status === "connected" - ? labels.connected - : status.auth_status === "required" - ? labels.required - : status.auth_status === "configured" - ? labels.configured - : labels.installed - : plugin.adapter === "guide" - ? copy.candidate - : plugin.adapter === "lark" - ? t.capabilities.notInstalled - : labels.notConfigured - } + label={getPluginStatusLabel( + plugin.adapter, + status, + unavailable, + t, + labels, + )} onDetails={() => setSelectedId(plugin.id)} detailsLabel={`${t.capabilities.details} ${catalogText(plugin.name, locale)}`} > @@ -136,15 +159,7 @@ export function PluginGallery({ query }: { query: string }) { aria-label={`${plugin.adapter === "guide" ? copy.guide : t.capabilities.configure} ${catalogText(plugin.name, locale)}`} onClick={() => setSelectedId(plugin.id)} > - {status - ? t.capabilities.manage - : plugin.adapter === "guide" - ? copy.view - : canManage - ? plugin.adapter === "lark" - ? t.common.install - : t.capabilities.configure - : copy.view} + {getPluginActionLabel(plugin.adapter, !!status, canManage, t)} ), diff --git a/frontend/src/core/capabilities/hooks.ts b/frontend/src/core/capabilities/hooks.ts index b3f0ce97f..43b2e01d1 100644 --- a/frontend/src/core/capabilities/hooks.ts +++ b/frontend/src/core/capabilities/hooks.ts @@ -30,11 +30,7 @@ export function useCapabilityCatalog() { }); } export function useCapabilityInstallations(adapter: string) { - return useQuery({ - queryKey: ["capabilities", "installations", adapter], - queryFn: () => - request(`installations/${encodeURIComponent(adapter)}`), - }); + return useQuery(installationQuery(adapter)); } export function useInstallCapability() { const client = useQueryClient();