Skip to content

Standalone MCP consent delivery: build authorize URL + deliver login link (#317 follow-up) #332

Description

@initializ-mk

Carved out of the #317 epic. The auth-required gate (#330 / PR #331, merged) parks a grantless type: user call and can resume it; the standalone loopback callback validates state and completes the exchange. What's missing is the Issue-side of the standalone consent loop — the part that actually builds the authorize URL and prompts the user.

Scope split (2026-07): this issue is standalone only — Forge builds the authorize URL, hosts GET /mcp/oauth/callback, and delivers the login link via the A2A auth-required artifact (task status). Forge-driven Slack (channel) delivery is now #343 and layers on top (it consumes the AuthorizeURL this issue produces).

Problem

In standalone mode (no platform), a type: user MCP call with no grant parks correctly on the auth-required gate — but nothing builds/serves the authorize URL, so it just parks until timeout. Also, type: user is currently construction-rejected without a platform block (forge-core/mcp/server.go:192), so the standalone delegated path doesn't exist yet at all.

have (merged): park on ErrNoToken → [ ??? ] → GET /mcp/oauth/callback → validate state → exchange → resume
missing:       standalone type:user resolver + build authorize URL + Issue(state) + deliver via A2A artifact + CallbackCompleter

Managed mode is unaffected — the platform delivers the prompt and hosts the callback. This is Forge, standalone only (design-tool-registry.md §18.4).

Scope

  1. Standalone type: user resolver. Today type: user hard-requires platform.token_endpoint and routes to delegatedTokenSource (server.go:711). Add a standalone branch: when there is no platform block, resolve the per-subject token from a local SubjectTokenStoreGet(subject) hit → return; miss → ErrNoToken (trips the gate). The CallbackCompleter populates that store. Decide the enable signal (recommended: type: user + no platform block + grant: authorization_code).
  2. Callback host config. Standalone OAuth needs the agent reachable at a public https://<host>/mcp/oauth/callback for the IdP browser redirect — no such config is wired today. Add it (forge.yaml field and/or AGENT_URL), used to build redirect_uri.
  3. Build the authorize URL. Reuse buildAuthorizeURL (forge-core/mcp/oauth_flow.go:489) + oauth.GeneratePKCE() / oauth.GenerateState() + stateBinder.Issue(subject, server, session). Endpoints/client_id from explicit config or persisted registration (MCP OAuth Phase 1.5: automated discovery (RFC 9728/8414) + dynamic client registration (RFC 7591) #316, merged). Store the PKCE verifier keyed by state so the callback can complete the exchange (the current stateBinder holds {subject, server, session} but not the verifier — extend it).
  4. Deliver the link via the A2A auth-required artifact. Populate the parked task's auth-required status message with the authorize URL so UI/A2A clients render a clickable "Connect <server>" link. (Channel/Slack DM delivery is Forge-driven Slack delivery of MCP consent prompts (channel-adapter ConsentDeliverer) #343.)
  5. Provide the CallbackCompleter (Runner.SetCallbackCompleter): oauth.ExchangeCodeCtx(...) with the stored verifier → SubjectTokenStore.Put(subject, token, ttl) so the resumed call finds a grant.
  6. Wire it in forge-cli/cmd/run.go — call SetCallbackCompleter + SetConsentDeliverer (both currently never called). Also expose the Runner.SetAuthorizeURLProvider seam Forge-driven Slack delivery of MCP consent prompts (channel-adapter ConsentDeliverer) #343 needs, whose standalone implementation is the step-3 builder.

Already in place (build on these — all merged)

Security contract (already enforced — must honor)

  • The callback is auth-exempt (tokenless IdP redirect) — its authenticity is the state binding.
  • Issue MUST bind a non-empty session; the callback rejects empty/mismatched sessions fail-closed.
  • Never resume before the grant exists — the callback resolves the gate only after the code→token exchange succeeds.

Acceptance

  • A standalone type: user server (no platform block) is accepted at construction and parks a grantless call instead of being rejected.
  • The parked task's A2A auth-required artifact carries a clickable authorize URL for the requesting user.
  • Completing the browser flow lands on /mcp/oauth/callback, exchanges the code (with the state-bound PKCE verifier), stores the token in the in-memory SubjectTokenStore, and resumes the parked call — which now resolves a grant and proceeds.
  • The issued state binds a non-empty session; cross-session/replayed/expired callbacks are rejected (already tested).
  • No token on disk.

Out of scope

Related

Epic #317 · gate #330 · Slack delivery #343 · discovery/DCR #316 · AARM R10 #319 · authority design-tool-registry.md §18.4.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestforge-cliAffects the forge-cli command-line tool (init, run, build, mcp commands)forge-coreAffects the forge-core library (runtime, security, types, llm, mcp, auth)

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions