feat(mcp): Sharable Inventory — portable secret-free manifest with export, parse, validate, and explicit per-entry import (#2655)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Aashir Athar
2026-05-28 19:14:57 +05:30
committed by GitHub
co-authored by Claude Opus 4.7
parent 9349bbaa49
commit 6e2bc97ec5
21 changed files with 2574 additions and 7 deletions
@@ -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 (
<p className="text-sm text-stone-500 dark:text-neutral-400 py-6 text-center">
{t('mcp.inventory.export.empty')}
</p>
);
}
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 <pre>.
}
};
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 (
<div className="space-y-3">
<div
role="note"
className="rounded-lg border border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10 px-3 py-2 text-xs text-stone-700 dark:text-neutral-200">
<p className="font-medium mb-1">{t('mcp.inventory.export.privacyTitle')}</p>
<p>{t('mcp.inventory.export.privacyBody')}</p>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('mcp.inventory.export.serverCount').replace('{count}', String(servers.length))}
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleCopy()}
aria-label={t('mcp.inventory.export.copyAria')}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800">
{copyStatus === 'copied'
? t('mcp.inventory.export.copied')
: t('mcp.inventory.export.copy')}
</button>
<button
type="button"
onClick={handleDownload}
aria-label={t('mcp.inventory.export.downloadAria')}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600">
{t('mcp.inventory.export.download')}
</button>
</div>
</div>
<pre
data-testid="mcp-inventory-export-pre"
className="max-h-80 overflow-auto rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-950 p-3 text-[11px] font-mono text-stone-800 dark:text-neutral-100 whitespace-pre-wrap break-words">
{serialized}
</pre>
</div>
);
};
export default McpInventoryExportTab;
@@ -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<string, string>) => void;
}
const McpInventoryImportTab = ({
installedServers,
onInstallServer,
}: McpInventoryImportTabProps) => {
const { t } = useT();
const [rawInput, setRawInput] = useState('');
const [manifest, setManifest] = useState<McpInventoryManifest | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [fileError, setFileError] = useState<string | null>(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<HTMLInputElement>) => {
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<string, string> = {};
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 (
<div className="space-y-3">
<div
role="note"
className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-xs text-stone-700 dark:text-neutral-200">
<p className="font-medium mb-1">{t('mcp.inventory.import.trustTitle')}</p>
<p>{t('mcp.inventory.import.trustBody')}</p>
</div>
<div className="space-y-1.5">
<label
htmlFor="mcp-inventory-import-textarea"
className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('mcp.inventory.import.pasteLabel')}
</label>
<textarea
id="mcp-inventory-import-textarea"
value={rawInput}
onChange={e => {
setRawInput(e.target.value);
// Live-clear stale parse errors so the user sees a clean
// slate as they edit; they'll re-appear on "Preview".
setParseError(null);
}}
spellCheck={false}
rows={6}
placeholder={t('mcp.inventory.import.pastePlaceholder')}
aria-label={t('mcp.inventory.import.pasteLabel')}
className="w-full rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-xs font-mono text-stone-800 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-400 focus:border-primary-400 resize-y"
/>
</div>
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleParse}
disabled={rawInput.trim().length === 0}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed">
{t('mcp.inventory.import.preview')}
</button>
{(manifest || parseError || rawInput.length > 0) && (
<button
type="button"
onClick={handleClear}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1.5 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800">
{t('mcp.inventory.import.clear')}
</button>
)}
</div>
<label className="text-xs text-primary-600 dark:text-primary-300 cursor-pointer hover:underline">
{t('mcp.inventory.import.uploadFile')}
<input
type="file"
accept="application/json,.json"
onChange={handleFileChange}
className="sr-only"
aria-label={t('mcp.inventory.import.uploadFileAria')}
/>
</label>
</div>
{fileError && (
<p role="alert" className="text-[11px] text-coral-700 dark:text-coral-300">
{fileError}
</p>
)}
{parseError && (
<p role="alert" className="text-[11px] text-coral-700 dark:text-coral-300">
{t('mcp.inventory.import.parseErrorPrefix')} {parseError}
</p>
)}
{manifest && (
<section aria-labelledby="mcp-inventory-import-preview-heading" className="space-y-2">
<h3
id="mcp-inventory-import-preview-heading"
className="text-xs font-semibold text-stone-700 dark:text-neutral-200">
{t('mcp.inventory.import.previewHeading')}
</h3>
<div
role="status"
aria-live="polite"
className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('mcp.inventory.import.previewCounts')
.replace('{total}', String(stats.total))
.replace('{newly}', String(stats.newly))
.replace('{already}', String(stats.already))}
</div>
<div className="text-[10px] text-stone-400 dark:text-neutral-500">
{t('mcp.inventory.import.exportedFrom').replace('{exporter}', manifest.exported_by)} ·{' '}
{t('mcp.inventory.import.exportedAt').replace('{when}', manifest.exported_at)}
</div>
{classified.length === 0 ? (
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('mcp.inventory.import.previewEmpty')}
</p>
) : (
<ul className="space-y-1">
{classified.map(({ entry, status }) => (
<li
key={entry.qualified_name}
className="flex items-center justify-between gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider shrink-0 ${
status === 'new'
? 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300'
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-400'
}`}>
{status === 'new'
? t('mcp.inventory.import.statusNew')
: t('mcp.inventory.import.statusAlreadyInstalled')}
</span>
<span className="text-sm font-medium text-stone-800 dark:text-neutral-100 truncate">
{entry.display_name}
</span>
</div>
<p className="text-[11px] font-mono text-stone-400 dark:text-neutral-500 truncate">
{entry.qualified_name}
</p>
{entry.env_keys.length > 0 && (
<p className="text-[10px] text-stone-500 dark:text-neutral-400 mt-0.5">
{t('mcp.inventory.import.envKeysLabel')}: {entry.env_keys.join(', ')}
</p>
)}
</div>
{status === 'new' ? (
<button
type="button"
onClick={() => handleInstall(entry)}
aria-label={t('mcp.inventory.import.installAria').replace(
'{name}',
entry.display_name
)}
className="shrink-0 rounded-lg bg-primary-500 px-3 py-1 text-xs font-medium text-white hover:bg-primary-600">
{t('mcp.inventory.import.install')}
</button>
) : (
<span className="shrink-0 text-[10px] text-stone-400 dark:text-neutral-500 italic">
{t('mcp.inventory.import.skipped')}
</span>
)}
</li>
))}
</ul>
)}
</section>
)}
</div>
);
};
export default McpInventoryImportTab;
@@ -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);
});
});
@@ -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.<code>` 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<string, unknown> =>
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<string>();
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`;
}
@@ -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<string, string>) => void;
onClose?: () => void;
}) =>
render(
<McpInventoryPanel
servers={overrides?.servers ?? [SERVER_FS, SERVER_DB]}
onInstallServer={overrides?.onInstallServer ?? (() => {})}
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 <pre> 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<FileReader>) => unknown) | null = null;
onerror: ((this: FileReader, ev: ProgressEvent<FileReader>) => 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<FileReader>);
}
}
(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<FileReader>) => unknown) | null = null;
onerror: ((this: FileReader, ev: ProgressEvent<FileReader>) => unknown) | null = null;
readAsText(_: Blob) {
if (this.onerror)
this.onerror.call(this as unknown as FileReader, {} as ProgressEvent<FileReader>);
}
}
(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;
}
});
});
@@ -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<string, string>) => void;
onClose: () => void;
}
type Tab = 'export' | 'import';
const McpInventoryPanel = ({ servers, onInstallServer, onClose }: McpInventoryPanelProps) => {
const { t } = useT();
const [tab, setTab] = useState<Tab>('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 (
<div
role="dialog"
aria-modal="true"
aria-labelledby="mcp-inventory-panel-title"
onMouseDown={event => {
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">
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-xl max-w-3xl w-full p-5 max-h-full overflow-y-auto">
<div className="flex items-start justify-between gap-3 mb-3">
<div>
<h2
id="mcp-inventory-panel-title"
className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('mcp.inventory.title')}
</h2>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
{t('mcp.inventory.subtitle')}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={t('mcp.inventory.close')}
className="shrink-0 rounded-lg p-1.5 text-stone-400 hover:text-stone-700 dark:text-neutral-500 dark:hover:text-neutral-200 transition-colors">
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Tab bar */}
<div
role="tablist"
aria-label={t('mcp.inventory.tablistAria')}
className="flex gap-1 border-b border-stone-200 dark:border-neutral-800 mb-4">
<button
type="button"
role="tab"
id="mcp-inventory-tab-export"
aria-selected={tab === 'export'}
aria-controls="mcp-inventory-panel-export"
tabIndex={tab === 'export' ? 0 : -1}
onClick={() => setTab('export')}
className={`-mb-px px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
tab === 'export'
? 'border-primary-500 text-primary-600 dark:text-primary-300'
: 'border-transparent text-stone-500 dark:text-neutral-400 hover:text-stone-800 dark:hover:text-neutral-200'
}`}>
{t('mcp.inventory.tab.export')}
</button>
<button
type="button"
role="tab"
id="mcp-inventory-tab-import"
aria-selected={tab === 'import'}
aria-controls="mcp-inventory-panel-import"
tabIndex={tab === 'import' ? 0 : -1}
onClick={() => setTab('import')}
className={`-mb-px px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
tab === 'import'
? 'border-primary-500 text-primary-600 dark:text-primary-300'
: 'border-transparent text-stone-500 dark:text-neutral-400 hover:text-stone-800 dark:hover:text-neutral-200'
}`}>
{t('mcp.inventory.tab.import')}
</button>
</div>
{tab === 'export' && (
<div
role="tabpanel"
id="mcp-inventory-panel-export"
aria-labelledby="mcp-inventory-tab-export">
<McpInventoryExportTab servers={servers} />
</div>
)}
{tab === 'import' && (
<div
role="tabpanel"
id="mcp-inventory-panel-import"
aria-labelledby="mcp-inventory-tab-import">
<McpInventoryImportTab
installedServers={servers}
onInstallServer={(qualifiedName, prefillEnv) => {
// 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();
}}
/>
</div>
)}
</div>
</div>
);
};
export default McpInventoryPanel;
@@ -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<string | null>(null);
const [rightPane, setRightPane] = useState<RightPane>({ 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<ReturnType<typeof setTimeout> | null>(null);
const loadInstalled = useCallback(async () => {
@@ -145,13 +149,22 @@ const McpServersTab = () => {
return (
<div className="flex flex-col gap-3 h-full min-h-0">
<div
role="status"
className="flex items-start gap-2 rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-amber-200/70 dark:bg-amber-500/30 text-amber-900 dark:text-amber-100 shrink-0 mt-0.5">
{t('mcp.alphaBadge')}
</span>
<span className="leading-relaxed">{t('mcp.alphaBannerText')}</span>
<div className="flex items-center gap-2">
<div
role="status"
className="flex-1 flex items-start gap-2 rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-amber-200/70 dark:bg-amber-500/30 text-amber-900 dark:text-amber-100 shrink-0 mt-0.5">
{t('mcp.alphaBadge')}
</span>
<span className="leading-relaxed">{t('mcp.alphaBannerText')}</span>
</div>
<button
type="button"
onClick={() => setInventoryOpen(true)}
aria-label={t('mcp.inventory.openAria')}
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-2 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800">
{t('mcp.inventory.openButton')}
</button>
</div>
<div className="flex gap-4 flex-1 min-h-0">
{/* Left pane: installed list */}
@@ -200,6 +213,18 @@ const McpServersTab = () => {
)}
</div>
</div>
{inventoryOpen && (
<McpInventoryPanel
servers={servers}
onInstallServer={(qualifiedName, prefillEnv) => {
// 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)}
/>
)}
</div>
);
};
+63
View File
@@ -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': 'جارٍ تحميل تفاصيل الخادم...',
+63
View File
@@ -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': 'সার্ভারের বিবরণ লোড হচ্ছে...',
+63
View File
@@ -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...',
+63
View File
@@ -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...',
+63
View File
@@ -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...',
+63
View File
@@ -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...',
+63
View File
@@ -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': 'सर्वर विवरण लोड हो रहा है...',
+63
View File
@@ -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...',
+63
View File
@@ -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...',
+63
View File
@@ -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': '서버 세부정보 로드 중...',
+63
View File
@@ -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...',
+63
View File
@@ -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': 'Загрузка сведений о сервере...',
+63
View File
@@ -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': '正在加载服务器详细信息...',
+63
View File
@@ -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...',