import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; import { connectorIconUrl, statusOf, STATUS_LABEL } from './shared/connector-common.js'; // Connectors (MCP) — blueprint §7/§14/§15. // // **One row per connector**, not one per runtime instance. A catalog entry is a // template with two runtimes (§7), and a person thinks in terms of "do I have // Gmail?" — not "how many `mcp_user_servers` rows named gmail-ish do I own?". So the // old three-section split (Mine / Global / Available) is gone: the same connector // used to appear twice, once as a template and once as its instance, and the reader // had to join the two by eye. Here each connector appears exactly once, and its // state is a chip on the card. // // The card is a link, not a form. Everything that needs typing lives on the // connector's own page (`#connector?name=X`) — an activation form has as many // fields as the connector declares (EMAIL has a dozen), which a fixed-size dialog // could never hold. // // Reuses the marketplace's card styling (`web/css/connectors.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 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 _error: { state: true }, _q: { state: true }, _noIcon: { state: true }, // names whose icon failed to load _providers: { state: true }, // admin: OAuth provider list (modal) _pForm: { state: true }, // admin: provider being edited, or null _pError: { state: true }, }; } constructor() { super(); this._open = false; this._q = ''; this._noIcon = new Set(); this._reset(); } _reset() { this._me = null; this._available = null; this._activated = null; this._error = null; this._providers = null; this._pForm = null; this._pError = 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(); }); // Coming back from a connector's page must show its new state, not the state // captured before the user activated it. window.addEventListener('connectors-changed', () => { 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; } catch (e) { this._error = e.message; } } _go(page, hash) { history.pushState({ page }, '', hash); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page } })); } _openConnector(name) { this._go('connector', `#connector?name=${encodeURIComponent(name)}`); } // ── admin: OAuth sign-in providers (§15) ───────────────────────────────────── async _openProviders() { this._pError = null; this._pForm = null; try { this._providers = await jf('/api/mcp/providers'); } catch (e) { this._pError = e.message; this._providers = []; } } _closeProviders() { this._providers = null; this._pForm = null; this._pError = null; } _blankProvider() { return { name: '', display_name: '', auth_url: '', token_url: '', client_id: '', client_secret: '', redirect_uri: '', extra_params: '' }; } /// A Google preset — fills everything but the client_id/secret the admin pastes /// from their Google Cloud console. `prompt=consent` + `access_type=offline` are /// what make Google return a refresh token (§15). _presetGoogle() { this._pError = null; this._pForm = { name: 'google', display_name: 'Google', auth_url: 'https://accounts.google.com/o/oauth2/v2/auth', token_url: 'https://oauth2.googleapis.com/token', client_id: '', client_secret: '', redirect_uri: 'https://connectors.skaldagent.net/oauth/show.html', extra_params: '{"access_type":"offline","prompt":"consent"}', _isNew: true, }; } _editProvider(p) { // The secret never came back from the server; an empty box means "keep it". this._pForm = { ...p, client_secret: '', extra_params: p.extra_params || '', _isNew: false }; this._pError = null; } _patchProvider(key, value) { this._pForm = { ...this._pForm, [key]: value }; } async _saveProvider() { const f = this._pForm; if (!f.name.trim() || !f.client_id.trim()) { this._pError = 'Name and client id are required.'; return; } if (f._isNew && !f.client_secret.trim()) { this._pError = 'A client secret is required for a new provider.'; return; } this._pError = null; try { const { _isNew, has_client_secret, ...body } = f; await jf('/api/mcp/providers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); this._pForm = null; this._providers = await jf('/api/mcp/providers'); } catch (e) { this._pError = e.message; } } async _deleteProvider(name) { if (!confirm(`Delete the “${name}” sign-in provider?\n\nConnectors that use it will no longer be able to sign in.`)) return; try { await jf(`/api/mcp/providers/${encodeURIComponent(name)}`, { method: 'DELETE' }); this._providers = await jf('/api/mcp/providers'); } catch (e) { this._pError = e.message; } } /// The merged view: every connector the caller can see, exactly once, carrying /// whichever runtime rows exist for it. get _rows() { const catalog = this._available?.catalog ?? []; const globals = this._available?.globals ?? []; const activated = this._activated ?? []; const rows = catalog.map(e => ({ ...e, _act: activated.find(r => r.catalog_name === e.name) ?? null, _glob: globals.find(g => (g.catalog_name ?? g.name) === e.name) ?? null, })); // A granted global whose catalog row the caller cannot see. `/api/mcp/available` // only returns `global` catalog entries to a catalog manager, so without this the // connector an ordinary user actually uses every day would be missing from their // own list — visible to the admin, invisible to its user. for (const g of globals) { const key = g.catalog_name ?? g.name; if (rows.some(r => r.name === key)) continue; rows.push({ name: key, friendly_name: g.friendly_name, description: g.description, scope: 'global', source: 'remote', auth_kind: 'none', _act: null, _glob: g, }); } const q = this._q.trim().toLowerCase(); return rows .filter(r => !q || r.name.toLowerCase().includes(q) || (r.friendly_name ?? '').toLowerCase().includes(q) || (r.description ?? '').toLowerCase().includes(q)) .sort((a, b) => (a.friendly_name || a.name).localeCompare(b.friendly_name || b.name)); } _iconFailed(name) { // Re-render with the placeholder. A synthetic row (a granted global whose // catalog entry the caller cannot read) has no icon path to check up front, so // the 404 is the check. const next = new Set(this._noIcon); next.add(name); this._noIcon = next; } // ── Render ───────────────────────────────────────────────────────────────── render() { if (!this._open) return nothing; const loading = this._available === null && !this._error; const rows = loading ? [] : this._rows; return html`
No sign-in providers yet.
${p.name}No connector matches “${this._q}”.
${this._isAdmin ? 'No connectors installed yet.' : 'Nothing available to you yet.'}
${this._isAdmin ? html`Install one from the Marketplace to get started.
` : html`Ask an admin to make one available.
`}