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')}
+
+
+
+
+
+
+
+
+
+ {(manifest || parseError || rawInput.length > 0) && (
+
+ )}
+
+
+
+
+ {fileError && (
+
+ {fileError}
+
+ )}
+ {parseError && (
+
+ {t('mcp.inventory.import.parseErrorPrefix')} {parseError}
+
+ )}
+
+ {manifest && (
+
+
+ {t('mcp.inventory.import.previewHeading')}
+
+
+ {t('mcp.inventory.import.previewCounts')
+ .replace('{total}', String(stats.total))
+ .replace('{newly}', String(stats.newly))
+ .replace('{already}', String(stats.already))}
+
+
+ {t('mcp.inventory.import.exportedFrom').replace('{exporter}', manifest.exported_by)} ·{' '}
+ {t('mcp.inventory.import.exportedAt').replace('{when}', manifest.exported_at)}
+
+ {classified.length === 0 ? (
+
+ {t('mcp.inventory.import.previewEmpty')}
+
+ ) : (
+
+ {classified.map(({ entry, status }) => (
+ -
+
+
+
+ {status === 'new'
+ ? t('mcp.inventory.import.statusNew')
+ : t('mcp.inventory.import.statusAlreadyInstalled')}
+
+
+ {entry.display_name}
+
+
+
+ {entry.qualified_name}
+
+ {entry.env_keys.length > 0 && (
+
+ {t('mcp.inventory.import.envKeysLabel')}: {entry.env_keys.join(', ')}
+
+ )}
+
+ {status === 'new' ? (
+
+ ) : (
+
+ {t('mcp.inventory.import.skipped')}
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+};
+
+export default McpInventoryImportTab;
diff --git a/app/src/components/channels/mcp/McpInventoryManifest.test.ts b/app/src/components/channels/mcp/McpInventoryManifest.test.ts
new file mode 100644
index 000000000..4b6ba5f2f
--- /dev/null
+++ b/app/src/components/channels/mcp/McpInventoryManifest.test.ts
@@ -0,0 +1,362 @@
+/**
+ * Tests for the McpInventoryManifest helpers. Pure-data layer — no React.
+ *
+ * The redaction contract is the most security-relevant invariant in the
+ * whole inventory feature; the tests below pin it from both directions:
+ * - `buildManifest` must NEVER include `server_id`, `installed_at`,
+ * `last_connected_at`, `command`, `args`, `command_kind`, or any
+ * `env` value.
+ * - `parseManifest` must REJECT any input that smuggles back an `env`
+ * map (or other shape violations).
+ */
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildManifest,
+ classifyImport,
+ CURRENT_MANIFEST_SCHEMA,
+ parseManifest,
+ serializeManifest,
+ suggestedFilename,
+} from './McpInventoryManifest';
+import type { InstalledServer } from './types';
+
+const SERVER_FS: InstalledServer = {
+ server_id: 'srv-uuid-1',
+ qualified_name: 'acme/fs-server',
+ display_name: 'File Server',
+ description: 'Reads files',
+ icon_url: 'https://example.com/icon.png',
+ command_kind: 'node',
+ command: 'npx',
+ args: ['-y', 'acme/fs-server'],
+ env_keys: ['ROOT_DIR', 'API_KEY'],
+ config: { region: 'us-east-1' },
+ installed_at: 1_700_000_000,
+ last_connected_at: 1_700_001_000,
+};
+
+const SERVER_DB: InstalledServer = {
+ server_id: 'srv-uuid-2',
+ qualified_name: 'acme/db-server',
+ display_name: 'DB Server',
+ description: undefined,
+ command_kind: 'node',
+ command: 'npx',
+ args: ['-y', 'acme/db-server'],
+ env_keys: ['DB_URL'],
+ installed_at: 1_700_000_500,
+};
+
+describe('McpInventoryManifest: buildManifest', () => {
+ it('returns the documented schema sentinel', () => {
+ const m = buildManifest([]);
+ expect(m.$schema).toBe(CURRENT_MANIFEST_SCHEMA);
+ });
+
+ it('uses an ISO-8601 timestamp for exported_at', () => {
+ const m = buildManifest([]);
+ // Loose check — anything `new Date()` accepts back must be valid.
+ expect(Number.isNaN(Date.parse(m.exported_at))).toBe(false);
+ });
+
+ it('honours an explicit exported_by label', () => {
+ const m = buildManifest([SERVER_FS], 'my-machine');
+ expect(m.exported_by).toBe('my-machine');
+ });
+
+ it('defaults exported_by to "openhuman-desktop"', () => {
+ const m = buildManifest([SERVER_FS]);
+ expect(m.exported_by).toBe('openhuman-desktop');
+ });
+
+ it('redacts per-machine identifiers (server_id, installed_at, last_connected_at)', () => {
+ const m = buildManifest([SERVER_FS]);
+ const entry = m.servers[0];
+ expect('server_id' in entry).toBe(false);
+ expect('installed_at' in entry).toBe(false);
+ expect('last_connected_at' in entry).toBe(false);
+ });
+
+ it('redacts transient spawn shape (command, args, command_kind)', () => {
+ const m = buildManifest([SERVER_FS]);
+ const entry = m.servers[0];
+ expect('command' in entry).toBe(false);
+ expect('args' in entry).toBe(false);
+ expect('command_kind' in entry).toBe(false);
+ });
+
+ it('NEVER exports an env value map (only env_keys / NAMES)', () => {
+ const m = buildManifest([SERVER_FS]);
+ const entry = m.servers[0];
+ expect('env' in entry).toBe(false);
+ // env_keys IS present and matches the input (with any sort).
+ expect([...entry.env_keys].sort()).toEqual(['API_KEY', 'ROOT_DIR']);
+ });
+
+ it('sorts env_keys deterministically for diff-stability', () => {
+ // Input is in declaration order; output should always be sorted.
+ const m = buildManifest([SERVER_FS]);
+ expect(m.servers[0].env_keys).toEqual(['API_KEY', 'ROOT_DIR']);
+ });
+
+ it('sorts servers by qualified_name for diff-stability', () => {
+ // Input out of order; output sorted.
+ const m = buildManifest([SERVER_FS, SERVER_DB]);
+ expect(m.servers.map(s => s.qualified_name)).toEqual(['acme/db-server', 'acme/fs-server']);
+ });
+
+ it('omits description when absent (not serialised as null/undefined)', () => {
+ const m = buildManifest([SERVER_DB]);
+ expect('description' in m.servers[0]).toBe(false);
+ });
+
+ it('includes description when present', () => {
+ const m = buildManifest([SERVER_FS]);
+ expect(m.servers.find(s => s.qualified_name === 'acme/fs-server')?.description).toBe(
+ 'Reads files'
+ );
+ });
+
+ it('omits config when undefined or null; includes when present', () => {
+ const m = buildManifest([SERVER_FS, SERVER_DB]);
+ const fs = m.servers.find(s => s.qualified_name === 'acme/fs-server');
+ const db = m.servers.find(s => s.qualified_name === 'acme/db-server');
+ expect(fs?.config).toEqual({ region: 'us-east-1' });
+ expect('config' in (db as object)).toBe(false);
+ });
+
+ it('handles a server with an undefined env_keys (defensive) without crashing', () => {
+ const broken = { ...SERVER_DB, env_keys: undefined as unknown as string[] };
+ const m = buildManifest([broken]);
+ expect(m.servers[0].env_keys).toEqual([]);
+ });
+});
+
+describe('McpInventoryManifest: serializeManifest', () => {
+ it('produces pretty-printed JSON with a trailing newline', () => {
+ const m = buildManifest([SERVER_FS]);
+ const text = serializeManifest(m);
+ expect(text.endsWith('\n')).toBe(true);
+ // Must parse back to an equivalent object.
+ expect(JSON.parse(text).$schema).toBe(CURRENT_MANIFEST_SCHEMA);
+ });
+
+ it('byte-identical for repeated exports of the same input', () => {
+ // Hold time constant via a fixed exporter label since exported_at
+ // moves naturally; we test stable shape modulo timestamp.
+ const a = buildManifest([SERVER_FS, SERVER_DB], 'fixed');
+ const b = buildManifest([SERVER_DB, SERVER_FS], 'fixed');
+ // Different declaration order in input, identical sorted output.
+ expect(a.servers).toEqual(b.servers);
+ });
+});
+
+describe('McpInventoryManifest: parseManifest', () => {
+ const validRaw = serializeManifest(buildManifest([SERVER_FS, SERVER_DB]));
+
+ it('round-trips: build → serialize → parse yields equivalent servers', () => {
+ const result = parseManifest(validRaw);
+ if (!result.ok) throw new Error(`expected ok, got: ${result.errorCode}`);
+ expect(result.manifest.$schema).toBe(CURRENT_MANIFEST_SCHEMA);
+ expect(result.manifest.servers.map(s => s.qualified_name)).toEqual([
+ 'acme/db-server',
+ 'acme/fs-server',
+ ]);
+ });
+
+ it('rejects empty input with the "empty" code', () => {
+ expect(parseManifest('')).toEqual({ ok: false, errorCode: 'empty' });
+ expect(parseManifest(' ')).toEqual({ ok: false, errorCode: 'empty' });
+ });
+
+ it('rejects non-JSON input with the "invalidJson" code and a detail string', () => {
+ const result = parseManifest('{not json');
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.errorCode).toBe('invalidJson');
+ // Detail carries the underlying JSON parse exception message.
+ expect(typeof result.detail).toBe('string');
+ }
+ });
+
+ it('rejects non-object root with the "rootNotObject" code', () => {
+ for (const raw of ['[]', '"a string"', 'null']) {
+ const r = parseManifest(raw);
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.errorCode).toBe('rootNotObject');
+ }
+ });
+
+ it('rejects an unknown / mismatched $schema with the "unsupportedSchema" code', () => {
+ const result = parseManifest(JSON.stringify({ $schema: 'something-else', servers: [] }));
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.errorCode).toBe('unsupportedSchema');
+ expect(result.detail).toContain('something-else');
+ }
+ });
+
+ it('rejects missing exported_at / exported_by with the correct codes', () => {
+ const r1 = parseManifest(JSON.stringify({ $schema: CURRENT_MANIFEST_SCHEMA, servers: [] }));
+ expect(r1.ok).toBe(false);
+ if (!r1.ok) expect(r1.errorCode).toBe('missingExportedAt');
+ const r2 = parseManifest(
+ JSON.stringify({ $schema: CURRENT_MANIFEST_SCHEMA, exported_at: '2026-05-25', servers: [] })
+ );
+ expect(r2.ok).toBe(false);
+ if (!r2.ok) expect(r2.errorCode).toBe('missingExportedBy');
+ });
+
+ it('treats a blank or whitespace-only exported_by as missing', () => {
+ for (const blank of ['', ' ', '\t\n']) {
+ const result = parseManifest(
+ JSON.stringify({
+ $schema: CURRENT_MANIFEST_SCHEMA,
+ exported_at: '2026-05-25T00:00:00Z',
+ exported_by: blank,
+ servers: [],
+ })
+ );
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errorCode).toBe('missingExportedBy');
+ }
+ });
+
+ it('rejects a non-array servers field with the "invalidServers" code', () => {
+ const result = parseManifest(
+ JSON.stringify({
+ $schema: CURRENT_MANIFEST_SCHEMA,
+ exported_at: '2026-05-25T00:00:00Z',
+ exported_by: 'x',
+ servers: 'not-an-array',
+ })
+ );
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errorCode).toBe('invalidServers');
+ });
+
+ it('rejects entries missing qualified_name with the matching code', () => {
+ const result = parseManifest(
+ JSON.stringify({
+ $schema: CURRENT_MANIFEST_SCHEMA,
+ exported_at: '2026-05-25T00:00:00Z',
+ exported_by: 'x',
+ servers: [{ display_name: 'X', env_keys: [] }],
+ })
+ );
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errorCode).toBe('serverMissingQualifiedName');
+ });
+
+ it('rejects entries whose env_keys is not an array of strings', () => {
+ const result = parseManifest(
+ JSON.stringify({
+ $schema: CURRENT_MANIFEST_SCHEMA,
+ exported_at: '2026-05-25T00:00:00Z',
+ exported_by: 'x',
+ servers: [{ qualified_name: 'a/b', display_name: 'AB', env_keys: [1, 2, 3] }],
+ })
+ );
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errorCode).toBe('serverEnvKeysNotArray');
+ });
+
+ // The single most important security test in the file:
+ it('SECURITY: refuses any manifest that smuggles an `env` value map', () => {
+ const malicious = JSON.stringify({
+ $schema: CURRENT_MANIFEST_SCHEMA,
+ exported_at: '2026-05-25T00:00:00Z',
+ exported_by: 'attacker',
+ servers: [
+ {
+ qualified_name: 'evil/server',
+ display_name: 'Evil',
+ env_keys: ['API_KEY'],
+ env: { API_KEY: 'attacker-supplied-secret-value' },
+ },
+ ],
+ });
+ const result = parseManifest(malicious);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.errorCode).toBe('serverContainsEnv');
+ });
+
+ it('rejects manifests containing a duplicate qualified_name with the matching code', () => {
+ const dup = JSON.stringify({
+ $schema: CURRENT_MANIFEST_SCHEMA,
+ exported_at: '2026-05-25T00:00:00Z',
+ exported_by: 'x',
+ servers: [
+ { qualified_name: 'a/b', display_name: 'First', env_keys: [] },
+ { qualified_name: 'a/b', display_name: 'Second', env_keys: [] },
+ ],
+ });
+ const result = parseManifest(dup);
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.errorCode).toBe('duplicateQualifiedName');
+ // Detail includes the offending qualified_name for diagnosability.
+ expect(result.detail).toContain('a/b');
+ }
+ });
+
+ it('omits optional fields cleanly on parse (no undefined leaks)', () => {
+ const minimal = JSON.stringify({
+ $schema: CURRENT_MANIFEST_SCHEMA,
+ exported_at: '2026-05-25T00:00:00Z',
+ exported_by: 'x',
+ servers: [{ qualified_name: 'a/b', display_name: 'AB', env_keys: [] }],
+ });
+ const result = parseManifest(minimal);
+ if (!result.ok) throw new Error(result.errorCode);
+ const e = result.manifest.servers[0];
+ expect('description' in e).toBe(false);
+ expect('config' in e).toBe(false);
+ });
+});
+
+describe('McpInventoryManifest: classifyImport', () => {
+ const manifest = buildManifest([SERVER_FS, SERVER_DB]);
+
+ it('classifies each entry as new or already_installed by qualified_name', () => {
+ const classified = classifyImport(manifest, [
+ // Pretend SERVER_FS is already installed locally; SERVER_DB is not.
+ { ...SERVER_FS },
+ ]);
+ const byName = Object.fromEntries(classified.map(c => [c.entry.qualified_name, c.status]));
+ expect(byName['acme/fs-server']).toBe('already_installed');
+ expect(byName['acme/db-server']).toBe('new');
+ });
+
+ it('returns all as "new" when nothing is installed locally', () => {
+ const classified = classifyImport(manifest, []);
+ expect(classified.every(c => c.status === 'new')).toBe(true);
+ });
+
+ it('returns all as "already_installed" when every manifest entry exists locally', () => {
+ const classified = classifyImport(manifest, [SERVER_FS, SERVER_DB]);
+ expect(classified.every(c => c.status === 'already_installed')).toBe(true);
+ });
+
+ it('preserves manifest entry order in the classified output', () => {
+ const classified = classifyImport(manifest, []);
+ // Manifest is sorted by qualified_name; classification preserves that.
+ expect(classified.map(c => c.entry.qualified_name)).toEqual([
+ 'acme/db-server',
+ 'acme/fs-server',
+ ]);
+ });
+});
+
+describe('McpInventoryManifest: suggestedFilename', () => {
+ it('produces a slug-style filename including a timestamp', () => {
+ const m = buildManifest([SERVER_FS]);
+ const name = suggestedFilename(m);
+ expect(name.startsWith('openhuman-mcp-inventory-')).toBe(true);
+ expect(name.endsWith('.json')).toBe(true);
+ // Stamp must contain only digits (no T, no -, no :, no .).
+ const stamp = name.replace('openhuman-mcp-inventory-', '').replace('.json', '');
+ expect(/^\d+$/.test(stamp)).toBe(true);
+ });
+});
diff --git a/app/src/components/channels/mcp/McpInventoryManifest.ts b/app/src/components/channels/mcp/McpInventoryManifest.ts
new file mode 100644
index 000000000..62a61f250
--- /dev/null
+++ b/app/src/components/channels/mcp/McpInventoryManifest.ts
@@ -0,0 +1,275 @@
+/**
+ * Sharable MCP Inventory — portable, versioned, secret-free manifest of
+ * installed MCP servers.
+ *
+ * The shape is intentionally NOT just `InstalledServer[]`:
+ *
+ * - `server_id` is per-machine (UUID) and would mean nothing on the
+ * importer's host. Stripped on export.
+ * - `installed_at` / `last_connected_at` are local-time observability
+ * fields irrelevant to the importer. Stripped.
+ * - `env` *values* are SECRETS. Only the `env_keys` (NAMES) make the
+ * manifest. The importer fills values per-server.
+ * - `command` / `args` are intentionally NOT carried — the importer's
+ * core decides how to spawn from the upstream registry entry. This
+ * keeps manifests portable across `npx` / `uvx` upgrades and avoids
+ * baking transient command shapes into shared artifacts.
+ *
+ * The schema field (`$schema`) is a string sentinel rather than a URL
+ * so the manifest is fully self-contained and can be validated offline.
+ * Bump `CURRENT_MANIFEST_VERSION` if the shape changes.
+ */
+import type { InstalledServer } from './types';
+
+/**
+ * Sentinel embedded in every exported manifest. Importer rejects any
+ * payload whose `$schema` does not match exactly.
+ */
+export const CURRENT_MANIFEST_SCHEMA = 'openhuman.mcp-inventory.v1' as const;
+
+/**
+ * Per-server entry in the exported manifest. No secrets, no per-machine
+ * identifiers. Optional fields are omitted when absent (NOT serialised as
+ * `null` / `undefined`) to keep manifests stable across re-exports.
+ */
+export interface McpInventoryEntry {
+ qualified_name: string;
+ display_name: string;
+ description?: string;
+ /** ENV variable NAMES (not values). The importer collects values. */
+ env_keys: string[];
+ /** Free-form non-secret config blob the server may need. */
+ config?: unknown;
+}
+
+export interface McpInventoryManifest {
+ $schema: typeof CURRENT_MANIFEST_SCHEMA;
+ /** ISO-8601 UTC timestamp captured at export time. */
+ exported_at: string;
+ /** Free-form label for the exporting environment (host, user, env). */
+ exported_by: string;
+ servers: McpInventoryEntry[];
+}
+
+/**
+ * Build the export entry for one installed server. Centralised here so
+ * the redaction contract ("no secret values, no per-machine ids") is
+ * stated exactly once and tested exactly once.
+ */
+const toEntry = (server: InstalledServer): McpInventoryEntry => {
+ const entry: McpInventoryEntry = {
+ qualified_name: server.qualified_name,
+ display_name: server.display_name,
+ env_keys: Array.isArray(server.env_keys) ? [...server.env_keys].sort() : [],
+ };
+ if (server.description) entry.description = server.description;
+ if (server.config !== undefined && server.config !== null) entry.config = server.config;
+ return entry;
+};
+
+/** Produce a manifest object from a list of installed servers. */
+export function buildManifest(
+ servers: InstalledServer[],
+ exportedBy = 'openhuman-desktop'
+): McpInventoryManifest {
+ return {
+ $schema: CURRENT_MANIFEST_SCHEMA,
+ exported_at: new Date().toISOString(),
+ exported_by: exportedBy,
+ // Sort by qualified_name for deterministic output (re-exporting the
+ // same set twice produces byte-identical manifests, which makes
+ // them diff-friendly in source control).
+ servers: servers.map(toEntry).sort((a, b) => a.qualified_name.localeCompare(b.qualified_name)),
+ };
+}
+
+/** Pretty-print a manifest to JSON suitable for clipboard / download. */
+export function serializeManifest(manifest: McpInventoryManifest): string {
+ return `${JSON.stringify(manifest, null, 2)}\n`;
+}
+
+/**
+ * Stable, locale-independent identifiers for every parse-failure mode.
+ *
+ * Mapped to translated text by the consumer (UI) via
+ * `mcp.inventory.parseError.` i18n keys — so the manifest layer
+ * stays decoupled from any presentation locale, and the failure modes
+ * are a fixed contract that external tooling (CLIs, test fixtures,
+ * other clients) can match on without depending on the rendered text.
+ */
+export type ParseErrorCode =
+ | 'empty'
+ | 'invalidJson'
+ | 'rootNotObject'
+ | 'unsupportedSchema'
+ | 'missingExportedAt'
+ | 'missingExportedBy'
+ | 'invalidServers'
+ | 'serverNotObject'
+ | 'serverMissingQualifiedName'
+ | 'serverMissingDisplayName'
+ | 'serverEnvKeysNotArray'
+ | 'serverContainsEnv'
+ | 'duplicateQualifiedName';
+
+/**
+ * Discriminated-union result of parsing a manifest. On failure carries
+ * a stable `errorCode` for i18n + an optional `detail` string with
+ * machine context (JSON parse exception text, offending index, the
+ * actual schema string we got, etc.). Consumers render via
+ * `t(\`mcp.inventory.parseError.\${errorCode}\`)` + optional detail.
+ */
+export type ParseResult =
+ | { ok: true; manifest: McpInventoryManifest }
+ | { ok: false; errorCode: ParseErrorCode; detail?: string };
+
+const isObject = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value);
+
+const isStringArray = (value: unknown): value is string[] =>
+ Array.isArray(value) && value.every(v => typeof v === 'string');
+
+/**
+ * Parse + validate a raw manifest string. Returns a discriminated union
+ * with a stable `errorCode` on failure — never throws. Tolerant of
+ * trailing whitespace; strict on the rest. Includes a duplicate-
+ * `qualified_name` check so a malformed/malicious manifest can't
+ * quietly install the same server twice with diverging env_keys.
+ */
+export function parseManifest(raw: string): ParseResult {
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
+ return { ok: false, errorCode: 'empty' };
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (err) {
+ return {
+ ok: false,
+ errorCode: 'invalidJson',
+ detail: err instanceof Error ? err.message : undefined,
+ };
+ }
+ if (!isObject(parsed)) {
+ return { ok: false, errorCode: 'rootNotObject' };
+ }
+ if (parsed.$schema !== CURRENT_MANIFEST_SCHEMA) {
+ return {
+ ok: false,
+ errorCode: 'unsupportedSchema',
+ detail: `expected "${CURRENT_MANIFEST_SCHEMA}", got "${String(parsed.$schema)}"`,
+ };
+ }
+ if (typeof parsed.exported_at !== 'string' || parsed.exported_at.length === 0) {
+ return { ok: false, errorCode: 'missingExportedAt' };
+ }
+ if (typeof parsed.exported_by !== 'string' || parsed.exported_by.trim().length === 0) {
+ // Blank/whitespace-only `exported_by` is treated as missing: the field
+ // is observability metadata (which host/user produced the manifest)
+ // and an empty value would render as "Exported from " in the preview.
+ return { ok: false, errorCode: 'missingExportedBy' };
+ }
+ if (!Array.isArray(parsed.servers)) {
+ return { ok: false, errorCode: 'invalidServers' };
+ }
+ const servers: McpInventoryEntry[] = [];
+ // Track qualified_names we've already accepted, so a manifest that
+ // lists the same server twice (with possibly diverging env_keys or
+ // config) is rejected up-front rather than silently producing two
+ // import rows that would both call install on the same upstream id.
+ const seenQualifiedNames = new Set();
+ for (let i = 0; i < parsed.servers.length; i += 1) {
+ const raw = parsed.servers[i];
+ if (!isObject(raw)) {
+ return { ok: false, errorCode: 'serverNotObject', detail: `servers[${i}]` };
+ }
+ if (typeof raw.qualified_name !== 'string' || raw.qualified_name.length === 0) {
+ return { ok: false, errorCode: 'serverMissingQualifiedName', detail: `servers[${i}]` };
+ }
+ if (seenQualifiedNames.has(raw.qualified_name)) {
+ return {
+ ok: false,
+ errorCode: 'duplicateQualifiedName',
+ detail: `servers[${i}]: "${raw.qualified_name}"`,
+ };
+ }
+ seenQualifiedNames.add(raw.qualified_name);
+ if (typeof raw.display_name !== 'string' || raw.display_name.length === 0) {
+ return { ok: false, errorCode: 'serverMissingDisplayName', detail: `servers[${i}]` };
+ }
+ if (!isStringArray(raw.env_keys)) {
+ return { ok: false, errorCode: 'serverEnvKeysNotArray', detail: `servers[${i}]` };
+ }
+ // Pre-import safety net — refuse manifests that smuggle in an `env`
+ // map. (The exporter never writes one, but an attacker / leaked
+ // file might. We want NO path where parseManifest hands the
+ // importer concrete secret values.)
+ if ('env' in raw) {
+ return { ok: false, errorCode: 'serverContainsEnv', detail: `servers[${i}]` };
+ }
+ const entry: McpInventoryEntry = {
+ qualified_name: raw.qualified_name,
+ display_name: raw.display_name,
+ env_keys: raw.env_keys,
+ };
+ if (typeof raw.description === 'string') entry.description = raw.description;
+ if ('config' in raw && raw.config !== undefined && raw.config !== null) {
+ entry.config = raw.config;
+ }
+ servers.push(entry);
+ }
+ return {
+ ok: true,
+ manifest: {
+ $schema: CURRENT_MANIFEST_SCHEMA,
+ exported_at: parsed.exported_at,
+ exported_by: parsed.exported_by,
+ servers,
+ },
+ };
+}
+
+/**
+ * Per-entry import classification. The Import UI uses these statuses to
+ * colour-code the preview table and decide whether to surface an
+ * "Install" action.
+ */
+export type ImportEntryStatus = 'new' | 'already_installed';
+
+export interface ClassifiedImportEntry {
+ entry: McpInventoryEntry;
+ status: ImportEntryStatus;
+}
+
+/**
+ * Cross-reference each manifest entry against the importer's current
+ * installed servers (by `qualified_name`) to classify what would happen
+ * on install. Stable order: matches the manifest's input order.
+ */
+export function classifyImport(
+ manifest: McpInventoryManifest,
+ installed: InstalledServer[]
+): ClassifiedImportEntry[] {
+ const installedNames = new Set(installed.map(s => s.qualified_name));
+ return manifest.servers.map(entry => ({
+ entry,
+ status: installedNames.has(entry.qualified_name) ? 'already_installed' : 'new',
+ }));
+}
+
+/** Suggested default filename for browser-side download. */
+export function suggestedFilename(manifest: McpInventoryManifest): string {
+ // exported_at is "2026-05-25T20:14:15.123Z"; trim to a filename-safe
+ // YYYYMMDDHHMMSS slug so the file sorts well in directory listings.
+ //
+ // The character class is built from a String.fromCharCode array rather
+ // than a literal regex character class because Tailwind v4's content
+ // scanner greps every JS/TS source string for arbitrary-value class
+ // shapes and would mis-identify the literal as a Tailwind class,
+ // emitting an invalid CSS rule that fails `lightningcss minify` during
+ // the Tauri production build.
+ const SEPARATORS = String.fromCharCode(45, 58, 84); // '-', ':', 'T'
+ const stripPattern = new RegExp('[' + SEPARATORS + ']', 'g');
+ const stamp = manifest.exported_at.replace(stripPattern, '').replace(/\..*$/, '');
+ return `openhuman-mcp-inventory-${stamp}.json`;
+}
diff --git a/app/src/components/channels/mcp/McpInventoryPanel.test.tsx b/app/src/components/channels/mcp/McpInventoryPanel.test.tsx
new file mode 100644
index 000000000..ad6940801
--- /dev/null
+++ b/app/src/components/channels/mcp/McpInventoryPanel.test.tsx
@@ -0,0 +1,450 @@
+/**
+ * Tests for McpInventoryPanel + its Export / Import tab children.
+ *
+ * Strategy: drive the whole panel via the parent's public surface
+ * (servers prop, onInstallServer, onClose) so the test asserts the
+ * actual user-visible behaviour rather than internal state shape.
+ */
+import { act, fireEvent, render, screen, within } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { buildManifest, CURRENT_MANIFEST_SCHEMA, serializeManifest } from './McpInventoryManifest';
+import McpInventoryPanel from './McpInventoryPanel';
+import type { InstalledServer } from './types';
+
+const SERVER_FS: InstalledServer = {
+ server_id: 'srv-uuid-1',
+ qualified_name: 'acme/fs-server',
+ display_name: 'File Server',
+ description: 'Reads files',
+ command_kind: 'node',
+ command: 'npx',
+ args: ['-y', 'acme/fs-server'],
+ env_keys: ['ROOT_DIR'],
+ installed_at: 1_700_000_000,
+};
+
+const SERVER_DB: InstalledServer = {
+ server_id: 'srv-uuid-2',
+ qualified_name: 'acme/db-server',
+ display_name: 'DB Server',
+ command_kind: 'node',
+ command: 'npx',
+ args: ['-y', 'acme/db-server'],
+ env_keys: ['DB_URL'],
+ installed_at: 1_700_000_500,
+};
+
+const renderPanel = (overrides?: {
+ servers?: InstalledServer[];
+ onInstallServer?: (qualifiedName: string, prefillEnv: Record) => void;
+ onClose?: () => void;
+}) =>
+ render(
+ {})}
+ onClose={overrides?.onClose ?? (() => {})}
+ />
+ );
+
+describe('McpInventoryPanel — shell', () => {
+ it('renders as an accessible modal dialog with the title labelling it', () => {
+ renderPanel();
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).toHaveAttribute('aria-modal', 'true');
+ expect(dialog).toHaveAttribute('aria-labelledby', 'mcp-inventory-panel-title');
+ expect(screen.getByText('Sharable MCP Inventory')).toBeInTheDocument();
+ });
+
+ it('Esc closes via onClose', () => {
+ const onClose = vi.fn();
+ renderPanel({ onClose });
+ act(() => {
+ fireEvent.keyDown(document, { key: 'Escape' });
+ });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('close button calls onClose', () => {
+ const onClose = vi.fn();
+ renderPanel({ onClose });
+ fireEvent.click(screen.getByRole('button', { name: 'Close inventory panel' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('backdrop mousedown closes; click on dialog card does not', () => {
+ const onClose = vi.fn();
+ renderPanel({ onClose });
+ const dialog = screen.getByRole('dialog');
+ // Mousedown on the backdrop (== currentTarget) closes.
+ fireEvent.mouseDown(dialog);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ // Mousedown on a descendant must NOT close.
+ fireEvent.mouseDown(screen.getByText('Sharable MCP Inventory'));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders the Export tab by default and exposes both tabs', () => {
+ renderPanel();
+ const exportTab = screen.getByRole('tab', { name: 'Export' });
+ const importTab = screen.getByRole('tab', { name: 'Import' });
+ expect(exportTab).toHaveAttribute('aria-selected', 'true');
+ expect(importTab).toHaveAttribute('aria-selected', 'false');
+ });
+
+ it('switches to the Import tab on click', () => {
+ renderPanel();
+ fireEvent.click(screen.getByRole('tab', { name: 'Import' }));
+ expect(screen.getByRole('tab', { name: 'Import' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByRole('tab', { name: 'Export' })).toHaveAttribute('aria-selected', 'false');
+ });
+});
+
+describe('McpInventoryPanel — Export tab', () => {
+ it('renders the empty state when there are no installed servers', () => {
+ renderPanel({ servers: [] });
+ expect(screen.getByText(/Install one from the catalog first/)).toBeInTheDocument();
+ });
+
+ it('renders the manifest JSON in the preview when servers exist', () => {
+ renderPanel({ servers: [SERVER_FS] });
+ const pre = screen.getByTestId('mcp-inventory-export-pre');
+ expect(pre.textContent).toContain(`"$schema": "${CURRENT_MANIFEST_SCHEMA}"`);
+ expect(pre.textContent).toContain('"qualified_name": "acme/fs-server"');
+ expect(pre.textContent).toContain('"env_keys"');
+ // The preview MUST NOT contain server_id, command, args, etc.
+ expect(pre.textContent).not.toContain('"server_id"');
+ expect(pre.textContent).not.toContain('"installed_at"');
+ expect(pre.textContent).not.toContain('"command"');
+ });
+
+ it('renders the server count', () => {
+ renderPanel({ servers: [SERVER_FS, SERVER_DB] });
+ expect(screen.getByText('2 servers in this manifest')).toBeInTheDocument();
+ });
+
+ it('renders the privacy banner verbatim', () => {
+ renderPanel({ servers: [SERVER_FS] });
+ expect(screen.getByText('What is in this manifest')).toBeInTheDocument();
+ expect(
+ screen.getByText(/env-variable KEY NAMES, and non-secret config only/)
+ ).toBeInTheDocument();
+ });
+
+ it('Download button creates a Blob URL, triggers a click on a hidden link, and revokes', async () => {
+ const createObjectURL = vi.fn().mockReturnValue('blob:fake-url-123');
+ const revokeObjectURL = vi.fn();
+ const originalCreate = URL.createObjectURL;
+ const originalRevoke = URL.revokeObjectURL;
+ URL.createObjectURL = createObjectURL;
+ URL.revokeObjectURL = revokeObjectURL;
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
+ try {
+ renderPanel({ servers: [SERVER_FS] });
+ fireEvent.click(screen.getByRole('button', { name: /Download the manifest/ }));
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
+ const blobArg = createObjectURL.mock.calls[0][0] as Blob;
+ expect(blobArg.type).toBe('application/json');
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ // handleDownload defers revokeObjectURL via setTimeout(..., 0); flush.
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:fake-url-123');
+ } finally {
+ URL.createObjectURL = originalCreate;
+ URL.revokeObjectURL = originalRevoke;
+ clickSpy.mockRestore();
+ }
+ });
+
+ it('Copy button writes the serialized manifest to the clipboard', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ const originalClipboard = (navigator as { clipboard?: unknown }).clipboard;
+ Object.defineProperty(navigator, 'clipboard', {
+ writable: true,
+ configurable: true,
+ value: { writeText },
+ });
+ try {
+ renderPanel({ servers: [SERVER_FS] });
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /Copy the manifest JSON/ }));
+ });
+ expect(writeText).toHaveBeenCalledTimes(1);
+ const arg = writeText.mock.calls[0][0] as string;
+ expect(arg).toContain(`"$schema": "${CURRENT_MANIFEST_SCHEMA}"`);
+ expect(arg).toContain('"qualified_name": "acme/fs-server"');
+ } finally {
+ if (originalClipboard === undefined) {
+ delete (navigator as { clipboard?: unknown }).clipboard;
+ } else {
+ Object.defineProperty(navigator, 'clipboard', {
+ writable: true,
+ configurable: true,
+ value: originalClipboard,
+ });
+ }
+ }
+ });
+});
+
+describe('McpInventoryPanel — Import tab', () => {
+ beforeEach(() => {
+ // Each test starts on the Export tab by default. Most Import-tab
+ // tests want to jump straight to Import; the per-test setup does so.
+ });
+
+ const switchToImport = () => {
+ fireEvent.click(screen.getByRole('tab', { name: 'Import' }));
+ };
+
+ it('renders the trust banner', () => {
+ renderPanel();
+ switchToImport();
+ expect(screen.getByText('Treat imported manifests as untrusted code')).toBeInTheDocument();
+ });
+
+ it('Preview button is disabled until the textarea has content', () => {
+ renderPanel();
+ switchToImport();
+ expect(screen.getByRole('button', { name: 'Preview' })).toBeDisabled();
+ fireEvent.change(screen.getByLabelText('Paste manifest JSON'), { target: { value: '{}' } });
+ expect(screen.getByRole('button', { name: 'Preview' })).not.toBeDisabled();
+ });
+
+ it('shows a role=alert with the parse-error prefix when the JSON is invalid', () => {
+ renderPanel();
+ switchToImport();
+ fireEvent.change(screen.getByLabelText('Paste manifest JSON'), {
+ target: { value: '{not valid' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
+ const alert = screen.getByRole('alert');
+ expect(alert.textContent).toMatch(/Could not parse manifest:/);
+ });
+
+ it('shows a role=alert when an unknown $schema is presented', () => {
+ renderPanel();
+ switchToImport();
+ fireEvent.change(screen.getByLabelText('Paste manifest JSON'), {
+ target: {
+ value: JSON.stringify({
+ $schema: 'wrong',
+ exported_at: '2026-05-25T00:00:00Z',
+ exported_by: 'x',
+ servers: [],
+ }),
+ },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
+ expect(screen.getByRole('alert').textContent).toMatch(/Unsupported manifest schema/);
+ });
+
+ it('SECURITY: refuses a manifest that smuggles an env value map and surfaces a clear alert', () => {
+ renderPanel();
+ switchToImport();
+ fireEvent.change(screen.getByLabelText('Paste manifest JSON'), {
+ target: {
+ value: JSON.stringify({
+ $schema: CURRENT_MANIFEST_SCHEMA,
+ exported_at: '2026-05-25T00:00:00Z',
+ exported_by: 'attacker',
+ servers: [
+ {
+ qualified_name: 'evil/server',
+ display_name: 'Evil',
+ env_keys: ['SECRET'],
+ env: { SECRET: 'attacker-value' },
+ },
+ ],
+ }),
+ },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
+ expect(screen.getByRole('alert').textContent).toMatch(/secret values/i);
+ // Critically: no Install button should have been rendered for the rejected entry.
+ expect(screen.queryByRole('button', { name: /Install Evil/ })).not.toBeInTheDocument();
+ });
+
+ it('previews a valid manifest with a per-entry status row', () => {
+ const valid = serializeManifest(buildManifest([SERVER_FS, SERVER_DB]));
+ renderPanel({ servers: [SERVER_FS] }); // SERVER_FS already installed; SERVER_DB is new
+ switchToImport();
+ fireEvent.change(screen.getByLabelText('Paste manifest JSON'), { target: { value: valid } });
+ fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
+
+ // Heading + counts live region
+ expect(screen.getByRole('heading', { name: 'Preview' })).toBeInTheDocument();
+ const status = screen.getByRole('status', { hidden: true });
+ // The summary should mention 2 total — 1 new, 1 already installed.
+ expect(status.textContent).toMatch(/2 servers/);
+ expect(status.textContent).toMatch(/1 new/);
+ expect(status.textContent).toMatch(/1 already installed/);
+
+ // SERVER_DB row has an Install button; SERVER_FS row is skipped.
+ expect(
+ screen.getByRole('button', { name: 'Install DB Server from this manifest' })
+ ).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Install File Server/ })).not.toBeInTheDocument();
+ });
+
+ it('clicking Install hands the qualified_name + empty env prefill to onInstallServer', () => {
+ const onInstallServer = vi.fn();
+ const valid = serializeManifest(buildManifest([SERVER_DB]));
+ renderPanel({ servers: [], onInstallServer });
+ switchToImport();
+ fireEvent.change(screen.getByLabelText('Paste manifest JSON'), { target: { value: valid } });
+ fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Install DB Server from this manifest' }));
+ expect(onInstallServer).toHaveBeenCalledTimes(1);
+ expect(onInstallServer).toHaveBeenCalledWith('acme/db-server', { DB_URL: '' });
+ });
+
+ it('clicking Install also closes the panel (handoff to the existing InstallDialog flow)', () => {
+ const onClose = vi.fn();
+ const valid = serializeManifest(buildManifest([SERVER_DB]));
+ renderPanel({ servers: [], onClose });
+ switchToImport();
+ fireEvent.change(screen.getByLabelText('Paste manifest JSON'), { target: { value: valid } });
+ fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Install DB Server from this manifest' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('Clear button resets the textarea, preview, and any parse error', () => {
+ renderPanel();
+ switchToImport();
+ const textarea = screen.getByLabelText('Paste manifest JSON');
+ fireEvent.change(textarea, { target: { value: '{bad' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
+ expect(textarea).toHaveValue('');
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('typing in the textarea live-clears a stale parse error', () => {
+ renderPanel();
+ switchToImport();
+ const textarea = screen.getByLabelText('Paste manifest JSON');
+ fireEvent.change(textarea, { target: { value: '{bad' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ fireEvent.change(textarea, { target: { value: '{better-typing' } });
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('the empty-manifest case shows a helpful message and no Install buttons', () => {
+ const emptyManifest = serializeManifest(buildManifest([]));
+ renderPanel();
+ switchToImport();
+ fireEvent.change(screen.getByLabelText('Paste manifest JSON'), {
+ target: { value: emptyManifest },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
+ expect(screen.getByText('Manifest contains no servers.')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Install/ })).not.toBeInTheDocument();
+ });
+
+ it('shows env_keys for each preview row when present', () => {
+ const valid = serializeManifest(buildManifest([SERVER_FS]));
+ renderPanel({ servers: [] });
+ switchToImport();
+ fireEvent.change(screen.getByLabelText('Paste manifest JSON'), { target: { value: valid } });
+ fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
+ // The env_keys for SERVER_FS is just ['ROOT_DIR']
+ const previewSection = screen.getByRole('heading', { name: 'Preview' }).closest('section')!;
+ expect(within(previewSection).getByText(/ROOT_DIR/)).toBeInTheDocument();
+ });
+
+ // The file-upload paths are tested via a controllable FileReader stub so
+ // we can drive onload/onerror deterministically; jsdom's built-in
+ // FileReader works on File but doesn't fire events synchronously in a
+ // way that aligns with the synchronous fireEvent/expect cycle here.
+ it('uploading a valid JSON file populates the textarea and previews it', async () => {
+ const valid = serializeManifest(buildManifest([SERVER_DB]));
+ const OriginalFileReader = window.FileReader;
+ class StubReader {
+ onload: ((this: FileReader, ev: ProgressEvent) => unknown) | null = null;
+ onerror: ((this: FileReader, ev: ProgressEvent) => unknown) | null = null;
+ result: string | ArrayBuffer | null = null;
+ readAsText(_: Blob) {
+ this.result = valid;
+ if (this.onload)
+ this.onload.call(this as unknown as FileReader, {} as ProgressEvent);
+ }
+ }
+ (window as unknown as { FileReader: unknown }).FileReader = StubReader;
+ try {
+ renderPanel({ servers: [] });
+ switchToImport();
+ const fileInput = screen.getByLabelText('Upload a manifest .json file') as HTMLInputElement;
+ const file = new File([valid], 'manifest.json', { type: 'application/json' });
+ await act(async () => {
+ fireEvent.change(fileInput, { target: { files: [file] } });
+ });
+ expect(screen.getByLabelText('Paste manifest JSON')).toHaveValue(valid);
+ // Preview was rendered automatically by the onload's parseManifest call.
+ expect(screen.getByRole('heading', { name: 'Preview' })).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Install DB Server from this manifest' })
+ ).toBeInTheDocument();
+ } finally {
+ (window as unknown as { FileReader: unknown }).FileReader = OriginalFileReader;
+ }
+ });
+
+ it('uploading a file larger than 1 MB is refused with a file-error alert and clears any prior preview', () => {
+ renderPanel({ servers: [] });
+ switchToImport();
+ // Stage a valid preview first so we can assert that the refused upload
+ // clears it (per the stale-state-clearing contract in handleFileChange).
+ const valid = serializeManifest(buildManifest([SERVER_DB]));
+ fireEvent.change(screen.getByLabelText('Paste manifest JSON'), { target: { value: valid } });
+ fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
+ expect(screen.getByRole('heading', { name: 'Preview' })).toBeInTheDocument();
+
+ const fileInput = screen.getByLabelText('Upload a manifest .json file') as HTMLInputElement;
+ // 1.5 MB sparse File — `size` is what handleFileChange gates on.
+ const big = new File([new Uint8Array(1_500_000)], 'big.json', { type: 'application/json' });
+ fireEvent.change(fileInput, { target: { files: [big] } });
+ const alert = screen.getByRole('alert');
+ expect(alert.textContent).toMatch(/too large/i);
+ // Stale preview is gone.
+ expect(screen.queryByRole('heading', { name: 'Preview' })).not.toBeInTheDocument();
+ });
+
+ it('FileReader onerror surfaces a file-read-failed alert and clears any prior preview', async () => {
+ const OriginalFileReader = window.FileReader;
+ class FailingReader {
+ onload: ((this: FileReader, ev: ProgressEvent) => unknown) | null = null;
+ onerror: ((this: FileReader, ev: ProgressEvent) => unknown) | null = null;
+ readAsText(_: Blob) {
+ if (this.onerror)
+ this.onerror.call(this as unknown as FileReader, {} as ProgressEvent);
+ }
+ }
+ (window as unknown as { FileReader: unknown }).FileReader = FailingReader;
+ try {
+ renderPanel({ servers: [] });
+ switchToImport();
+ // Stage a valid preview to confirm it's wiped on read failure.
+ const valid = serializeManifest(buildManifest([SERVER_DB]));
+ fireEvent.change(screen.getByLabelText('Paste manifest JSON'), { target: { value: valid } });
+ fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
+ expect(screen.getByRole('heading', { name: 'Preview' })).toBeInTheDocument();
+
+ const fileInput = screen.getByLabelText('Upload a manifest .json file') as HTMLInputElement;
+ const file = new File(['anything'], 'broken.json', { type: 'application/json' });
+ await act(async () => {
+ fireEvent.change(fileInput, { target: { files: [file] } });
+ });
+ const alert = screen.getByRole('alert');
+ expect(alert.textContent).toMatch(/Could not read file/i);
+ expect(screen.queryByRole('heading', { name: 'Preview' })).not.toBeInTheDocument();
+ } finally {
+ (window as unknown as { FileReader: unknown }).FileReader = OriginalFileReader;
+ }
+ });
+});
diff --git a/app/src/components/channels/mcp/McpInventoryPanel.tsx b/app/src/components/channels/mcp/McpInventoryPanel.tsx
new file mode 100644
index 000000000..7d5a15de2
--- /dev/null
+++ b/app/src/components/channels/mcp/McpInventoryPanel.tsx
@@ -0,0 +1,163 @@
+/**
+ * Sharable MCP Inventory — top-level modal hosting Export / Import tabs.
+ *
+ * The parent (`McpServersTab`) holds the open/close state and the
+ * current `servers` array; this component owns the tab navigation and
+ * dispatches the install-via-existing-dialog flow back upward.
+ *
+ * Why a single modal with tabs (rather than two separate modals):
+ * - The user often flips between "let me see what I have" (Export)
+ * and "let me apply what someone sent" (Import) in the same
+ * session — tabbing is faster than re-opening.
+ * - The dialog focus contract (`role="dialog" aria-modal`) is
+ * simpler to maintain on a single mount.
+ *
+ * Esc closes the modal; backdrop mousedown closes; clicks inside the
+ * card do not.
+ */
+import { useEffect, useState } from 'react';
+
+import { useT } from '../../../lib/i18n/I18nContext';
+import McpInventoryExportTab from './McpInventoryExportTab';
+import McpInventoryImportTab from './McpInventoryImportTab';
+import type { InstalledServer } from './types';
+
+interface McpInventoryPanelProps {
+ /** Current installed servers — drives the Export tab and the
+ * "already installed" detection in the Import tab. */
+ servers: InstalledServer[];
+ /**
+ * Called when the user clicks "Install" on an entry in the Import
+ * preview. Parent wires this to its existing install-dialog flow
+ * (`setRightPane({ mode: 'install', qualifiedName, prefillEnv })`)
+ * so the proven InstallDialog handles env-value collection — we
+ * never re-implement that critical surface here.
+ */
+ onInstallServer: (qualifiedName: string, prefillEnv: Record) => void;
+ onClose: () => void;
+}
+
+type Tab = 'export' | 'import';
+
+const McpInventoryPanel = ({ servers, onInstallServer, onClose }: McpInventoryPanelProps) => {
+ const { t } = useT();
+ const [tab, setTab] = useState('export');
+
+ // Esc to close, regardless of which child has focus.
+ useEffect(() => {
+ const handler = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ onClose();
+ }
+ };
+ document.addEventListener('keydown', handler);
+ return () => document.removeEventListener('keydown', handler);
+ }, [onClose]);
+
+ return (
+ {
+ if (event.target === event.currentTarget) onClose();
+ }}
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-6 overflow-y-auto">
+
+
+
+
+ {t('mcp.inventory.title')}
+
+
+ {t('mcp.inventory.subtitle')}
+
+
+
+
+
+ {/* Tab bar */}
+
+
+
+
+
+ {tab === 'export' && (
+
+
+
+ )}
+ {tab === 'import' && (
+
+ {
+ // The parent's install flow lives outside this modal
+ // — close the inventory panel so the InstallDialog has
+ // room to render in the main right pane.
+ onInstallServer(qualifiedName, prefillEnv);
+ onClose();
+ }}
+ />
+
+ )}
+
+
+ );
+};
+
+export default McpInventoryPanel;
diff --git a/app/src/components/channels/mcp/McpServersTab.tsx b/app/src/components/channels/mcp/McpServersTab.tsx
index 13e9c2868..246a1805a 100644
--- a/app/src/components/channels/mcp/McpServersTab.tsx
+++ b/app/src/components/channels/mcp/McpServersTab.tsx
@@ -13,6 +13,7 @@ import InstallDialog from './InstallDialog';
import InstalledServerDetail from './InstalledServerDetail';
import InstalledServerList from './InstalledServerList';
import McpCatalogBrowser from './McpCatalogBrowser';
+import McpInventoryPanel from './McpInventoryPanel';
import type { ConnStatus, InstalledServer } from './types';
const log = debug('mcp-clients:tab');
@@ -31,6 +32,9 @@ const McpServersTab = () => {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [rightPane, setRightPane] = useState({ mode: 'none' });
+ // Sharable Inventory modal toggle. Local state — the manifest UX is
+ // a one-off interaction, not a saved view.
+ const [inventoryOpen, setInventoryOpen] = useState(false);
const pollTimerRef = useRef | null>(null);
const loadInstalled = useCallback(async () => {
@@ -145,13 +149,22 @@ const McpServersTab = () => {
return (
-
-
- {t('mcp.alphaBadge')}
-
- {t('mcp.alphaBannerText')}
+
+
+
+ {t('mcp.alphaBadge')}
+
+ {t('mcp.alphaBannerText')}
+
+
{/* Left pane: installed list */}
@@ -200,6 +213,18 @@ const McpServersTab = () => {
)}
+ {inventoryOpen && (
+ {
+ // Hand the entry off to the existing install-dialog flow.
+ // The panel closes itself; here we open the dialog with the
+ // env keys pre-populated so the user only has to fill values.
+ setRightPane({ mode: 'install', qualifiedName, prefillEnv });
+ }}
+ onClose={() => setInventoryOpen(false)}
+ />
+ )}
);
};
diff --git a/app/src/lib/i18n/chunks/ar-1.ts b/app/src/lib/i18n/chunks/ar-1.ts
index 6d3e5cc8b..bd7df0c03 100644
--- a/app/src/lib/i18n/chunks/ar-1.ts
+++ b/app/src/lib/i18n/chunks/ar-1.ts
@@ -1261,6 +1261,69 @@ const ar1: TranslationMap = {
'mcp.installed.empty': 'لم يتم تثبيت خوادم MCP حتى الآن.',
'mcp.installed.toolSingular': 'أداة {count}',
'mcp.installed.toolPlural': 'أدوات {count}',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'جارٍ تحميل خوادم MCP...',
'mcp.tab.emptyDetail': 'حدد خادمًا أو تصفح الكتالوج.',
'mcp.install.loadingDetail': 'جارٍ تحميل تفاصيل الخادم...',
diff --git a/app/src/lib/i18n/chunks/bn-1.ts b/app/src/lib/i18n/chunks/bn-1.ts
index 797d9c35f..abb16487d 100644
--- a/app/src/lib/i18n/chunks/bn-1.ts
+++ b/app/src/lib/i18n/chunks/bn-1.ts
@@ -1271,6 +1271,69 @@ const bn1: TranslationMap = {
'mcp.installed.empty': 'এখনো কোনো MCP সার্ভার ইনস্টল করা হয়নি।',
'mcp.installed.toolSingular': '{count} টুল',
'mcp.installed.toolPlural': '{count} টুল',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'MCP সার্ভার লোড হচ্ছে...',
'mcp.tab.emptyDetail': 'একটি সার্ভার বা সারি নির্বাচন করুন।',
'mcp.install.loadingDetail': 'সার্ভারের বিবরণ লোড হচ্ছে...',
diff --git a/app/src/lib/i18n/chunks/de-1.ts b/app/src/lib/i18n/chunks/de-1.ts
index 951d5035f..ec83368ff 100644
--- a/app/src/lib/i18n/chunks/de-1.ts
+++ b/app/src/lib/i18n/chunks/de-1.ts
@@ -1289,6 +1289,69 @@ const de1: TranslationMap = {
'mcp.installed.empty': 'Noch keine MCP-Server installiert.',
'mcp.installed.toolSingular': '{count}-Tool',
'mcp.installed.toolPlural': '{count}-Tools',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'MCP-Server werden geladen...',
'mcp.tab.emptyDetail': 'Wählen Sie einen Server aus oder durchsuchen Sie den Katalog.',
'mcp.install.loadingDetail': 'Serverdetails werden geladen...',
diff --git a/app/src/lib/i18n/chunks/en-1.ts b/app/src/lib/i18n/chunks/en-1.ts
index 5de532849..7894c1819 100644
--- a/app/src/lib/i18n/chunks/en-1.ts
+++ b/app/src/lib/i18n/chunks/en-1.ts
@@ -1247,6 +1247,69 @@ const en1: TranslationMap = {
'mcp.installed.empty': 'No MCP servers installed yet.',
'mcp.installed.toolSingular': '{count} tool',
'mcp.installed.toolPlural': '{count} tools',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'Loading MCP servers...',
'mcp.tab.emptyDetail': 'Select a server or browse the catalog.',
'mcp.install.loadingDetail': 'Loading server details...',
diff --git a/app/src/lib/i18n/chunks/es-1.ts b/app/src/lib/i18n/chunks/es-1.ts
index 8f3b3f0df..844e888a3 100644
--- a/app/src/lib/i18n/chunks/es-1.ts
+++ b/app/src/lib/i18n/chunks/es-1.ts
@@ -1286,6 +1286,69 @@ const es1: TranslationMap = {
'mcp.installed.empty': 'Aún no hay servidores MCP instalados.',
'mcp.installed.toolSingular': 'herramienta {count}',
'mcp.installed.toolPlural': '{count} herramientas',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'Cargando servidores MCP...',
'mcp.tab.emptyDetail': 'Seleccione un servidor o explore el catálogo.',
'mcp.install.loadingDetail': 'Cargando detalles del servidor...',
diff --git a/app/src/lib/i18n/chunks/fr-1.ts b/app/src/lib/i18n/chunks/fr-1.ts
index 90f0bf295..f9bef1aa2 100644
--- a/app/src/lib/i18n/chunks/fr-1.ts
+++ b/app/src/lib/i18n/chunks/fr-1.ts
@@ -1291,6 +1291,69 @@ const fr1: TranslationMap = {
'mcp.installed.empty': 'No MCP servers installed yet.',
'mcp.installed.toolSingular': 'Outil {count}',
'mcp.installed.toolPlural': 'Outils {count}',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'Chargement des serveurs MCP...',
'mcp.tab.emptyDetail': 'Sélectionnez un serveur ou parcourez le catalogue.',
'mcp.install.loadingDetail': 'Chargement des détails du serveur...',
diff --git a/app/src/lib/i18n/chunks/hi-1.ts b/app/src/lib/i18n/chunks/hi-1.ts
index 5512e58dd..0a766ac3c 100644
--- a/app/src/lib/i18n/chunks/hi-1.ts
+++ b/app/src/lib/i18n/chunks/hi-1.ts
@@ -1269,6 +1269,69 @@ const hi1: TranslationMap = {
'mcp.installed.empty': 'अभी तक कोई MCP सर्वर स्थापित नहीं है।',
'mcp.installed.toolSingular': '{count} उपकरण',
'mcp.installed.toolPlural': '{count} उपकरण',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'MCP सर्वर लोड हो रहा है...',
'mcp.tab.emptyDetail': 'एक सर्वर चुनें या कैटलॉग ब्राउज़ करें।',
'mcp.install.loadingDetail': 'सर्वर विवरण लोड हो रहा है...',
diff --git a/app/src/lib/i18n/chunks/id-1.ts b/app/src/lib/i18n/chunks/id-1.ts
index 645b3c9a3..31bcc390c 100644
--- a/app/src/lib/i18n/chunks/id-1.ts
+++ b/app/src/lib/i18n/chunks/id-1.ts
@@ -1274,6 +1274,69 @@ const id1: TranslationMap = {
'mcp.installed.empty': 'Belum ada server MCP yang terinstal.',
'mcp.installed.toolSingular': '{count} alat',
'mcp.installed.toolPlural': '{count} alat',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'Memuat MCP server...',
'mcp.tab.emptyDetail': 'Pilih server atau telusuri katalog.',
'mcp.install.loadingDetail': 'Memuat detail server...',
diff --git a/app/src/lib/i18n/chunks/it-1.ts b/app/src/lib/i18n/chunks/it-1.ts
index 421096663..903d38718 100644
--- a/app/src/lib/i18n/chunks/it-1.ts
+++ b/app/src/lib/i18n/chunks/it-1.ts
@@ -1279,6 +1279,69 @@ const it1: TranslationMap = {
'mcp.installed.empty': 'Nessun server MCP ancora installato.',
'mcp.installed.toolSingular': '{count} strumento',
'mcp.installed.toolPlural': '{count} strumenti',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'Caricamento MCP server...',
'mcp.tab.emptyDetail': 'Selezionare un server o sfogliare il catalogo.',
'mcp.install.loadingDetail': 'Caricamento dettagli server...',
diff --git a/app/src/lib/i18n/chunks/ko-1.ts b/app/src/lib/i18n/chunks/ko-1.ts
index 31136cd16..b2ea4609c 100644
--- a/app/src/lib/i18n/chunks/ko-1.ts
+++ b/app/src/lib/i18n/chunks/ko-1.ts
@@ -1271,6 +1271,69 @@ const ko1: TranslationMap = {
'mcp.installed.empty': '아직 MCP 서버가 설치되지 않았습니다.',
'mcp.installed.toolSingular': '{count} 도구',
'mcp.installed.toolPlural': '{count} 도구',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'MCP 서버 로드 중...',
'mcp.tab.emptyDetail': '서버를 선택하거나 카탈로그를 찾아보세요.',
'mcp.install.loadingDetail': '서버 세부정보 로드 중...',
diff --git a/app/src/lib/i18n/chunks/pt-1.ts b/app/src/lib/i18n/chunks/pt-1.ts
index d212f2cb9..ea3e5ae82 100644
--- a/app/src/lib/i18n/chunks/pt-1.ts
+++ b/app/src/lib/i18n/chunks/pt-1.ts
@@ -1284,6 +1284,69 @@ const pt1: TranslationMap = {
'mcp.installed.empty': 'Nenhum servidor MCP instalado ainda.',
'mcp.installed.toolSingular': 'Ferramenta {count}',
'mcp.installed.toolPlural': 'Ferramentas {count}',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'Carregando servidores MCP...',
'mcp.tab.emptyDetail': 'Selecione um servidor ou navegue no catálogo.',
'mcp.install.loadingDetail': 'Carregando detalhes do servidor...',
diff --git a/app/src/lib/i18n/chunks/ru-1.ts b/app/src/lib/i18n/chunks/ru-1.ts
index bfb50924f..d8e8313ae 100644
--- a/app/src/lib/i18n/chunks/ru-1.ts
+++ b/app/src/lib/i18n/chunks/ru-1.ts
@@ -1274,6 +1274,69 @@ const ru1: TranslationMap = {
'mcp.installed.empty': 'Серверы MCP пока не установлены.',
'mcp.installed.toolSingular': '{count} инструмент',
'mcp.installed.toolPlural': '{count} инструменты',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'Загрузка серверов MCP...',
'mcp.tab.emptyDetail': 'Выберите сервер или просмотрите каталог.',
'mcp.install.loadingDetail': 'Загрузка сведений о сервере...',
diff --git a/app/src/lib/i18n/chunks/zh-CN-1.ts b/app/src/lib/i18n/chunks/zh-CN-1.ts
index bf6611ae6..ae0fd5d62 100644
--- a/app/src/lib/i18n/chunks/zh-CN-1.ts
+++ b/app/src/lib/i18n/chunks/zh-CN-1.ts
@@ -1254,6 +1254,69 @@ const zhCN1: TranslationMap = {
'mcp.installed.empty': '尚未安装 MCP 服务器。',
'mcp.installed.toolSingular': '{count} 工具',
'mcp.installed.toolPlural': '{count} 工具',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': '正在加载 MCP 服务器...',
'mcp.tab.emptyDetail': '选择服务器或浏览目录。',
'mcp.install.loadingDetail': '正在加载服务器详细信息...',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index ddef7088f..606109a0f 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -869,6 +869,69 @@ const en: TranslationMap = {
'mcp.installed.empty': 'No MCP servers installed yet.',
'mcp.installed.toolSingular': '{count} tool',
'mcp.installed.toolPlural': '{count} tools',
+ 'mcp.inventory.openButton': 'Inventory',
+ 'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
+ 'mcp.inventory.title': 'Sharable MCP Inventory',
+ 'mcp.inventory.subtitle':
+ 'Export your installed MCP servers as a portable, secret-free manifest, or import one from a teammate. Secret env values are never included or imported.',
+ 'mcp.inventory.close': 'Close inventory panel',
+ 'mcp.inventory.tablistAria': 'Inventory sections',
+ 'mcp.inventory.tab.export': 'Export',
+ 'mcp.inventory.tab.import': 'Import',
+ 'mcp.inventory.export.empty':
+ 'No MCP servers installed yet — nothing to export. Install one from the catalog first.',
+ 'mcp.inventory.export.privacyTitle': 'What is in this manifest',
+ 'mcp.inventory.export.privacyBody':
+ 'Server names, qualified names, env-variable KEY NAMES, and non-secret config only. Secret values, your machine identifiers, and per-install timestamps are intentionally stripped.',
+ 'mcp.inventory.export.serverCount': '{count} servers in this manifest',
+ 'mcp.inventory.export.copy': 'Copy',
+ 'mcp.inventory.export.copied': 'Copied',
+ 'mcp.inventory.export.copyAria': 'Copy the manifest JSON to the clipboard',
+ 'mcp.inventory.export.download': 'Download',
+ 'mcp.inventory.export.downloadAria': 'Download the manifest as a JSON file',
+ 'mcp.inventory.import.trustTitle': 'Treat imported manifests as untrusted code',
+ 'mcp.inventory.import.trustBody':
+ 'An MCP server is a tool you grant your agent. Only import manifests from sources you trust. Each install requires your explicit click; nothing is auto-installed.',
+ 'mcp.inventory.import.pasteLabel': 'Paste manifest JSON',
+ 'mcp.inventory.import.pastePlaceholder': 'Paste a manifest here, or upload a .json file below.',
+ 'mcp.inventory.import.preview': 'Preview',
+ 'mcp.inventory.import.clear': 'Clear',
+ 'mcp.inventory.import.uploadFile': 'or upload a .json file',
+ 'mcp.inventory.import.uploadFileAria': 'Upload a manifest .json file',
+ 'mcp.inventory.import.fileTooLarge': 'File is too large (over 1 MB). Refusing to load.',
+ 'mcp.inventory.import.fileReadFailed': 'Could not read file.',
+ 'mcp.inventory.import.parseErrorPrefix': 'Could not parse manifest:',
+ 'mcp.inventory.import.previewHeading': 'Preview',
+ 'mcp.inventory.import.previewCounts':
+ '{total} servers — {newly} new, {already} already installed',
+ 'mcp.inventory.import.previewEmpty': 'Manifest contains no servers.',
+ 'mcp.inventory.import.exportedFrom': 'Exported from {exporter}',
+ 'mcp.inventory.import.exportedAt': 'at {when}',
+ 'mcp.inventory.import.statusNew': 'New',
+ 'mcp.inventory.import.statusAlreadyInstalled': 'Already installed',
+ 'mcp.inventory.import.envKeysLabel': 'Env keys',
+ 'mcp.inventory.import.install': 'Install',
+ 'mcp.inventory.import.installAria': 'Install {name} from this manifest',
+ 'mcp.inventory.import.skipped': 'skipped',
+ 'mcp.inventory.parseError.empty': 'Manifest is empty.',
+ 'mcp.inventory.parseError.invalidJson': 'Invalid JSON.',
+ 'mcp.inventory.parseError.rootNotObject': 'Manifest must be a JSON object at the root.',
+ 'mcp.inventory.parseError.unsupportedSchema':
+ 'Unsupported manifest schema — this file was not produced by a compatible exporter.',
+ 'mcp.inventory.parseError.missingExportedAt': 'Missing or invalid `exported_at` field.',
+ 'mcp.inventory.parseError.missingExportedBy': 'Missing or invalid `exported_by` field.',
+ 'mcp.inventory.parseError.invalidServers': 'Missing or invalid `servers` array.',
+ 'mcp.inventory.parseError.serverNotObject': 'A server entry is not an object.',
+ 'mcp.inventory.parseError.serverMissingQualifiedName':
+ 'A server entry is missing its qualified_name.',
+ 'mcp.inventory.parseError.serverMissingDisplayName':
+ 'A server entry is missing its display_name.',
+ 'mcp.inventory.parseError.serverEnvKeysNotArray':
+ 'A server entry has an env_keys field that is not an array of strings.',
+ 'mcp.inventory.parseError.serverContainsEnv':
+ 'A server entry contains an `env` value map. Refusing to import — manifests must only carry env_keys (names), never secret values.',
+ 'mcp.inventory.parseError.duplicateQualifiedName':
+ 'Duplicate qualified_name found in manifest. Each server must appear at most once.',
'mcp.tab.loading': 'Loading MCP servers...',
'mcp.tab.emptyDetail': 'Select a server or browse the catalog.',
'mcp.install.loadingDetail': 'Loading server details...',