feat(robinhood): wire agentic-trading OAuth connect flow end-to-end#11
feat(robinhood): wire agentic-trading OAuth connect flow end-to-end#11boostbar9 wants to merge 9 commits into
Conversation
Implements the full OAuth 2.1 + PKCE flow for the Robinhood agentic trading MCP (agent.robinhood.com/mcp/trading) so the cockpit "Connect your agent" onboarding works once the beta is enabled. Auth wiring only - SHADOW mode and the $300 float cap remain the safe defaults; this never flips the user to live trading. robinhood_token.py: - Dynamic discovery (RFC 9728 -> 8414 -> OIDC well-known fallback) - Dynamic client registration (RFC 7591) + ROBINHOOD_OAUTH_CLIENT_ID env fallback; client_id persisted in keyring - build_authorize_url (PKCE S256 + RFC 8707 resource indicator), exchange_code, refresh_access_token (preserves non-rotated refresh) robinhood.py: - Loopback PKCE browser flow: begin_auth / complete_auth (CSRF state check) / pending_auth / disconnect / is_connected - _require_token auto-refreshes stale tokens via _refresh_or_die robinhood_access.py: - Real _check_granted_via_token: authenticated MCP initialize+list_tools handshake; granted on success, waitlist on 401/403, fall-through on ambiguous errors cockpit/web/server.py: - New routes: POST connect, GET /callback (loopback handler), POST finish (manual paste fallback), POST disconnect, GET status Tests: 27 new (OAuth helpers, browser flow, granted-detector); all Robinhood + cockpit suites green (946 passed, 2 skipped).
There was a problem hiding this comment.
Pull request overview
This PR wires the Robinhood agentic-trading OAuth “Connect your agent” flow end-to-end across the execution layer (token discovery/refresh + PKCE browser flow) and cockpit (onboarding endpoints + granted detection), enabling the cockpit onboarding UX to authorize MCP sessions without changing execution mode defaults.
Changes:
- Added OAuth 2.1 + PKCE support with dynamic endpoint discovery (well-known metadata), dynamic client registration, authorize URL construction, code exchange, refresh, and keyring persistence for both tokens and
client_id. - Implemented a loopback browser flow in the Robinhood execution broker (
begin_auth/complete_auth/disconnect/is_connected) and automatic refresh of stale access tokens. - Added cockpit onboarding routes for connect/callback/finish/disconnect/status and implemented a real “granted via token” detector using an authenticated MCP handshake, with new tests for OAuth wiring and granted detection.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/execution/tests/test_robinhood_oauth.py | New hermetic tests covering discovery, registration, PKCE URL building, code exchange, refresh behavior, and broker auth flow. |
| packages/execution/robinhood.py | Adds PKCE loopback browser flow helpers and auto-refreshes stale tokens in _require_token. |
| packages/execution/robinhood_token.py | Introduces discovery/registration/token exchange/refresh helpers plus client_id keyring persistence. |
| packages/cockpit/web/server.py | Adds onboarding API endpoints for Robinhood OAuth connect/callback/finish/disconnect/status. |
| packages/cockpit/tests/test_robinhood_granted.py | New tests for the implemented “granted detector” behavior under success/403/500/refresh-failure cases. |
| packages/cockpit/robinhood_access.py | Implements _check_granted_via_token using broker token resolution + MCP initialize/list_tools handshake. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def _get_json(client: httpx.Client, url: str) -> dict[str, Any] | None: | ||
| """GET a well-known doc; return parsed JSON or ``None`` on any miss. | ||
|
|
||
| We tolerate 404s (a given well-known path may not exist on this | ||
| server) by returning ``None`` so the caller can try the next | ||
| candidate. Network/transport errors raise -- the user is mid-flow | ||
| and deserves a real error, not a silent fallback. | ||
| """ | ||
| try: | ||
| r = client.get(url, headers={"Accept": "application/json"}) | ||
| except httpx.HTTPError as exc: | ||
| raise OAuthError(f"discovery transport error for {url}: {exc!r}") from exc | ||
| if r.status_code == 404: | ||
| return None | ||
| if r.status_code >= 400: | ||
| return None | ||
| try: | ||
| data = r.json() | ||
| return data if isinstance(data, dict) else None | ||
| except ValueError: | ||
| return None |
| refresh = str(data.get("refresh_token", "")) | ||
| expires_in = float(data.get("expires_in", 3600)) |
| def _rh_callback_page(*, ok: bool, msg: str) -> str: | ||
| """Minimal standalone HTML for the OAuth callback landing page.""" | ||
| color = "#16a34a" if ok else "#dc2626" | ||
| icon = "✓" if ok else "✗" | ||
| title = "Connected" if ok else "Connection failed" | ||
| return ( | ||
| "<!doctype html><html><head><meta charset='utf-8'>" | ||
| "<meta name='viewport' content='width=device-width,initial-scale=1'>" | ||
| f"<title>Robinhood · {title}</title></head>" | ||
| "<body style='font-family:system-ui,sans-serif;background:#0b0b0f;" | ||
| "color:#e5e7eb;display:flex;min-height:100vh;align-items:center;" | ||
| "justify-content:center;margin:0'>" | ||
| "<div style='max-width:420px;text-align:center;padding:32px'>" | ||
| f"<div style='font-size:48px;color:{color}'>{icon}</div>" | ||
| f"<h1 style='font-size:20px;margin:12px 0 8px'>{title}</h1>" | ||
| f"<p style='color:#9ca3af;line-height:1.5'>{msg}</p>" | ||
| "</div></body></html>" | ||
| ) |
| # Mark onboarding status granted on a successful connect (the token | ||
| # check on next probe will confirm; this gives instant UI feedback). | ||
| try: | ||
| from packages.cockpit.onboarding import ( | ||
| load_onboarding, | ||
| save_onboarding, | ||
| ) | ||
|
|
||
| st = load_onboarding() | ||
| st.robinhood_status = "granted" # type: ignore[assignment] | ||
| save_onboarding(st) | ||
| except Exception: # pragma: no cover - UI nicety, never fatal | ||
| pass |
| try: | ||
| from packages.cockpit.onboarding import ( | ||
| load_onboarding, | ||
| save_onboarding, | ||
| ) | ||
|
|
||
| st = load_onboarding() | ||
| st.robinhood_status = "granted" # type: ignore[assignment] | ||
| save_onboarding(st) | ||
| except Exception: # pragma: no cover | ||
| pass |
| @app.post("/api/onboarding/robinhood/connect") | ||
| def api_rh_connect() -> dict[str, Any]: | ||
| """Start the Robinhood OAuth flow and return the authorize URL. | ||
|
|
||
| The cockpit opens this URL in the user's browser; Robinhood prompts | ||
| them to open/confirm their Agentic account and approve, then redirects | ||
| back to our loopback callback. Returns ``ok=False`` with a message on | ||
| discovery/registration failure so the UI shows a clear error instead | ||
| of a dead button. | ||
| """ | ||
| from packages.execution.robinhood import begin_auth | ||
|
|
||
| try: | ||
| pending = begin_auth() | ||
| except BrokerError as exc: | ||
| return {"ok": False, "error": str(exc)} | ||
| return { | ||
| "ok": True, | ||
| "authorize_url": pending.authorize_url, | ||
| "redirect_uri": pending.redirect_uri, | ||
| "state": pending.state, | ||
| } |
Verified our integration against the live agent.robinhood.com server and Robinhood's official agentic-trading docs, then fixed two contract mismatches and built the actual cockpit connect UI. robinhood_token.py: - Scope: 'trade read' -> 'internal'. The live authorization-server metadata advertises only scopes_supported=['internal']; requesting 'trade read' would be rejected. Per-tool trade/read permissions are approved in Robinhood's own consent screen, not via OAuth scopes. - Discovery: build the RFC 8414 *path-inserted* well-known URL (/.well-known/oauth-authorization-server/mcp/trading) instead of the path-suffixed form (/mcp/trading/.well-known/...) which 404s in prod. New _well_known_candidates() probes RFC 8414 + OIDC + root variants; first responder wins. Verified end-to-end: discovery now resolves authorize=https://robinhood.com/oauth, token=https://api.robinhood.com/oauth2/token/, register=https://agent.robinhood.com/oauth/trading/register, and dynamic registration issues a real client_id. welcome.html (onboarding UI): - Added the real 'Connect your agent' flow: calls /robinhood/connect, opens Robinhood's OAuth in a new tab, polls /robinhood/status until connected, with live progress + a pulsing indicator. - Manual-paste fallback (collapsible) posts to /robinhood/finish for networks where the loopback redirect can't return. - Disconnect button (shown only when connected) -> /robinhood/disconnect. - status check now uses /robinhood/status (real token check) before falling back to the discovery probe. tests: +2 regression tests pinning the live RFC 8414 layout and the 'internal' scope. 948 passed, 2 skipped; ruff clean on changed files.
Verified against the live Robinhood server + official docsProbed
End-to-end discovery + dynamic registration verified live:
Real Connect UIThe onboarding wizard previously only had a waitlist/skip step. Added the actual "Connect your agent" flow that drives the new endpoints: opens Robinhood OAuth in a new tab, shows live "Waiting for Robinhood…" progress, polls Still SHADOW-mode + $300 cap by default; this only wires auth. |
…l MCP tool names - Add RobinhoodAgenticBroker.account_snapshot(): read-only buying power / cash / total equity / positions via the real agentic-trading read tools (get_accounts, portfolio, get_equity_positions). Always read-only, safe in shadow mode; never raises. - Fix latent tool-name bug: list_positions -> get_equity_positions and submit_order -> place_equity_order (the real Robinhood MCP tool names). - Add tolerant MCP content-block normalizers (_unwrap_content / _normalize_rows / _normalize_obj / _first_float) so payloads parse whether the server frames them as raw dicts, content-block text, or json blocks. - Add robinhood_account_snapshot() module wrapper + broker.aclose(). - Cockpit: GET /api/onboarding/robinhood/snapshot (refresh|cached), in-memory cache warmed on the autonomy quote-warmup tick, surfaced under the 'robinhood' key of /api/trading/unified-snapshot so the AI agent gets live account context. - 20 new tests (snapshot read-only safety, content-block parsing, partial-failure degradation, endpoint refresh/cached, market-context surfacing). Full execution+cockpit suites: 968 passed, 2 skipped.
Live account data for the AI (read-only) + real MCP tool namesThis adds the live Robinhood data layer so the AI has real account context when reasoning about the market, and fixes a latent tool-name mismatch. Nothing here can place, review, or cancel an order — it is strictly read-only and safe even while the bot runs in SHADOW mode. The $300 float cap and SHADOW default are untouched. What's new
Live market data (already wired in
|
…ession-Id echo, Accept header, notifications/initialized, SSE parsing) Brings RobinhoodMcpClient into compliance with the MCP Streamable HTTP transport (2025-06-18 spec) so the live agent.robinhood.com server accepts our requests: - Send Accept: application/json, text/event-stream on every POST (servers return 406 without it). - Capture Mcp-Session-Id from the initialize response and echo it on all follow-up requests (session-enforcing servers reject calls without it). - Send the required notifications/initialized notification after initialize, carrying the session id; tolerate the 202/empty ack. - Parse text/event-stream response bodies (concatenating data: lines) in addition to application/json. - Update stale tool-name examples in docstrings to the real Robinhood names. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Streamable HTTP transport fix for
|
…-notional ledger, live gate, configurable cap P0-1 Deterministic order idempotency: replace fresh uuid4 client_order_id with sha256(decision_id,symbol,side,qty,bar_ts); uuid4 only when no identity. Wired into Alpaca + Robinhood submit paths. P0-3 Partial-fill reconciliation: bounded read-only polling (reconcile_fill_via_poll) comparing filled vs intended qty; surfaces mismatch via structured warning, never places new orders. P0-4 Cumulative daily-notional ledger: per-calendar-day JSONL buy-notional ledger; live buys rejected when aggregate exceeds cap. Shadow buys recorded, never blocked. Sells never cap-checked. P0-5 Route Robinhood live through resolve_mode gate: live requires ENABLE_LIVE_TRADING + passed promotion gate; fails safe to shadow. FEATURE Configurable float cap: GET/POST /api/onboarding/robinhood/cap, clamped to [0, 10000], default $300; welcome.html UI input with hard-max hint + clamp. P2-12 Delete dead live_quotes.build_finnhub_fetcher. Tests: +test_daily_notional, +test_idempotency_reconcile, +test_robinhood_cap; full suite 1012 passed, 2 skipped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Architecture-review fixes (P0/P1/P2) — commit
|
…_order (was guessed client_order_id) Robinhood's live-confirmed place_equity_order schema names the idempotency parameter `ref_id` (optional UUID), not the previously guessed `client_order_id` that carried a TODO(robinhood-api) note. Rename the MCP argument key to `ref_id` and drop the TODO. The value stays our deterministic key so a retried logical order re-sends the same id and the gateway dedupes -- but since deterministic_client_order_id emits a sha256-derived `rh-<hex>` string (not a UUID), we fold it through uuid5 over a fixed namespace to get a value that is BOTH deterministic AND valid UUID format, as the schema requires. Alpaca's client_order_id (broker.py) is intentionally left unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Correction: confirmed Robinhood idempotency key is
|
…oop (shadow-default, gated, agentic-account targeting) Introduce a single broker-selection seam (resolve_active_broker / resolve_broker_selection in packages/execution/broker_factory.py) so the autonomy loop trades through whichever backend the user selected. Default, unset, unknown, error, and Robinhood-selected-but-not-ready all fail safe to the existing Alpaca paper broker -- current behavior and tests unchanged. Selecting Robinhood (OnboardingState.broker_backend="robinhood", env override BROKER_BACKEND) does NOT enable live trading: the broker still runs in SHADOW unless ENABLE_LIVE_TRADING + the resolve_mode promotion gate authorize live, identical to Alpaca. The Robinhood live buy path flows through the full safety stack (configurable cap, daily-notional ledger, deterministic ref_id, fill reconciliation). Add agentic-account targeting: discover the single agentic_allowed=true account via get_accounts, persist its number in onboarding, and thread it into every Robinhood MCP tool call (reads + orders). The live order path refuses to submit without a resolved agentic account (fail safe); the number is never hardcoded in source. Surface the active broker + safety posture (backend, shadow/live, cap, masked agentic account ••••NNNN) on /api/onboarding/robinhood/status and a new /api/onboarding/broker-backend select endpoint, plus a select-account discovery endpoint. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Robinhood is now a selectable broker in the autonomy loop (shadow-default, gated)Commit Broker-selection seam
Fail-safe fallback (never crashes the loop, never silently trades nowhere)Default / unset / unknown backend / corrupt config / Robinhood-not-connected / no-agentic-account / broker-build-failure → all resolve to the existing Alpaca paper broker. The status surface reports Agentic-account auto-targeting
All safety gates still apply on the Robinhood live pathSelecting Status surfacing (read-only)
Defaults unchangedSHADOW stays the default; the default broker selection remains Alpaca paper. No default that enables live trading was changed. Tests / verification
|
…7 manual fixes), behavior-preserving Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ruff lint cleanup — CI Lint gate is now green ✅Pushed Lint resultStarting point was 159 ruff errors (ruff 0.15.17). 116 cleared via safe Counts by rule (start): UP045 33, F401 25, I001 16, RUF059 11, RUF100 8, F841 6, N806 6, SIM105 6, C420 4, RUF022 4, UP037 4, B904 3, RUF003 3, C408 2, E402 2, RUF001 2, SIM108 2, SIM117 2, SIM300 2, UP017 2, UP035 2, + 1 each: B007 B017 B905 C416 F821 N803 RUF046 RUF102 SIM103 SIM222 UP012 UP030 UP032 UP041. Notable manual fixes: F821 ( Behavior-preserving — proven. Ran the full suite on the pristine base CI status (run 27649250186)
The Lint blocker this PR was stuck on is resolved. Making the entire Python 3.12 job green additionally requires fixing those pre-existing test failures, which is a separate effort. |
… broker factory
Dashboard now shows Robinhood connection state and a plain-language
go-live checklist, with an easy/obvious LIVE<->SHADOW toggle (typed-confirm
to arm live) and a side-by-side Alpaca-paper vs Robinhood pipeline view.
Backend:
* GET /api/onboarding/robinhood/readiness -- ordered checklist + overall
ready + single next step.
* POST /api/onboarding/robinhood/mode -- shadow always allowed; live
requires confirm=true AND a passed readiness check.
* unified snapshot now carries active_broker status.
Load-bearing: tools/paper_trade.run() now submits orders through
broker_factory.resolve_broker_selection() (account/positions/risk/brackets
still read from the Alpaca paper broker). Fail-safe: any selection error
falls back to Alpaca paper. SHADOW stays the default; no gate is weakened.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Robinhood Control Center + runner routing (commit e029310)What landedBackend
Load-bearing change — Frontend ( Safety preserved
Verification
CI on this push
|
Summary
Wires the Robinhood agentic-trading OAuth connect flow end-to-end so the cockpit "Connect your agent" onboarding works once the beta is enabled at
agent.robinhood.com/mcp/trading.This is auth wiring only. SHADOW mode and the $300 float cap remain the safe defaults — nothing here flips the user to live trading. Connecting the agent simply authorizes the MCP session; trade execution mode is unchanged.
What changed
packages/execution/robinhood_token.py/.well-known/oauth-protected-resource) → RFC 8414 → OIDC well-known fallback. No hardcoded endpoints, so it survives contract changes.ROBINHOOD_OAUTH_CLIENT_IDenv fallback;client_idpersisted in keyring.build_authorize_url(PKCE S256 + RFC 8707 resource indicator),exchange_code,refresh_access_token(preserves the old refresh token when the server doesn't rotate it).packages/execution/robinhood.pybegin_auth/complete_auth(CSRF state check) /pending_auth/disconnect/is_connected._require_tokennow auto-refreshes stale tokens via_refresh_or_die.packages/cockpit/robinhood_access.py_check_granted_via_token(was a stub): runs an authenticated MCPinitialize+list_toolshandshake →grantedon success,waitliston 401/403, fall-through on ambiguous errors.packages/cockpit/web/server.pyPOST .../robinhood/connect,GET /callback(loopback redirect handler),POST .../robinhood/finish(manual code/state paste fallback for environments that reject loopback redirects),POST .../robinhood/disconnect,GET .../robinhood/status.Tests
test_robinhood_oauth.py,test_robinhood_granted.py) covering discovery variants, registration, authorize URL, code exchange, refresh rotation, client_id persistence, browser flow, CSRF state mismatch, disconnect, and the granted-detector.ruff checkclean on all changed files (only pre-existing baseline warnings remain).