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`
No per-user connectors activated.
| Name | Type | From catalog | |
|---|---|---|---|
| ${r.name} | ${r.source === 'local_script' ? 'local script' : 'remote'} | ${r.catalog_name ? html`${r.catalog_name}` : html`—`} |
None enabled. Enable one from Available below.
| Name | Transport | Status | |
|---|---|---|---|
| ${g.friendly_name || g.name} ${this._isAdmin && g.can_use ? html` yours` : nothing} ${g.description ? html` ` : nothing} | ${g.transport} | ${g.enabled ? html`on` : html`off`} |
${this._isAdmin ? html`
` : nothing}
|
${this._isAdmin ? 'The catalog is empty.' : 'Nothing available to you yet.'}
${this._isAdmin ? html`Add connectors to the catalog first.
` : nothing}| Connector | Scope | Auth | |
|---|---|---|---|
| ${e.friendly_name || e.name} ${e.description ? html` ` : nothing} | ${isGlobal ? 'global' : 'per-user'} | ${e.auth_kind} |
${already
? html`${isGlobal ? 'enabled' : 'active'}`
: isGlobal
? html``
: html``}
|