feat(mcp): add Streamable HTTP transport with OAuth 2.0 (#1033)
* feat(mcp): add Streamable HTTP transport with OAuth 2.0 Odysseus could only reach MCP servers over stdio and SSE, so modern remote servers like https://mcp.higgsfield.ai/mcp (Streamable HTTP, gated behind OAuth) could not be connected. Add an `http` transport that connects via the SDK's streamablehttp_client and authenticates with the SDK's OAuthClientProvider: RFC 9728 protected-resource discovery, RFC 8414 authorization-server metadata, Dynamic Client Registration, authorization-code + PKCE, and token refresh. A small bridge (src/mcp_oauth.py) connects the SDK's blocking callback to the existing web callback route via an asyncio.Future keyed by the OAuth `state`, and the dynamic client registration plus tokens persist per-server in a new encrypted `oauth_tokens` column. The connect runs as a bounded background task so the "Add server" request returns immediately; redirect_handler publishes needs_auth + auth_url to connection state as soon as discovery/DCR completes (which can exceed the bounded wait), and the UI polls until connected. Remote users finish via the existing paste-back flow. The Google OAuth path is left unchanged. - core/database.py: encrypted oauth_tokens column + migration - src/mcp_oauth.py: OAuth provider, DB-backed TokenStorage, state registry - src/mcp_manager.py: http dispatch, background connect, _connect_http - routes/mcp_routes.py: http validation, needs_auth/auth_url, callback bridge - static/js/settings.js: Streamable HTTP option + OAuth flow with polling - tests: 5 new unit tests (transport dispatch, registry, token storage) Verified against the live Higgsfield server: discovery, DCR (client_id issued), loopback redirect accepted, and a PKCE authorization URL with needs_auth status. No regressions (full suite delta is only the 5 added passing tests). * fix(mcp): address PR #1033 review feedback - mcp_oauth: derive redirect URI from OAUTH_REDIRECT_BASE_URL/APP_PUBLIC_URL (default http://localhost:7000) instead of hardcoding the port - mcp_oauth: leave OAuth scope unset so the SDK derives it from the server's WWW-Authenticate/protected-resource metadata; hardcoding an OIDC scope broke non-OpenID MCP servers (verified: Higgsfield still gets its server-derived scope) - mcp_oauth: prune abandoned OAuth flows (_prune_stale + _pending_ts) so the module-level registries can't grow unbounded - mcp_oauth: persist tokens/client-info in a single DB session/commit (_update) instead of a load+save double round-trip - mcp_manager: cancel and drop the background connect task in disconnect_server so a deleted server stops publishing status - database: document why the oauth_tokens migration uses TEXT while the model declares EncryptedText (encryption is applied at the Python layer) - settings.js: surface persistent OAuth-poll failures and an explicit timeout message instead of silently swallowing errors - tests: cover the stale-flow pruning * static/js/settings.js now shows an in-flight loading state on the buttons that fire requests:
This commit is contained in:
committed by
GitHub
parent
85334e8f3d
commit
1d80bf5e65
@@ -4448,6 +4448,68 @@ async function initUnifiedIntegrations() {
|
||||
|
||||
// ── MCP form — full management view ──
|
||||
async function showMcpForm(editId) {
|
||||
// Toggle an in-flight loading state on a button (disabled + dimmed + label).
|
||||
function _setBtnLoading(btn, loading, label) {
|
||||
if (!btn) return;
|
||||
btn.disabled = loading;
|
||||
btn.style.opacity = loading ? '0.6' : '';
|
||||
btn.style.cursor = loading ? 'progress' : '';
|
||||
if (label != null) btn.textContent = label;
|
||||
}
|
||||
function _showMcpPasteback(id) {
|
||||
const msg = el('uf-mcp-msg'); if (!msg) return;
|
||||
if (el('uf-mcp-pasteback')) return; // already shown
|
||||
msg.innerHTML =
|
||||
'Authorize in the opened tab. If the redirect fails (remote access), paste the resulting URL here: ' +
|
||||
'<input id="uf-mcp-pasteback" class="settings-input" placeholder="http://localhost:7000/api/mcp/oauth/callback?code=..." style="margin-top:4px">' +
|
||||
'<button class="admin-btn-sm" id="uf-mcp-paste-go" style="margin-top:4px">Submit</button>';
|
||||
const pasteGo = el('uf-mcp-paste-go');
|
||||
if (pasteGo) pasteGo.addEventListener('click', async () => {
|
||||
const cb = el('uf-mcp-pasteback').value.trim();
|
||||
if (!cb) return;
|
||||
const pf = new FormData(); pf.append('callback_url', cb);
|
||||
_setBtnLoading(pasteGo, true, 'Submitting…');
|
||||
try {
|
||||
await fetch(`/api/mcp/oauth/exchange/${id}`, { method: 'POST', credentials: 'same-origin', body: pf });
|
||||
} finally {
|
||||
_setBtnLoading(pasteGo, false, 'Submit');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drives the OAuth flow: waits for the auth_url (discovery+DCR may lag),
|
||||
// opens it once, then resolves on connected/error.
|
||||
async function _handleMcpAuth(id, initialAuthUrl, tries = 90) {
|
||||
let opened = false;
|
||||
const openAuth = (u) => { if (!opened && u) { opened = true; window.open(u, '_blank', 'noopener'); _showMcpPasteback(id); } };
|
||||
openAuth(initialAuthUrl);
|
||||
const msg = el('uf-mcp-msg');
|
||||
let fails = 0;
|
||||
for (let i = 0; i < tries; i++) {
|
||||
await new Promise(res => setTimeout(res, 2000));
|
||||
try {
|
||||
const r = await fetch('/api/mcp/servers', { credentials: 'same-origin' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const list = await r.json();
|
||||
fails = 0;
|
||||
const s = Array.isArray(list) ? list.find(x => x.id === id) : null;
|
||||
if (!s) continue;
|
||||
if (s.auth_url) openAuth(s.auth_url);
|
||||
if (s.status === 'connected') {
|
||||
if (msg) msg.textContent = `Connected (${s.tool_count || 0} tools)`;
|
||||
await renderList(); return;
|
||||
}
|
||||
if (s.status === 'error') {
|
||||
if (msg) msg.textContent = `Failed: ${s.error || 'unknown'}`; return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Tolerate a single blip, but surface persistent failures instead of
|
||||
// silently polling until timeout.
|
||||
if (++fails >= 5 && msg) msg.textContent = `Status check failing (${e.message || 'network error'}) — still retrying…`;
|
||||
}
|
||||
}
|
||||
if (msg) msg.textContent = 'Authorization timed out. Reconnect from the server list to retry.';
|
||||
}
|
||||
if (editId && editId !== 'new') {
|
||||
// Show management view for existing server
|
||||
formEl.innerHTML = '<div class="admin-card" style="margin-top:8px"><span style="opacity:0.5;font-size:11px">Loading...</span></div>';
|
||||
@@ -4525,7 +4587,7 @@ async function initUnifiedIntegrations() {
|
||||
<h2 style="font-size:13px">Add MCP Server</h2>
|
||||
<div class="settings-col">
|
||||
<div class="settings-row"><label class="settings-label">Name</label><input id="uf-mcp-name" class="settings-input" placeholder="Server name"></div>
|
||||
<div class="settings-row"><label class="settings-label">Transport</label><select id="uf-mcp-transport" class="settings-input"><option value="stdio">stdio</option><option value="sse">SSE</option></select></div>
|
||||
<div class="settings-row"><label class="settings-label">Transport</label><select id="uf-mcp-transport" class="settings-input"><option value="stdio">stdio</option><option value="sse">SSE</option><option value="http">Streamable HTTP</option></select></div>
|
||||
<div id="uf-mcp-stdio-fields" style="display:flex;flex-direction:column;gap:6px;">
|
||||
<div class="settings-row"><label class="settings-label">Command</label><input id="uf-mcp-cmd" class="settings-input" placeholder="npx"></div>
|
||||
<div class="settings-row"><label class="settings-label">Args</label><input id="uf-mcp-args" class="settings-input" placeholder='["-y", "@modelcontextprotocol/server-filesystem"]'></div>
|
||||
@@ -4538,9 +4600,12 @@ async function initUnifiedIntegrations() {
|
||||
</div>
|
||||
</div>`;
|
||||
el('uf-mcp-transport').addEventListener('change', () => {
|
||||
const sse = el('uf-mcp-transport').value === 'sse';
|
||||
el('uf-mcp-stdio-fields').style.display = sse ? 'none' : 'flex';
|
||||
el('uf-mcp-sse-fields').style.display = sse ? 'flex' : 'none';
|
||||
const v = el('uf-mcp-transport').value;
|
||||
const isUrl = (v === 'sse' || v === 'http');
|
||||
el('uf-mcp-stdio-fields').style.display = isUrl ? 'none' : 'flex';
|
||||
el('uf-mcp-sse-fields').style.display = isUrl ? 'flex' : 'none';
|
||||
const urlInput = el('uf-mcp-url');
|
||||
if (urlInput) urlInput.placeholder = (v === 'http') ? 'https://mcp.example.com/mcp' : 'http://localhost:3001/sse';
|
||||
});
|
||||
el('uf-mcp-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
|
||||
el('uf-mcp-save').addEventListener('click', async () => {
|
||||
@@ -4558,14 +4623,25 @@ async function initUnifiedIntegrations() {
|
||||
} else {
|
||||
fd.append('url', el('uf-mcp-url').value);
|
||||
}
|
||||
const saveBtn = el('uf-mcp-save'), cancelBtn = el('uf-mcp-cancel');
|
||||
const _origLabel = saveBtn.textContent;
|
||||
_setBtnLoading(saveBtn, true, 'Saving…'); if (cancelBtn) cancelBtn.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/api/mcp/servers', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
if (r.ok) {
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (r.ok && data.needs_auth) {
|
||||
el('uf-mcp-msg').textContent = 'Preparing authorization…';
|
||||
_handleMcpAuth(data.id, data.auth_url);
|
||||
} else if (r.ok && (data.connected || data.status === 'connected')) {
|
||||
el('uf-mcp-msg').textContent = `Connected (${data.tool_count || 0} tools)`;
|
||||
formEl.style.display = 'none'; await renderList();
|
||||
} else if (r.ok) {
|
||||
el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList();
|
||||
} else {
|
||||
el('uf-mcp-msg').textContent = `Failed (${r.status})`;
|
||||
}
|
||||
} catch (_) { el('uf-mcp-msg').textContent = 'Failed'; }
|
||||
finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user