import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; // Connectors (MCP) management — blueprint §14/§15. // // Two audiences on one page: // • every user: activate/deactivate per-user connectors from the catalog, and // see the global connectors they've been granted; // • admin (role_id === 'admin'): curate the catalog and enable globally-active // connectors + grant per-user access. // // Reuses the shared `um-*` / bootstrap styling (no page-specific CSS). const ADMIN_ID = 'admin'; function parseJson(s, fallback) { if (!s) return fallback; try { return JSON.parse(s); } catch { return fallback; } } 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 ConnectorsPage extends LightElement { static get properties() { return { _open: { state: true }, _me: { state: true }, // { role_id } _available: { state: true }, // { catalog: [...], global: [names] } _activated: { state: true }, // [ user server rows ] _catalog: { state: true }, // admin: catalog rows _global: { state: true }, // admin: global server rows _users: { state: true }, // admin: user summaries (for access) _access: { state: true }, // admin: { server_id -> Set(user_id) } (loaded lazily) _error: { state: true }, _modal: { state: true }, }; } constructor() { super(); this._open = false; this._reset(); } _reset() { this._me = null; this._available = null; this._activated = null; this._catalog = null; this._global = null; this._users = null; this._error = null; this._modal = null; } connectedCallback() { super.connectedCallback(); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'connectors'; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._load(); }); } get _isAdmin() { return this._me?.role_id === ADMIN_ID; } async _load() { this._error = null; try { this._me = await jf('/api/auth/me'); const [available, activated] = await Promise.all([ jf('/api/mcp/available'), jf('/api/mcp/activated'), ]); this._available = available; this._activated = activated; if (this._isAdmin) { const [catalog, global, users] = await Promise.all([ jf('/api/mcp/catalog'), jf('/api/mcp/global'), jf('/api/users'), ]); this._catalog = catalog; this._global = global; this._users = users; } } catch (e) { this._error = e.message; } } _patch(field, value) { this._modal = { ...this._modal, form: { ...this._modal.form, [field]: value } }; } _closeModal() { this._modal = null; this._error = null; } // ── User: activate / deactivate ──────────────────────────────────────────── _openActivate(entry) { const schema = parseJson(entry.config_schema_json, []); this._modal = { kind: 'activate', entry, form: { name: entry.name, api_key: '', env: Object.fromEntries((schema || []).map(k => [k, ''])) }, }; } async _activate() { const { entry, form } = this._modal; if (!form.name.trim()) { this._error = 'A name is required.'; return; } const env = {}; for (const [k, v] of Object.entries(form.env || {})) if (v !== '') env[k] = v; try { await jf('/api/mcp/activate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catalog_name: entry.name, name: form.name.trim(), api_key: form.api_key || null, env: Object.keys(env).length ? env : null, }), }); this._closeModal(); await this._load(); } catch (e) { this._error = e.message; } } async _deactivate(row) { if (!confirm(`Deactivate connector "${row.name}"?`)) return; try { await jf(`/api/mcp/activated/${row.id}`, { method: 'DELETE' }); await this._load(); } catch (e) { this._error = e.message; } } // ── Admin: catalog ───────────────────────────────────────────────────────── _openCatalogNew() { this._modal = { kind: 'catalog', form: { name: '', scope: 'per_user', source: 'remote', transport: 'stdio', command: '', args: '', url: '', script_path: '', config_schema: '', auth_kind: 'none', friendly_name: '', description: '', }, }; } async _saveCatalog() { const f = this._modal.form; if (!f.name.trim()) { this._error = 'Name is required.'; 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 _deleteCatalog(row) { if (!confirm(`Delete catalog entry "${row.name}"?`)) return; try { await jf(`/api/mcp/catalog/${row.id}`, { method: 'DELETE' }); await this._load(); } catch (e) { this._error = e.message; } } // ── Admin: global connectors + access ────────────────────────────────────── _openGlobalEnable() { const globals = (this._catalog ?? []).filter(c => c.scope === 'global'); this._modal = { kind: 'global', globals, form: { catalog_name: globals[0]?.name ?? '', name: '', api_key: '' }, }; } async _enableGlobal() { const f = this._modal.form; if (!f.catalog_name) { this._error = 'Pick a catalog entry.'; return; } try { await jf('/api/mcp/global', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catalog_name: f.catalog_name, name: f.name.trim() || null, api_key: f.api_key || null, }), }); this._closeModal(); await this._load(); } catch (e) { this._error = e.message; } } async _deleteGlobal(row) { if (!confirm(`Remove global connector "${row.name}"?`)) return; try { await jf(`/api/mcp/global/${row.id}`, { method: 'DELETE' }); await this._load(); } catch (e) { this._error = e.message; } } async _openAccess(server) { this._modal = { kind: 'access', server, selected: new Set() }; try { const current = await jf(`/api/mcp/global/${server.id}/access`); // Ignore if the admin already navigated away / opened another modal. if (this._modal?.kind === 'access' && this._modal.server.id === server.id) { this._modal = { ...this._modal, selected: new Set(current || []) }; } } catch (e) { this._error = e.message; } } _toggleAccess(userId) { const sel = new Set(this._modal.selected); sel.has(userId) ? sel.delete(userId) : sel.add(userId); this._modal = { ...this._modal, selected: sel }; } async _saveAccess() { const { server, selected } = this._modal; try { await jf(`/api/mcp/global/${server.id}/access`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_ids: [...selected] }), }); this._closeModal(); } catch (e) { this._error = e.message; } } // ── Render ───────────────────────────────────────────────────────────────── render() { if (!this._open) return nothing; const loading = this._available === null && !this._error; return html`
${g}`)}
No per-user connectors activated.
| Name | Source | From catalog | |
|---|---|---|---|
| ${r.name} | ${r.source} | ${r.catalog_name ? html`${r.catalog_name}` : html`—`} |
| Connector | Source | Auth | |
|---|---|---|---|
| ${e.friendly_name || e.name}
${e.description ? html` ${e.description} ` : nothing} |
${e.source} | ${e.auth_kind} |
${activatedNames.has(e.name) ? html`active` : nothing}
|
Empty catalog.
| Name | Scope | Source | Transport | |
|---|---|---|---|---|
| ${c.name}${c.friendly_name ? html` (${c.friendly_name})` : nothing} | ${c.scope} | ${c.source} | ${c.transport} |
No global connectors.
| Name | Transport | Enabled | |
|---|---|---|---|
| ${g.name} | ${g.transport} | ${g.enabled ? html`on` : html`off`} |
global-scoped catalog entries yet. Add one to the catalog first.