import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; // Connectors (MCP) — blueprint §7/§14/§15. // // One question: **what is running, and what can I add?** This is the runtime view — // literally `UserMcpView` (global ∪ per-user) plus the actions that create those // instances. What this box *offers* is a different question, answered by the // Connector Catalog page. // // The same page serves everyone; the admin just has more verbs. A catalog entry is a // template with two runtimes (§7), so "Available" is one list with the verb that fits // each row: a `per_user` entry says Activate (anyone), a `global` entry says Enable // globally (admin only). Enabling a global is the admin's counterpart to activating a // per-user one — which is why they live side by side instead of in an admin dungeon. // // 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: [...], globals: [...] } _activated: { state: true }, // my per-user server rows _users: { state: true }, // admin: user summaries (for the access modal) _error: { state: true }, _modal: { state: true }, }; } constructor() { super(); this._open = false; this._reset(); } _reset() { this._me = null; this._available = null; this._activated = 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; // Only the access modal needs the user list, and only an admin opens it. if (this._isAdmin) this._users = await jf('/api/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; } _goCatalog() { history.pushState({ page: 'catalog' }, '', '#catalog'); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'catalog' } })); } // ── Activate a per-user connector ────────────────────────────────────────── _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; } } // ── Enable a global connector (admin) ────────────────────────────────────── // The entry comes from the row the admin clicked, so there is no catalog picker: // the old dropdown existed only because this action lived on a page that did not // show the catalog. _openEnableGlobal(entry) { this._modal = { kind: 'global', entry, form: { name: entry.name, api_key: '' }, }; } async _enableGlobal() { const { entry, form } = this._modal; try { await jf('/api/mcp/global', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catalog_name: entry.name, name: form.name.trim() || null, api_key: form.api_key || null, }), }); this._closeModal(); await this._load(); } catch (e) { this._error = e.message; } } async _deleteGlobal(row) { if (!confirm(`Disable global connector "${row.name}"?\n\nIt stops for everyone who can use it.`)) 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(); await this._load(); } catch (e) { this._error = e.message; } } // ── Render ───────────────────────────────────────────────────────────────── render() { if (!this._open) return nothing; const loading = this._available === null && !this._error; return html`

Connectors

${this._isAdmin ? html` ` : nothing}
${this._error && !this._modal ? html`
${this._error}
` : nothing} ${loading ? html`
Loading…
` : html`
${this._renderMine()} ${this._renderGlobals()} ${this._renderAvailable()}
`}
${this._renderModal()}`; } _section(title, icon, right, body) { return html`

${title}

${right ?? nothing}
${body}
`; } _renderMine() { const rows = this._activated ?? []; return this._section('My connectors', 'bi-check2-circle', nothing, rows.length === 0 ? html`

No per-user connectors activated.

` : html` ${rows.map(r => html` `)}
NameTypeFrom catalog
${r.name} ${r.source === 'local_script' ? 'local script' : 'remote'} ${r.catalog_name ? html`${r.catalog_name}` : html``}
`); } _renderGlobals() { const rows = this._available?.globals ?? []; if (rows.length === 0 && !this._isAdmin) return nothing; return this._section('Global connectors', 'bi-globe', nothing, rows.length === 0 ? html`

None enabled. Enable one from Available below.

` : html` ${this._isAdmin ? html`
Shared by the household. You see every one so you can manage it — yours marks the ones granted to you.
` : nothing} ${rows.map(g => html` `)}
NameTransportStatus
${g.friendly_name || g.name} ${this._isAdmin && g.can_use ? html` yours` : nothing} ${g.description ? html`
${g.description}
` : nothing}
${g.transport} ${g.enabled ? html`on` : html`off`}
${this._isAdmin ? html` ` : nothing}
`); } _renderAvailable() { const entries = this._available?.catalog ?? []; const enabledGlobals = new Set((this._available?.globals ?? []).map(g => g.catalog_name ?? g.name)); const activatedNames = new Set((this._activated ?? []).map(r => r.catalog_name)); const right = this._isAdmin ? html` ` : nothing; if (entries.length === 0) { return this._section('Available', 'bi-plus-square', right, html`

${this._isAdmin ? 'The catalog is empty.' : 'Nothing available to you yet.'}

${this._isAdmin ? html`

Add connectors to the catalog first.

` : nothing}
`); } return this._section('Available', 'bi-plus-square', right, html` ${entries.map(e => { const isGlobal = e.scope === 'global'; const already = isGlobal ? enabledGlobals.has(e.name) : activatedNames.has(e.name); return html` `; })}
ConnectorScopeAuth
${e.friendly_name || e.name} ${e.description ? html`
${e.description}
` : nothing}
${isGlobal ? 'global' : 'per-user'} ${e.auth_kind}
${already ? html`${isGlobal ? 'enabled' : 'active'}` : isGlobal ? html`` : html``}
`); } // ── Modals ───────────────────────────────────────────────────────────────── _modalShell(title, icon, body, onSave, saveLabel) { return html`
{ if (e.target.classList.contains('um-modal-overlay')) this._closeModal(); }}>
${title}
${this._error ? html`
${this._error}
` : nothing} ${body}
`; } _field(label, value, oninput, opts = {}) { return html`
`; } _renderModal() { if (!this._modal) return nothing; const m = this._modal; if (m.kind === 'activate') { const f = m.form; const schema = parseJson(m.entry.config_schema_json, []) || []; return this._modalShell(`Activate ${m.entry.friendly_name || m.entry.name}`, 'bi-plug', html` ${this._field('Name', f.name, e => this._patch('name', e.target.value), { hint: 'unique for you', mono: true })} ${m.entry.auth_kind === 'api_key' ? this._field('API key', f.api_key, e => this._patch('api_key', e.target.value), { type: 'password', mono: true }) : nothing} ${m.entry.auth_kind === 'oauth' ? html`
This connector needs an interactive login, which is not wired up yet — it will activate but cannot authenticate.
` : nothing} ${schema.map(k => html`
this._patch('env', { ...f.env, [k]: e.target.value })} />
`)} `, () => this._activate(), 'Activate'); } if (m.kind === 'global') { const f = m.form; return this._modalShell(`Enable ${m.entry.friendly_name || m.entry.name} globally`, 'bi-globe', html`
Runs once for the household on the host. Nobody reaches it until you grant access.
${this._field('Name', f.name, e => this._patch('name', e.target.value), { hint: 'runtime name', mono: true })} ${m.entry.auth_kind === 'api_key' ? this._field('API key', f.api_key, e => this._patch('api_key', e.target.value), { type: 'password', mono: true }) : nothing} `, () => this._enableGlobal(), 'Enable'); } if (m.kind === 'access') { const users = this._users ?? []; return this._modalShell(`Access — ${m.server.name}`, 'bi-people', html`
Select who may use this global connector. This replaces the current list.
${users.map(u => html`
this._toggleAccess(u.id)} />
`)} `, () => this._saveAccess(), 'Save access'); } return nothing; } }