3 Commits
Author SHA1 Message Date
Léo 2e2bb5231e fix(mcp): stop assuming http://localhost:7000 for the OAuth callback (#6032)
* fix(mcp): stop assuming http://localhost:7000 for the OAuth callback

The MCP OAuth callback origin is wrong on any install not reached at
http://localhost:7000, and on Docker it cannot be corrected at all.
Three sites, one assumption:

- The redirect base fell back to a fixed port 7000. The app binds APP_PORT
  natively (app.py, launcher.py) and the macOS launcher defaults to 7860,
  where 7000 is AirPlay Receiver, so the callback lands on another service
  entirely. The fallback now follows APP_PORT. The hostname stays localhost
  rather than internal_api_base()'s 127.0.0.1: this URI is registered with
  the authorization server, so changing the host would invalidate the
  registrations that already exist.

- The paste-back form hardcoded an http:// action. Serving the page over
  HTTPS, Chrome raises its insecure-form interstitial, and overriding that
  posts plain HTTP at a TLS port, which fails too. Either way the
  authorization code never reaches Odysseus. The action now carries the
  scheme the request arrived on.

- OAUTH_REDIRECT_BASE_URL is the only fix available to a Docker install,
  because the container listens on 7000 and cannot see the host port map,
  but compose never forwarded it and nothing documented it. Both fixed.

* fix(mcp): make the paste-back form action relative and export APP_PORT

Answers the review on #6032. Three of the fixes did not survive contact with
the deployments they targeted.

- The form action derived its scheme from request.url.scheme. uvicorn only
  honours X-Forwarded-Proto from a peer inside --forwarded-allow-ips, which
  defaults to 127.0.0.1; the Dockerfile CMD sets no override, so a proxy
  arriving over the Docker bridge is untrusted and the scheme stays http.
  That is mixed content on exactly the HTTPS installs paste-back exists for.
  A relative action is resolved by the browser against the origin the page
  came from, which is right under every proxy setup, and it drops the Host
  header from the page entirely.

- The APP_PORT fallback never fired for the shipped launchers. start-macos.sh,
  the generated .app launcher and launch-windows.ps1 all pass --port to
  uvicorn without putting the value in the environment, so the motivating
  case, macOS on 7860, still registered localhost:7000. Each now exports it.
  internal_api_base() and companion pairing read APP_PORT too and were wrong
  in the same way.

- .env.example pointed Google MCP servers at OAUTH_REDIRECT_BASE_URL.
  add_server writes Desktop App credentials, and Google only accepts loopback
  redirects for that client type, so a public origin comes back as
  redirect_uri_mismatch. The variable is for the DCR flow; Google stays on the
  loopback default and finishes remotely through paste-back.

The Host header is no longer reflected into the page, so the escaping
regression test asserts its absence instead of its escaping.
2026-08-15 23:09:01 -06:00
jagadish-zentiti 99f0facc2c fix(mcp): guard DbTokenStorage against non-dict oauth_tokens JSON (#5107)
_load() returned whatever json.loads() produced without checking it was a
dict; _update() did the same before assigning data[key] = value. If the
oauth_tokens column ever held a JSON array or primitive (DB corruption,
manual edit, migration drift), _load()'s callers crashed with
AttributeError on .get(), and _update() crashed with TypeError trying to
item-assign into a list/string/int.

Validate the parsed value is a dict in both methods, falling back to {}
otherwise - same recovery behavior already used elsewhere in the codebase
for this exact JSON-blob-is-not-a-dict shape (_parse_tool_args,
_is_sensitive_path's siblings).

Adds 3 regression tests for _load, get_tokens, and _update against a
non-dict oauth_tokens value.

Fixes #5082
2026-07-11 05:26:23 +01:00
Abylaikhan Zulbukharov 5d62be4470 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:
2026-06-05 02:40:52 +02:00