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
@@ -375,6 +375,7 @@ class McpServer(TimestampMixin, Base):
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
oauth_config = Column(Text, nullable=True) # JSON: provider, keys_file, token_file, scopes
|
||||
disabled_tools = Column(Text, nullable=True) # JSON array of tool names to hide from LLM
|
||||
oauth_tokens = Column(EncryptedText, nullable=True) # JSON {tokens, client_info} for generic MCP OAuth, encrypted at rest
|
||||
|
||||
|
||||
class Comparison(TimestampMixin, Base):
|
||||
@@ -1311,6 +1312,23 @@ def _migrate_add_disabled_tools():
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(f"disabled_tools migration: {e}")
|
||||
|
||||
def _migrate_add_mcp_oauth_tokens_column():
|
||||
"""Add oauth_tokens column to mcp_servers table if missing.
|
||||
|
||||
The model declares this column as EncryptedText, but the SQL type is plain
|
||||
TEXT on purpose: EncryptedText is a SQLAlchemy TypeDecorator that encrypts at
|
||||
the Python layer and stores the ciphertext as TEXT, so the DB column type is
|
||||
TEXT. This matches the existing encrypted columns (see _migrate_encrypt_*)."""
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(mcp_servers)"))]
|
||||
if "oauth_tokens" not in cols:
|
||||
conn.execute(text("ALTER TABLE mcp_servers ADD COLUMN oauth_tokens TEXT"))
|
||||
conn.commit()
|
||||
logging.getLogger(__name__).info("Added oauth_tokens column to mcp_servers")
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(f"oauth_tokens migration: {e}")
|
||||
|
||||
def _migrate_add_task_v2_columns():
|
||||
"""Add cron_expression, then_task_id, webhook_token to scheduled_tasks."""
|
||||
new_cols = {
|
||||
@@ -1589,6 +1607,7 @@ def init_db():
|
||||
_migrate_add_oauth_config()
|
||||
_migrate_add_task_automation_columns()
|
||||
_migrate_add_disabled_tools()
|
||||
_migrate_add_mcp_oauth_tokens_column()
|
||||
_migrate_add_task_v2_columns()
|
||||
_migrate_add_notifications_enabled()
|
||||
_migrate_drop_ping_notes_tasks()
|
||||
|
||||
Reference in New Issue
Block a user