From f068d9b733533197f6d4ef9914ae066114dc4f75 Mon Sep 17 00:00:00 2001 From: Mickael Farina Date: Thu, 16 Jul 2026 12:39:54 +0200 Subject: [PATCH] feat(connector): real connect/disconnect state + Keychain-persistent tokens (beat 19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Connector tab was half-built: toggling on showed a cramped inline-styled "Sign in" button jammed against the auth note, and after a SUCCESSFUL Notion sign-in the card still said "Needs OAuth sign-in" — no connected state, no way to disconnect, and the token died with the process. Root cause of the missing state: fastmcp's OAuth defaulted to IN-MEMORY token storage, so nothing persisted and nothing could be queried. - mcp_connect: hand OAuth a Keychain-backed token store (KeyringStore, service ai.avadigital.codec.mcp_tokens; 0600 disk fallback for headless/CI). Adds is_connected(name) and disconnect_server(name) built on fastmcp's own TokenStorageAdapter (get_tokens / clear) — no guessing at its key scheme. Also adds the module-level logger the new code needs. - routes/mcp: GET /api/mcp/servers now returns `connected` per ENABLED server (never leaks the token); new POST /api/mcp/servers/{name}/disconnect (audited). - dashboard: proper state machine per card — off → (toggle only) on + needs auth + not connected → "Needs OAuth sign-in" + [Sign in] on + connected → "● Connected" + [Disconnect] on + no auth → Connected Real CSS classes (.mcp-state/.mcp-btn/.mcp-connected) matching the dashboard's tokens instead of ad-hoc inline styles, on its own row so nothing crowds the auth note. Toggling on re-renders immediately so Sign in appears at once. No emoji (CSS dot + line-SVG). Manifest regenerated. 42 mcp tests pass, ruff clean. Co-Authored-By: Claude Opus 4.8 --- codec_dashboard.html | 79 ++++++++++++++++++++++++++++++++++++------- routes/mcp.py | 65 ++++++++++++++++++++++++++++------- skills/.manifest.json | 2 +- skills/mcp_connect.py | 78 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 197 insertions(+), 27 deletions(-) diff --git a/codec_dashboard.html b/codec_dashboard.html index b5bf9c9..f254347 100644 --- a/codec_dashboard.html +++ b/codec_dashboard.html @@ -744,6 +744,18 @@

MENU

.mcp-auth{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:#d99a33;margin-top:5px} .mcp-auth svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round} .mcp-empty{font-size:12.5px;color:var(--text-muted);line-height:1.6;padding:22px 18px;text-align:center;border:1px dashed var(--border);border-radius:10px} + /* Connector state row: sits on its own line under the url so the button + never crowds the auth note (the old inline-styled button did). */ + .mcp-state{display:flex;align-items:center;gap:10px;margin-top:8px;flex-wrap:wrap} + .mcp-btn{font:inherit;font-size:12px;font-weight:600;padding:5px 14px;border-radius:6px; + border:1px solid var(--border);background:var(--surface-3);color:var(--text);cursor:pointer; + transition:border-color .12s,background .12s} + .mcp-btn:hover:not(:disabled){border-color:var(--border-hover)} + .mcp-btn:disabled{opacity:.55;cursor:default} + .mcp-btn-primary{background:var(--accent);border-color:var(--accent);color:#fff} + .mcp-btn-primary:hover:not(:disabled){filter:brightness(1.08)} + .mcp-connected{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;font-weight:600;color:#3fa45b} + .mcp-connected .dot{width:7px;height:7px;border-radius:50%;background:currentColor;flex:none} .mcp-switch{position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0} .mcp-switch input{opacity:0;width:0;height:0} .mcp-slider{position:absolute;cursor:pointer;inset:0;background:var(--surface-3);border:1px solid var(--border);border-radius:24px;transition:.2s} @@ -1078,23 +1090,38 @@

MENU

box.innerHTML = servers.map(function(s) { var checked = s.enabled ? 'checked' : ''; var badge = escHtml((s.transport || 'http').toUpperCase()); - var authNote = ''; - if (s.needs_auth) { - var kind = s.auth === 'api_key' ? 'API key' : (s.auth === 'oauth' ? 'OAuth sign-in' : 'sign-in'); - authNote = '
Needs ' + kind + '
'; - } var note = s.note ? '
' + escHtml(s.note) + '
' : ''; - // OAuth connectors get a "Sign in" button (only when enabled) that opens - // the browser authorize flow — toggling on alone doesn't authenticate. - var signinBtn = (s.enabled && s.needs_auth && s.auth === 'oauth') ? - '' : ''; + var lock = ''; + var nm = escHtml(s.name); + // State machine, in the order a user actually experiences it: + // off → nothing (just the toggle) + // on + needs auth + !conn → why + how to fix it (Sign in) + // on + connected → Connected + a way back out (Disconnect) + // on + no auth needed → Connected (nothing to sign into) + var state = ''; + if (s.enabled && s.needs_auth && !s.connected) { + var kind = s.auth === 'api_key' ? 'API key' : 'OAuth sign-in'; + state = '
' + + '' + lock + 'Needs ' + kind + '' + + (s.auth === 'oauth' + ? '' + : '') + + '
'; + } else if (s.enabled && s.connected) { + state = '
' + + 'Connected' + + (s.auth === 'oauth' + ? '' + : '') + + '
'; + } return '
' + '
' + - '
' + escHtml(s.name) + '' + + '
' + nm + '' + '' + badge + '
' + note + '
' + escHtml(s.url || '') + '
' + - authNote + signinBtn + + state + '
' + '