mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Claude Opus 4.7
parent
9349bbaa49
commit
6e2bc97ec5
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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': 'جارٍ تحميل تفاصيل الخادم...',
|
||||
|
||||
@@ -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': 'সার্ভারের বিবরণ লোড হচ্ছে...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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': 'सर्वर विवरण लोड हो रहा है...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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': '서버 세부정보 로드 중...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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': 'Загрузка сведений о сервере...',
|
||||
|
||||
@@ -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': '正在加载服务器详细信息...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
Reference in New Issue
Block a user