import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; // Connector catalog — blueprint §14/§15. Admin only. // // One question: **what does this box offer?** The catalog is the shelf; nothing here // is running. A `global` entry still needs the admin to enable it and a `per_user` // one still needs each user to activate it — both of which happen on the Connectors // page, where the runtime lives. // // Adding is one intent with two sources, so it is one button with two options rather // than two distant affordances. Their order mirrors the trust model (§14): the // marketplace path is vetted and hash-verified, the manual path is the escape hatch // that puts unvetted code on the box — which is why it needs `mcp.register_local_script` // and why it sits second. // // Reuses the shared `um-*` / bootstrap styling (no page-specific CSS). const ADMIN_ID = 'admin'; async function jf(url, opts) { const res = await fetch(url, opts); if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`); const ct = res.headers.get('content-type') || ''; return ct.includes('application/json') ? res.json() : null; } export class CatalogPage extends LightElement { static get properties() { return { _open: { state: true }, _me: { state: true }, _rows: { state: true }, _addOpen: { state: true }, // the "Add connector" chooser _error: { state: true }, _modal: { state: true }, }; } constructor() { super(); this._open = false; this._reset(); } _reset() { this._me = null; this._rows = null; this._addOpen = false; this._error = null; this._modal = null; } connectedCallback() { super.connectedCallback(); this.__onLocaleChanged = () => this.requestUpdate(); window.addEventListener('locale-changed', this.__onLocaleChanged); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'catalog'; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._load(); }); document.addEventListener('click', () => { if (this._addOpen) this._addOpen = false; }); } disconnectedCallback() { window.removeEventListener('locale-changed', this.__onLocaleChanged); super.disconnectedCallback(); } get _isAdmin() { return this._me?.role_id === ADMIN_ID; } async _load() { this._error = null; try { this._me = await jf('/api/auth/me'); if (!this._isAdmin) return; this._rows = await jf('/api/mcp/catalog'); } catch (e) { this._error = e.message; } } _goMarketplace() { this._addOpen = false; history.pushState({ page: 'marketplace' }, '', '#marketplace'); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'marketplace' } })); } _goConnectors() { history.pushState({ page: 'connectors' }, '', '#connectors'); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'connectors' } })); } // ── Manual entry ─────────────────────────────────────────────────────────── _openManual() { this._addOpen = false; this._modal = { form: { name: '', scope: 'per_user', source: 'remote', transport: 'stdio', command: '', args: '', url: '', script_path: '', config_schema: '', auth_kind: 'none', friendly_name: '', description: '', }, }; } _patch(field, value) { this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; } _closeModal() { this._modal = null; this._error = null; } async _saveManual() { const f = this._modal.form; if (!f.name.trim()) { this._error = t('catalog.error.name'); return; } const listField = (s) => s.split(/[\n,]/).map(x => x.trim()).filter(Boolean); try { await jf('/api/mcp/catalog', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: f.name.trim(), scope: f.scope, source: f.source, transport: f.transport, command: f.command.trim() || null, args: f.args.trim() ? listField(f.args) : null, url: f.url.trim() || null, script_path: f.script_path.trim() || null, config_schema: f.config_schema.trim() ? listField(f.config_schema) : null, auth_kind: f.auth_kind, friendly_name: f.friendly_name.trim() || null, description: f.description.trim() || null, }), }); this._closeModal(); await this._load(); } catch (e) { this._error = e.message; } } async _delete(row) { if (!confirm(t('catalog.confirm.delete', { name: row.name }))) return; try { await jf(`/api/mcp/catalog/${row.id}`, { method: 'DELETE' }); await this._load(); } catch (e) { this._error = e.message; } } // ── Render ───────────────────────────────────────────────────────────────── render() { if (!this._open) return nothing; const rows = this._rows ?? []; const loading = this._rows === null && !this._error && this._isAdmin; return html`

${t('catalog.title')}

${this._isAdmin ? this._renderAddButton() : nothing}
${this._error && !this._modal ? html`
${this._error}
` : nothing}
${this._me && !this._isAdmin ? html`

${t('catalog.not_admin')}

${unsafeHTML(t('catalog.not_admin_link'))}

` : loading ? html`

${t('catalog.loading')}

` : html`
${unsafeHTML(t('catalog.desc'))}
${rows.length === 0 ? this._renderEmpty() : this._renderTable(rows)} `}
${this._renderModal()}`; } // Bootstrap's own dropdown classes, not a hand-rolled panel: 5.3 themes // `.dropdown-menu`/`.dropdown-item` from `data-bs-theme`, so this follows the // light/dark switch for free. `.show` opens it — the state is ours, not // Bootstrap's JS. _renderAddButton() { return html` `; } _renderEmpty() { return html`

${t('catalog.empty.title')}

${t('catalog.empty.hint')}

`; } _renderTable(rows) { return html` ${rows.map(r => html` `)}
${t('catalog.table.connector')}${t('catalog.table.scope')}${t('catalog.table.type')}${t('catalog.table.auth')}
${r.friendly_name || r.name} ${r.friendly_name ? html` ${r.name}` : nothing} ${r.description ? html`
${r.description}
` : nothing}
${r.scope === 'global' ? t('catalog.badge.global') : t('catalog.badge.per_user')} ${r.source === 'local_script' ? t('catalog.badge.local_script') : t('catalog.badge.remote')} ${r.auth_kind}
`; } _field(label, value, oninput, opts = {}) { return html`
`; } _select(label, value, options, onchange) { return html`
`; } _renderModal() { if (!this._modal) return nothing; const f = this._modal.form; const isScript = f.source === 'local_script'; return html`
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
${t('catalog.modal.title')}
${this._error ? html`
${this._error}
` : nothing} ${isScript ? html`
${unsafeHTML(t('catalog.modal.script_warn'))}
` : nothing} ${this._field(t('catalog.modal.name'), f.name, e => this._patch('name', e.target.value), { hint: t('catalog.modal.name_hint'), mono: true })} ${this._select(t('catalog.modal.scope'), f.scope, ['per_user', 'global'], e => this._patch('scope', e.target.value))} ${this._select(t('catalog.modal.type'), f.source, ['remote', 'local_script'], e => this._patch('source', e.target.value))} ${this._select(t('catalog.modal.transport'), f.transport, ['stdio', 'http', 'sse'], e => this._patch('transport', e.target.value))} ${isScript ? html`${this._field(t('catalog.modal.command'), f.command, e => this._patch('command', e.target.value), { placeholder: t('catalog.modal.command_ph'), mono: true })} ${this._field(t('catalog.modal.script_path'), f.script_path, e => this._patch('script_path', e.target.value), { hint: t('catalog.modal.script_path_hint'), mono: true })}` : this._field(t('catalog.modal.url'), f.url, e => this._patch('url', e.target.value), { mono: true })} ${this._field(t('catalog.modal.args'), f.args, e => this._patch('args', e.target.value), { hint: t('catalog.modal.args_hint'), mono: true })} ${this._field(t('catalog.modal.config_schema'), f.config_schema, e => this._patch('config_schema', e.target.value), { hint: t('catalog.modal.config_schema_hint'), mono: true })} ${this._select(t('catalog.modal.auth'), f.auth_kind, ['none', 'api_key', 'oauth', 'qr', 'ssh_key'], e => this._patch('auth_kind', e.target.value))} ${this._field(t('catalog.modal.friendly'), f.friendly_name, e => this._patch('friendly_name', e.target.value))} ${this._field(t('catalog.modal.desc'), f.description, e => this._patch('description', e.target.value), { hint: t('catalog.modal.desc_hint') })}
`; } }