initializdocs
DeveloperForge runtimeMCP

MCP — Delegated Consent (Platform Integration)

How a managing platform deploys type: user MCP servers and drives the auth-required gate to obtain and manage per-user consent.

This is the operational runbook for a platform that manages Forge agent deployments and wants per-user (delegated-identity) MCP access — replacing any prior use of DEFER for MCP human-in-the-loop consent. For the concept and config-field reference, see Configuration → the auth-required gate; for the event fields, see Audit events.

First: the auth gate is not DEFER

DEFER and the auth-required gate solve different problems. Migrate only the MCP-consent case; keep DEFER for per-action approval.

DEFER — POST /tasks/{id}/decisionsAuth gate — POST /mcp/consent
Question"Approve/reject this write action?""Has the user authorized the agent to act as them on this server?"
Keyed bytaskID (1 decision : 1 task){subject, server} — fans out; one consent unblocks all of that user's parked calls for that server
Outcomeapprove / reject (approver allowlist)granted / timeout / canceled
Triggerevery matching tool callonly a type: user call with no grant for the requesting user
Tokenn/aForge never receives it — the resume is a "a grant now exists, re-resolve" signal

If a human must still approve a specific MCP write, that stays on DEFER. The auth gate is purely for delegated-identity consent (the user hasn't connected their Atlassian / Linear / etc. account yet).

1. Deploy-time — forge.yaml

Mark each delegated MCP server auth.type: user and wire the platform block. The gate activates automatically for type: user servers.

platform:
  token_endpoint:     https://platform.internal/agents/{id}/mcp/token      # returns the access token
  authorize_endpoint: https://platform.internal/agents/{id}/mcp/authorize  # returns the consent login URL (#343)
  agent_identity:     ${FORGE_PLATFORM_TOKEN}   # Bearer the agent sends on BOTH calls

mcp:
  servers:
    - name: atlassian
      transport: http
      url: https://mcp.atlassian.example/mcp
      required: false                 # MUST be false — no user exists at startup
      auth:
        type: user                    # delegated per-user identity; activates the gate
        ref: atlassian-registry-entry # platform tool-registry key BOTH endpoints authorize against
      tools:
        allow: ["*"]                  # or an explicit allowlist (default-deny)
  • token_endpoint (required) — where Forge fetches the per-user access token. See §2.
  • authorize_endpoint (optional) — where Forge fetches the consent login URL so it can deliver the prompt itself (e.g. over Slack). Omit it if the platform delivers the prompt instead (see §4).
  • agent_identity — the agent's platform credential, sent as Authorization: Bearer … on both calls (the platform injects it as a pod secret; ${VAR} is expanded at request time, so rotation needs no restart).

To let Forge deliver over Slack, also start the adapter — forge run --with slack — with bot scopes chat:write, users:read.email, im:write.

Invariants the runtime enforces:

RuleWhy
type: user must not be required: trueDelegated identity is lazy — there is no user at boot, so the server can't be a startup gate.
No token_store_path, no forge mcp login for type: userThe token is materialized by your token_endpoint, keyed {server, subject} — never stored on disk by the agent.
The gate attaches to every MCP tool automaticallyIt only trips on an ErrNoToken from the per-user resolver, so non-delegated servers are unaffected — nothing to disable.
Both endpoint hosts are auto-merged into the egress allowlistForge's outbound calls to them ride the same allowlist as the rest of the agent.

Remove those MCP tools from any DEFER policy that was standing in for consent.

2. The two platform endpoints

The platform implements two HTTP endpoints. Both take the same request shape — POST with Authorization: Bearer <agent_identity>, Org-Id / Workspace-Id tenancy headers (from FORGE_ORG_ID / FORGE_WORKSPACE_ID, when set), and a JSON body {"server": "<ref>", "subject": "<email>"} — and are keyed on the same {ref, subject} so you can correlate them.

token_endpoint — provide the token

POST {token_endpoint}
Authorization: Bearer <agent_identity>
{ "server": "atlassian-registry-entry", "subject": "user@corp.com" }
ResponseMeaning
200 {"access_token": "…", "expires_in": 3600}The delegated access token for this user. Forge caches it per-subject for expires_in seconds but clamps the cache to a 5-minute hard cap (delegatedTokenMaxTTL, #380) regardless of a larger expires_in — so a platform-side disconnect (revoking the user's grant) takes effect within ~5 minutes instead of lingering for the provider's full TTL (e.g. Atlassian's ~1h). Absent expires_in also defaults to 5 min. Return only a short-lived access token — never the refresh token (it stays in your vault).
401 / 403 / 404No grant for this user yet → Forge treats it as ErrNoToken, which trips the auth-required gate and parks the call. This is the signal that starts the consent flow.
other non-200Protocol error — the call fails (not parked).

authorize_endpoint — provide the authorization URL

POST {authorize_endpoint}
Authorization: Bearer <agent_identity>
{ "server": "atlassian-registry-entry", "subject": "user@corp.com" }

200 { "authorize_url": "https://auth.atlassian.com/authorize?client_id=<yours>&redirect_uri=<your-callback>&state=<yours>&…" }
  • You build the URL with your own client_id, redirect_uri (pointing at your callback), state, PKCE, and scopes. Forge treats it as opaque — it only delivers it.
  • Because redirect_uri is yours, the browser lands back at your callback: you receive the authorization code, exchange it, and vault the refresh token. Forge never sees the code or the refresh token.
  • Must be an absolute https:// URL (Forge rejects anything else before it reaches a clickable button). Non-200 fails delivery (the call still surfaces via mcp_auth_required).

Reference — curl

The exact calls Forge makes (and the resume you make back). FORGE_ORG_ID / FORGE_WORKSPACE_ID are sent only when set.

# --- token_endpoint: provide the token -----------------------------------
curl -sS -X POST "$TOKEN_ENDPOINT" \
  -H "Authorization: Bearer $AGENT_IDENTITY" \
  -H "Org-Id: $FORGE_ORG_ID" -H "Workspace-Id: $FORGE_WORKSPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{"server":"atlassian-registry-entry","subject":"user@corp.com"}'
# 200 → {"access_token":"eyJ…","expires_in":3600}      # granted
# 401 / 403 / 404 → (no body needed)                    # no grant yet → parks the call

# --- authorize_endpoint: provide the authorization URL -------------------
curl -sS -X POST "$AUTHORIZE_ENDPOINT" \
  -H "Authorization: Bearer $AGENT_IDENTITY" \
  -H "Org-Id: $FORGE_ORG_ID" -H "Workspace-Id: $FORGE_WORKSPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{"server":"atlassian-registry-entry","subject":"user@corp.com"}'
# 200 → {"authorize_url":"https://auth.atlassian.com/authorize?client_id=<yours>&redirect_uri=<your-callback>&state=<yours>&code_challenge=<yours>&scope=…"}

# --- resume: after you exchange the code + vault the refresh token --------
curl -sS -X POST "$AGENT_URL/mcp/consent" \
  -H "Authorization: Bearer $AGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subject":"user@corp.com","server":"atlassian","granted":true}'
# 200 resumes every parked call for {subject, server}; granted:false fails them fast

When a type: user call has no grant, the runtime surfaces the need three ways (no polling of internal state required):

  1. Task status flips to auth-required (A2A TaskState). A GET /tasks/{id} reader sees state auth-required with the agent message "Authorization required: connect <server> to continue (as <subject>)".

  2. Audit event mcp_auth_required — emitted once per {subject, server} (not per call):

    {"event":"mcp_auth_required","correlation_id":"…","task_id":"…",
     "fields":{"server":"atlassian","subject":"user@corp.com",
               "deadline":"2026-07-19T12:34:56Z","timeout_ms":600000}}
  3. Resolution emits mcp_auth_resolved; expiry emits mcp_auth_timeout. Both are emitted once, by the resuming gate, and carry the parked call's correlation_id / task_id / seq so they group under the original invocation (#366) — the resume channels (loopback callback, POST /mcp/consent) no longer emit a second, unattributed copy. The only exception is a late grant that lands after the call already timed out: with no in-flight invocation to attribute to, the callback records an unattributed mcp_auth_resolved{via:loopback_callback,late:true}.

subject is the user's email (falls back to the opaque user ID), server is the MCP server name, deadline is the hard park window — default 10 minutes, after which the call fails with an auth-required error and the LLM sees a no_token result. Consume the audit stream and/or watch task status.

4. Two delivery models

Presenting the link is independent of who receives the code — so you choose who delivers the "Connect" prompt. Token custody is the platform's either way.

Forge delivers (over Slack)Platform delivers
Who shows the linkForge DMs the subject over its own Slack botYour platform (its Slack/console/email)
Where the URL comes fromForge POSTs your authorize_endpointYou build it internally — no authorize_endpoint needed
Configset authorize_endpoint + run --with slackomit authorize_endpoint; consume mcp_auth_required
Callback / refresh tokenyours (the URL's redirect_uri is yours)yours

Either way the link is also published on the task's A2A auth-required artifact as a durable record, so a UI/A2A client can render it and a per-subject Slack failure never strands the user.

End-to-end (Forge-delivers-over-Slack):

type: user call, no grant for subject
  → Forge POSTs token_endpoint {server, subject} → 401/403/404 → ErrNoToken
  → gate PARKS; task → auth-required; emit mcp_auth_required{subject, server, deadline}
  → Forge POSTs authorize_endpoint {server, subject} → {authorize_url}
  → Forge DMs the subject the "Connect" link (+ publishes it on the task artifact)
  → subject signs in at YOUR callback → you exchange the code + VAULT the refresh token
  → platform: POST /mcp/consent {subject, server, granted:true}
  → gate resolves → parked call re-resolves → token_endpoint now returns a token → call proceeds

(For the platform-delivers model, drop the two Forge lines: you deliver the prompt off the mcp_auth_required event and host the whole OAuth yourself.)

The resume call:

POST /mcp/consent                       # on the agent's A2A server
Authorization: Bearer <agent token>     # NOT auth-exempt — you must authenticate
Content-Type: application/json

{ "subject": "user@corp.com", "server": "atlassian", "granted": true }

Response codes:

CodeMeaning
200Gate resolved; every parked call for {subject, server} re-resolves and re-fetches the token.
404No call is currently parked for that {subject, server}.
400Missing subject/server, or malformed body.
409Race — the gate already resolved (e.g. timed out) between check and resolve.
  • granted: false reports a refusal so the parked call fails fast instead of idling to the deadline. Omitting granted defaults to true.
  • Ordering is load-bearing: send granted: true only after your token_endpoint can return a token for that subject. The consent endpoint merely unblocks re-resolution; if the grant isn't vaulted yet, the re-resolve hits ErrNoToken again and re-parks. Delegation follows authorization.
  • No token crosses the boundary. Forge fetches the token itself via the delegated resolver (POST {token_endpoint} with {server, subject}) on re-resolution (AARM R10; design-tool-registry.md §18.5).
  • Cancel = fast-fail. In the Forge-delivers-over-Slack model, a "Cancel" button posts {granted:false} to the same endpoint for you, so the parked call fails immediately instead of idling to the deadline.

5. Embedding Forge (optional)

The stock binary is fully drivable via the HTTP signals above. If you link Forge as a library and prefer push-delivery over consuming the audit stream, set the deliverer seam so Forge calls you directly when a gate opens:

runner.SetConsentDeliverer(func(ctx context.Context, subject, server, taskID string, deadline time.Time) error {
    return platform.DeliverConsentPrompt(subject, server, taskID, deadline) // Slack DM, etc.
})

Delivery is best-effort — an error is logged, never fatal; the park/resume still works. The standalone loopback callback (GET /mcp/oauth/callback, public) is for the Forge-hosts-its-own-callback case and is not used in managed mode.

Durable token custody across replicas

The per-user access-token cache is the SubjectTokenStore interface (in-process by default). A managed broker can substitute a shared/durable implementation so grants survive restarts and are shared across replicas — the resolver and agent are unchanged. See Configuration → the auth-required gate.

Migration checklist

  • auth.type: user + ref on the delegated servers; required: false; platform.token_endpoint + agent_identity set.
  • Implement token_endpoint200 {access_token, expires_in} when granted, 401/403/404 when not (this trips the gate). Return no refresh token.
  • Choose a delivery model: Forge-over-Slack (set authorize_endpoint returning {authorize_url} + run --with slack) or platform-delivers (consume mcp_auth_required).
  • Point the authorize URL's redirect_uri at your callback → you exchange the code and vault the refresh token.
  • After vaulting, POST /mcp/consent {subject, server, granted:true} (authenticated) — only after token_endpoint can return the token.
  • Subscribe to mcp_auth_required / mcp_auth_resolved / mcp_auth_timeout (and/or watch task auth-required).
  • Keep DEFER for genuine per-action approve/reject.

On this page