You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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 SubjectTokenStore — Get(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).
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.
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).
Provide the CallbackCompleter (Runner.SetCallbackCompleter): oauth.ExchangeCodeCtx(...) with the stored verifier → SubjectTokenStore.Put(subject, token, ttl) so the resumed call finds a grant.
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).
Problem
In standalone mode (no platform), a
type: userMCP 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: useris currently construction-rejected without a platform block (forge-core/mcp/server.go:192), so the standalone delegated path doesn't exist yet at all.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
type: userresolver. Todaytype: userhard-requiresplatform.token_endpointand routes todelegatedTokenSource(server.go:711). Add a standalone branch: when there is no platform block, resolve the per-subject token from a localSubjectTokenStore—Get(subject)hit → return; miss →ErrNoToken(trips the gate). TheCallbackCompleterpopulates that store. Decide the enable signal (recommended:type: user+ no platform block +grant: authorization_code).https://<host>/mcp/oauth/callbackfor the IdP browser redirect — no such config is wired today. Add it (forge.yaml field and/orAGENT_URL), used to buildredirect_uri.buildAuthorizeURL(forge-core/mcp/oauth_flow.go:489) +oauth.GeneratePKCE()/oauth.GenerateState()+stateBinder.Issue(subject, server, session). Endpoints/client_idfrom 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 currentstateBinderholds{subject, server, session}but not the verifier — extend it).auth-requiredartifact. 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.)CallbackCompleter(Runner.SetCallbackCompleter):oauth.ExchangeCodeCtx(...)with the stored verifier →SubjectTokenStore.Put(subject, token, ttl)so the resumed call finds a grant.forge-cli/cmd/run.go— callSetCallbackCompleter+SetConsentDeliverer(both currently never called). Also expose theRunner.SetAuthorizeURLProviderseam 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)
authgate,mcpAuthGate).stateBinder(single-use/expiring/session-bound) +GET /mcp/oauth/callback+CallbackCompleterseam —forge-cli/runtime/mcp_consent_callback.go.SubjectTokenStore(Get/Put/Evict) —forge-core/mcp/subject_token_store.go.ConsentDeliverer/SetConsentDeliverer/SetCallbackCompleterseams —runner.go(defined, never called).buildAuthorizeURL,oauth.GeneratePKCE,oauth.GenerateState,oauth.ExchangeCodeCtx, discovery/DCRresolveOAuthConfig(MCP OAuth Phase 1.5: automated discovery (RFC 9728/8414) + dynamic client registration (RFC 7591) #316).Security contract (already enforced — must honor)
IssueMUST bind a non-empty session; the callback rejects empty/mismatched sessions fail-closed.Acceptance
type: userserver (no platform block) is accepted at construction and parks a grantless call instead of being rejected.auth-requiredartifact carries a clickable authorize URL for the requesting user./mcp/oauth/callback, exchanges the code (with the state-bound PKCE verifier), stores the token in the in-memorySubjectTokenStore, and resumes the parked call — which now resolves a grant and proceeds.Out of scope
initializ/aip/mcp-delegated-identity-broker.md).Related
Epic #317 · gate #330 · Slack delivery #343 · discovery/DCR #316 · AARM R10 #319 · authority
design-tool-registry.md§18.4.