diff --git a/app/src/components/channels/mcp/McpInventoryExportTab.tsx b/app/src/components/channels/mcp/McpInventoryExportTab.tsx new file mode 100644 index 000000000..4c69f3df8 --- /dev/null +++ b/app/src/components/channels/mcp/McpInventoryExportTab.tsx @@ -0,0 +1,106 @@ +/** + * Export tab of the Sharable MCP Inventory panel. + * + * Renders the user's current installed MCP servers as a versioned, + * secret-free manifest in a code block, with Copy and Download + * actions. A loud privacy banner re-states what is and is not in the + * manifest so the user sees it before sharing the artifact. + */ +import { useMemo, useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { + buildManifest, + type McpInventoryManifest, + serializeManifest, + suggestedFilename, +} from './McpInventoryManifest'; +import type { InstalledServer } from './types'; + +interface McpInventoryExportTabProps { + servers: InstalledServer[]; +} + +const McpInventoryExportTab = ({ servers }: McpInventoryExportTabProps) => { + const { t } = useT(); + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied'>('idle'); + + const manifest: McpInventoryManifest = useMemo(() => buildManifest(servers), [servers]); + const serialized = useMemo(() => serializeManifest(manifest), [manifest]); + + if (servers.length === 0) { + return ( +

+ {t('mcp.inventory.export.empty')} +

+ ); + } + + const handleCopy = async () => { + if (typeof navigator === 'undefined' || !navigator.clipboard) return; + try { + await navigator.clipboard.writeText(serialized); + setCopyStatus('copied'); + window.setTimeout(() => setCopyStatus('idle'), 1500); + } catch { + // Silently no-op on platforms / contexts without clipboard access. + // The serialized text is visible and selectable in the
.
+    }
+  };
+
+  const handleDownload = () => {
+    if (typeof document === 'undefined') return;
+    const blob = new Blob([serialized], { type: 'application/json' });
+    const url = URL.createObjectURL(blob);
+    const link = document.createElement('a');
+    link.href = url;
+    link.download = suggestedFilename(manifest);
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
+    // Revoke after the click handler is done so the browser has a
+    // chance to start the download.
+    setTimeout(() => URL.revokeObjectURL(url), 0);
+  };
+
+  return (
+    
+
+

{t('mcp.inventory.export.privacyTitle')}

+

{t('mcp.inventory.export.privacyBody')}

+
+
+

+ {t('mcp.inventory.export.serverCount').replace('{count}', String(servers.length))} +

+
+ + +
+
+
+        {serialized}
+      
+
+ ); +}; + +export default McpInventoryExportTab; diff --git a/app/src/components/channels/mcp/McpInventoryImportTab.tsx b/app/src/components/channels/mcp/McpInventoryImportTab.tsx new file mode 100644 index 000000000..2144d83aa --- /dev/null +++ b/app/src/components/channels/mcp/McpInventoryImportTab.tsx @@ -0,0 +1,304 @@ +/** + * Import tab of the Sharable MCP Inventory panel. + * + * The flow is intentionally THREE-STEP and never auto-installs: + * + * 1. **Source** — user pastes JSON or uploads a file. + * 2. **Preview** — manifest is parsed + validated; each entry is + * classified (`new` vs `already_installed`); the validation error, + * if any, is surfaced via `role="alert"`. + * 3. **Per-entry install** — each `new` entry has its own "Install" + * button that calls the parent's existing install-dialog flow with + * the env_keys pre-filled (as empty values). We deliberately do + * NOT collect secret values here — the proven InstallDialog + * surface already handles that. + * + * No automatic bulk-install was implemented: an MCP server is a piece of + * trust the user is granting to their agent. A one-click-install-many + * action would invite supply-chain attacks via malicious manifests. The + * per-entry "Install" preserves friction at exactly the right step. + */ +import { type ChangeEvent, useCallback, useMemo, useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { + type ClassifiedImportEntry, + classifyImport, + type McpInventoryManifest, + type ParseErrorCode, + parseManifest, +} from './McpInventoryManifest'; +import type { InstalledServer } from './types'; + +interface McpInventoryImportTabProps { + installedServers: InstalledServer[]; + onInstallServer: (qualifiedName: string, prefillEnv: Record) => void; +} + +const McpInventoryImportTab = ({ + installedServers, + onInstallServer, +}: McpInventoryImportTabProps) => { + const { t } = useT(); + const [rawInput, setRawInput] = useState(''); + const [manifest, setManifest] = useState(null); + const [parseError, setParseError] = useState(null); + const [fileError, setFileError] = useState(null); + + const classified: ClassifiedImportEntry[] = useMemo( + () => (manifest ? classifyImport(manifest, installedServers) : []), + [manifest, installedServers] + ); + + // Map a stable manifest-layer error code to the user-visible string. + // Detail (machine context — JSON parse text, offending index, etc.) + // is appended after a separator when present so the alert text reads + // as one sentence. + const renderParseError = useCallback( + (errorCode: ParseErrorCode, detail?: string): string => { + const base = t(`mcp.inventory.parseError.${errorCode}`); + return detail ? `${base} (${detail})` : base; + }, + [t] + ); + + const handleParse = useCallback(() => { + setFileError(null); + const result = parseManifest(rawInput); + if (result.ok) { + setManifest(result.manifest); + setParseError(null); + } else { + setManifest(null); + setParseError(renderParseError(result.errorCode, result.detail)); + } + }, [rawInput, renderParseError]); + + const handleFileChange = useCallback( + (event: ChangeEvent) => { + const file = event.target.files?.[0]; + // Allow re-selecting the same file by clearing the input value. + event.target.value = ''; + if (!file) return; + if (file.size > 1_000_000) { + // 1 MB is generous for a manifest (~10k servers at 100 bytes each). + // Reject anything larger as a defence against accidental upload + // of an unrelated big JSON blob. Clear any stale preview / + // parse-error state so the rejected upload doesn't leave a + // previous (and now misleading) preview actionable below. + setManifest(null); + setParseError(null); + setFileError(t('mcp.inventory.import.fileTooLarge')); + return; + } + setFileError(null); + const reader = new FileReader(); + reader.onload = () => { + const text = typeof reader.result === 'string' ? reader.result : ''; + setRawInput(text); + const result = parseManifest(text); + if (result.ok) { + setManifest(result.manifest); + setParseError(null); + } else { + setManifest(null); + setParseError(renderParseError(result.errorCode, result.detail)); + } + }; + reader.onerror = () => { + // Same reasoning as the size-reject branch — drop any stale + // preview so a failed re-upload doesn't leave the previous + // manifest's preview rows still rendering / actionable. + setManifest(null); + setParseError(null); + setFileError(t('mcp.inventory.import.fileReadFailed')); + }; + reader.readAsText(file); + }, + [t, renderParseError] + ); + + const handleClear = useCallback(() => { + setRawInput(''); + setManifest(null); + setParseError(null); + setFileError(null); + }, []); + + const handleInstall = useCallback( + (entry: ClassifiedImportEntry['entry']) => { + // Build the prefill object: keys from the manifest, empty values + // for the user to fill in the existing InstallDialog. The dialog's + // existing required-field validation does the rest. + const prefill: Record = {}; + for (const key of entry.env_keys) prefill[key] = ''; + onInstallServer(entry.qualified_name, prefill); + }, + [onInstallServer] + ); + + const stats = useMemo(() => { + let newly = 0; + let already = 0; + for (const c of classified) { + if (c.status === 'new') newly += 1; + else already += 1; + } + return { newly, already, total: classified.length }; + }, [classified]); + + return ( +
+
+

{t('mcp.inventory.import.trustTitle')}

+

{t('mcp.inventory.import.trustBody')}

+
+ +
+ +