MCP — Configuration Reference
Full schema for the forge.yaml mcp: block.
The mcp: block in forge.yaml declares the MCP servers an agent
connects to. Empty / absent is fine — agents without mcp: work
exactly like pre-v0.12.0 agents.
Schema
mcp:
token_store_path: ~/.forge/credentials # optional; OAuth token dir
servers:
- name: linear # required, slug
transport: http # required, "http" only in Phase 1
url: https://mcp.linear.app/sse # required for transport: http
auth: # optional
type: oauth # oauth | bearer | static
client_id: my-client-id # optional for oauth (see Discovery)
scopes: [read, write] # optional
authorize_url: https://... # optional for oauth (discovered if omitted)
token_url: https://... # optional for oauth (discovered if omitted)
token_env: NAME_OF_ENV_VAR # required if type=bearer|static
tools: # default-deny — at least one of:
allow: [create_issue, list_issues] # explicit names or ["*"]
deny: [drop_table] # subtractive, optional
timeout: 60s # default 60s; min 1s
required: false # default falseField reference
mcp.token_store_path
Directory where OAuth tokens are persisted by forge mcp login.
Default: ~/.forge/credentials/ (each server gets its own
mcp_<name>.json file). Override via env MCP_TOKEN_STORE_PATH.
mcp.servers[].name
Slug-format identifier, ^[a-z][a-z0-9-]{0,30}$. Used as the tool
namespace prefix: tool create_issue exposed by server linear
becomes linear__create_issue in the registry.
mcp.servers[].transport
Phase 1: must be "http". transport: stdio is rejected at
forge validate time with a roadmap pointer.
mcp.servers[].url
The MCP server's Streamable HTTP endpoint. Must be http:// or
https://. Both vendor-hosted (https://mcp.linear.app/sse) and
in-cluster (http://internal-mcp.svc.cluster.local:8080/mcp) are
supported.
mcp.servers[].auth
Optional. Omit for unauthenticated servers (e.g., trusted in-cluster MCPs).
auth.type | Required fields | When to use |
|---|---|---|
oauth | (none required — see Discovery) | Hosted MCPs (Linear, Notion, GitHub hosted) |
bearer | token_env | In-cluster sidecars; CI machine-to-machine |
static | token_env | Same as bearer; named for clarity |
platform | top-level platform block | Managed: agent-principal (service) identity resolved by the platform |
user | top-level platform block or explicit endpoints (standalone) | Delegated per-requesting-user identity — managed (platform-resolved) or standalone (Forge runs the OAuth itself, #332) |
token_env is the name of an environment variable; the variable's
value is read at runtime — never stored in forge.yaml.
Managed identity — type: platform / type: user
In a platform-managed deployment, Forge doesn't hold long-lived
credentials at all: it fetches a short-lived access token from the
platform's token endpoint per request. The refresh token stays platform-side
and never reaches the agent. Both types use the top-level platform block:
platform:
token_endpoint: ${INITIALIZ_TOKEN_ENDPOINT} # ${VAR}-expanded at use
agent_identity: ${FORGE_PLATFORM_TOKEN} # the agent's platform credential
authorize_endpoint: ${INITIALIZ_AUTHORIZE_ENDPOINT} # optional: consent-link resolver (#343)
mcp:
servers:
- name: atlassian-read
url: https://mcp.atlassian.com/mcp
auth: { type: platform, ref: mcp.atlassian } # agent-principal
required: true # startup-viable: no human needed
- name: atlassian-write
url: https://mcp.atlassian.com/mcp
auth: { type: user, ref: mcp.atlassian } # delegated per-user
required: false # lazy: no user at startuptype: platform— agent-principal. Forge POSTs{server: <ref>}with the agent credential; startup-viable (no user, no login).required: trueis valid.type: user(#317) — delegated. Forge resolves the requesting user's identity from the authenticated request and POSTs{server: <ref>, subject}, so each user gets their own token and its own connection. It is inherently lazy:required: trueis rejected (there is no user at startup), and until the platform has a grant for that user the call pauses on the auth-required gate (rather than failing) until they consent.tools.schemasis required fortype: user— the server has no connection at startup (no user), so it can't runtools/list. The platform materializes the tool schemas from the registry entry into config, and Forge registers them without a live connection; the per-user connection is established lazily on the first call. See Materialized tool schemas.refnames the platform tool-registry entry the token is authorized against (defaults to the server name).- Egress: the platform token-endpoint (and
authorize_endpoint) hosts are auto-merged into the allowlist.
Delivering the consent prompt (managed, #343)
When a type: user call parks awaiting consent, Forge can present a
"Connect <server>" login link to the requesting user over its own Slack
connection — the same bot the agent already runs. Two config pieces:
platform.authorize_endpoint— where Forge fetches the login URL. Forge POSTs{"server": <ref>, "subject": <email>}(same Bearer + tenancy headers astoken_endpoint) and the platform returns{"authorize_url": "https://…"}. The platform builds that URL with its ownclient_id/redirect_uri/stateand hosts the callback, so the authorizationcodeand refresh token land at the platform, never at Forge (managed invariant). Forge treats the URL as opaque and only delivers it. Unset ⇒ no managed link; the parked call still surfaces via themcp_auth_requiredaudit event.- A running Slack adapter (
forge run --with slack) — Forge DMs the subject the link (resolving the Slack user by email viausers.lookupByEmail→conversations.open). Requires the bot scopeschat:write,users:read.email, andim:write.
The login link is always published on the task's A2A auth-required
artifact as a durable record; Slack (when active) is an additive push on
top. So a per-subject Slack failure (e.g. the email isn't in the workspace, or
users.lookupByEmail is missing a scope) still leaves the user a clickable link
on the task — it's logged, never fatal, and the parked call still resumes when
the callback lands.
After the user consents at the platform's callback, the platform resumes the
parked call with POST /mcp/consent {subject, server, granted:true}. A Slack
"Cancel" button fails the call fast (granted:false). Delivery is decoupled from
token custody — the same Slack path serves standalone (Forge-built URL) and
managed (platform-supplied URL).
Per-user connection isolation. For
type: user, Forge establishes one MCP connection per requesting user — the connection'sinitializeruns under that user's token, so identity is bound at the connection level, not just per call. This holds even for a session-stateful server that binds identity atinitialize: user A's calls ride user A's session, user B's ride B's. Connections are established lazily (first call) and per subject.
The platform materializes both the
platformblock and the per-identity server entries (same URL, split bytype). For a standalone agent-principal, usetype: oauthwithgrant: client_credentials(above); for standalone per-user delegation, use standalonetype: user(below).
Standalone delegated consent — type: user without a platform (#332)
When there is no platform block, type: user runs the delegated OAuth
in Forge itself: a grantless call parks on the auth-required gate, the user
is shown a "Connect" login link, they consent in the browser, and Forge stores
their per-user access token in memory (no disk) so the call resumes.
server:
public_url: https://agent.example.com # or the AGENT_URL env var — see below
mcp:
servers:
- name: atlassian
url: https://mcp.atlassian.com/mcp
auth:
type: user
# standalone requires explicit endpoints + client_id (no runtime discovery):
client_id: ${ATLASSIAN_CLIENT_ID}
authorize_url: https://auth.atlassian.com/authorize
token_url: https://auth.atlassian.com/oauth/token
scopes: [read:jira-work, write:jira-work]
required: false
tools: { allow: ["*"] }grantisauthorization_code(the default) —client_credentialsis rejected for standalonetype: user.- Explicit endpoints +
client_idare required. Standalone does not run discovery/registration at runtime, soauthorize_url,token_url, andclient_idmust be set. (Add aplatformblock instead for managed delegation, which resolves tokens without any of these.) server.public_url(falling back to theAGENT_URLenv var) is the agent's externally-reachable base URL. Forge buildsredirect_uri = <public_url>/mcp/oauth/callback, so the URL must be reachable by the user's browser after IdP consent.- Delivery. The login link is published on the parked task's A2A
auth-requiredartifact (a UI/A2A client renders it). Forge-driven Slack delivery of the same link is tracked separately (#343). - Endpoints registered (only in standalone mode):
GET /mcp/oauth/start(sets aforge_sessioncookie, then redirects to the IdP) andGET /mcp/oauth/callback(validates state + session, exchanges the code, resumes the parked call). Both are auth-exempt (anonymous browser hops); their authenticity rests on the single-use, expiring, session-bound state — see the auth-required gate.
Trust model / limitation (standalone). The consent link is a bearer capability: the browser that completes it is anonymous, so the session cookie only proves the same browser did
/startand/callbackacross the IdP round-trip — it does not prove the completing user is the parked subject. If the link leaks, an attacker could authenticate at the IdP as themselves and have that token filed under the victim's subject (a confused-deputy / token-fixation vector). This is bounded by the single-use, short-TTL state and, above all, by delivering the link only over an authenticated channel to the requesting user (the A2Aauth-requiredartifact in their own session; the Slack DM in #343). Treat the link as a secret. The tamper-proof (heavier) alternative — verifying the IdPuserinfoidentity against the parked subject at exchange time — is deferred; managed mode sidesteps it entirely (the platform owns the callback and token custody).
Materialized tool schemas (#317)
A type: user server can't discover tools at startup (no user ⇒ no
connection), so its tools are declared under tools.schemas — the platform
materializes them from the tool-registry entry. Forge registers them without
a live connection; allow/deny still filter this set.
mcp:
servers:
- name: atlassian-write
transport: http
url: https://mcp.atlassian.com/mcp
auth: { type: user, ref: mcp.atlassian }
required: false
tools:
allow: ["*"]
schemas: # platform-materialized; no live tools/list
- name: create_issue
description: Create a Jira issue
input_schema:
type: object
properties:
project: { type: string }
summary: { type: string }
required: [project, summary]- Each schema is
name+ optionaldescription+input_schema(a JSON Schema authored as YAML; an omittedinput_schemadefaults to{"type":"object"}). Names are validated exactly as discovered tools are — non-empty and no__(the namespace separator). - The first call by a user establishes that user's connection lazily and runs
initializeunder their token; subsequent calls reuse it.
The schema set is a global declaration — per-user access is enforced at call time.
tools.schemas(and theallow/denyfilter) is the same for every user: it's what the server can expose. A user whose platform grant doesn't cover a materialized tool still sees it registered, so the LLM may attempt it and get a runtime auth error from that user's own connection — the per-user gate is the connection, not registration.
Staleness. Materialized schemas are a snapshot. If the MCP server's real tool set changes, the
tools.schemasinforge.yamlis stale until the platform re-materializes the registry entry (a redeploy) — the same snapshot semantics as theallow: ["*"]discovery filter.
Delegated consent — the auth-required gate (#330)
When a type: user call has no grant yet for the requesting user, the
tool call does not fail — it pauses on the auth-required gate, the user is
prompted to consent, and the call resumes with their token once a grant
exists. This is the delegated analog of the DEFER
park/resume: DEFER waits for a human to approve an action; the auth gate
waits for a user to complete OAuth consent.
type: user call, no grant → PARK (task status → auth-required)
→ prompt the user to consent
→ user consents → platform holds a grant
→ RESUME → the call re-resolves and proceeds- One prompt per user, not per call. The gate is keyed by
{subject, server}, so a user's concurrent calls (in any task) share one gate and one consent; a single grant resumes them all. - The gate never sees a token. It only unblocks the executor to re-resolve
through the normal delegated path — the token is fetched by the resolver, not
handed through the agent (AARM R10;
design-tool-registry.md§18.5). - Bounded. A call parks for at most the gate timeout (default 10m), then
fails auth-required. Audit events:
mcp_auth_required→mcp_auth_resolved/mcp_auth_timeout.
Resuming a parked call — two modes (§18.4):
| Mode | Consent delivery + callback | Resume signal to Forge |
|---|---|---|
| Managed | The platform prompts (Slack DM / console), hosts the OAuth callback, and holds the token. | POST /mcp/consent with {subject, server} (optionally granted:false to refuse). Carries no token — a pure "a grant now exists, re-resolve" signal. |
| Standalone | Forge hosts its own loopback callback GET /mcp/oauth/callback. | The callback validates the OAuth state (single-use, expiring, session-bound — cross-session/replayed/expired callbacks are rejected), exchanges the code for a token, then resumes. |
Never resume before the grant exists. In standalone mode the callback resumes the gate only after the code→token exchange succeeds — resuming with no stored token would just re-park the call. Delegation follows authorization.
Swappable token store. The per-user access-token cache is the
SubjectTokenStoreinterface (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. Only short-lived access tokens live here; refresh tokens never leave the broker.
OAuth discovery & dynamic client registration (#316)
For type: oauth, client_id / authorize_url / token_url are all
optional. When omitted, Forge discovers them from the server url
at forge mcp login time, using the MCP Authorization spec:
- RFC 9728 — protected-resource metadata (from the server's
401WWW-Authenticateheader, or{origin}/.well-known/oauth-protected-resource) to find the authorization server. - RFC 8414 — authorization-server metadata
(
/.well-known/oauth-authorization-server, with the OpenIDopenid-configurationvariant as a fallback) to discover theauthorize/token/registrationendpoints. - RFC 7591 — dynamic client registration mints a
client_idat first login; it is persisted (encrypted, alongside the token) and reused on refresh — never re-minted per run.
So a fully zero-config OAuth server is just:
- name: linear
transport: http
url: https://mcp.linear.app/mcp
auth:
type: oauth
scopes: [read, write] # optional; discovery uses scopes_supported when omitted
tools:
allow: [create_issue, list_issues]Precedence & rules:
- Discovery is the standalone default; explicit config always wins.
Setting
client_id/authorize_url/token_urlis both the static override (for servers that don't advertise metadata or don't support DCR) and the platform-materialized path — the normal case when a control plane materializes these fields from a registry entry. Explicit config is not just the exception; under managed/admission-time provisioning it is the common case. authorize_urlandtoken_urlmust be set together (or both omitted); a partial pair is a validation error.- Fail-closed: if a server advertises no metadata / no
registration_endpointand noclient_idis configured, login fails with a clear message — supply the fields explicitly, or use a discovery-capable server. - Egress: the discovered authorization-server host isn't in
forge.yamlto pre-seed the allowlist, so it is learned from the login-time registration record and merged into the egress allowlist at runtime automatically. (Discovery itself runs at laptop-timeforge mcp login, off the egress-enforced path.) - Recovery (revoked/expired client): a dynamically-registered client
is minted once and never re-minted. If the authorization server revokes
it (or
client_secret_expires_atpasses), runforge mcp logout <name>— that clears both the token and the stored registration — thenforge mcp login <name>again to re-discover and re-register. - Confidential clients are not supported for the interactive grant.
Forge registers a public (PKCE) client and sends no
client_secret. If a server insists on issuing a confidential client, login fails closed — configureclient_id/authorize_url/token_urlexplicitly for that server. (Confidential credentials are used by theclient_credentialsgrant below.)
Agent-principal — grant: client_credentials (2LO, #324)
The default oauth grant is 3-legged: a user consents once via
forge mcp login. Set grant: client_credentials for the
agent-principal path — the deployed agent authenticates as itself,
with no user and no browser, so it works headless:
mcp:
servers:
- name: internal-api
transport: http
url: https://mcp.internal.corp/mcp
auth:
type: oauth
grant: client_credentials # 2-legged; agent acts as itself
client_id: forge-agent
client_secret_env: MCP_INTERNAL_SECRET # NAME of an env var; never in yaml
token_url: https://mcp.internal.corp/token
scopes: [read]
tools: { allow: ["*"] }- Requires an explicit
client_id,client_secret_env, andtoken_url(2LO has no authorization endpoint and no dynamic registration).authorize_urlis not used. client_secret_envnames an environment variable (liketoken_env) — the secret is read at runtime and rotated by redeploy, never stored inforge.yaml.- No
forge mcp login— the token is minted at runtime and re-minted on expiry.forge mcp login <name>on such a server prints "no login needed." required: trueis valid here (unlike a delegated/per-user server, which has no user at startup): the agent-principal token resolves at startup, so a required server can gate readiness.- The token endpoint host is auto-added to the egress allowlist (from
token_url).
Use this where the MCP server supports the client_credentials / 2LO grant and the agent should act as a service identity. For per-user (delegated) identity, see #317. Managed platform-brokered tokens are #324's follow-on (the platform holds the service refresh token).
mcp.servers[].tools
Default-deny. Validation rejects entries where both allow and
deny are empty — operators must be explicit about exposure.
allow: ["*"]exposes every tool discovered at first connect (snapshot semantics — tools the server adds later do NOT appear without a rebuild).denysubtracts from either an explicit allow set or the wildcard.- A tool listed in both
allowanddenyis a validation error. - Tool names follow
^[a-zA-Z0-9_-]{1,128}$(hyphens allowed; length up to 128 — #370). The__namespace separator stays reserved.
mcp.servers[].timeout
Per-RPC timeout. Default 60s. Minimum 1s.
mcp.servers[].required
true— failure during startup (e.g. unreachable URL, OAuth refresh denied) abortsforge runwith a non-zero exit. K8s observesCrashLoopBackOff.false(default) — failure logs a warning; the agent starts without that server's tools.
Worked examples
Vendor-hosted MCP with OAuth (discovery — preferred)
Point at the server's Streamable HTTP endpoint (/mcp, not the legacy
/sse) and let discovery resolve everything:
mcp:
servers:
- name: linear
transport: http
url: https://mcp.linear.app/mcp
auth:
type: oauth
scopes: [read, write] # client_id + endpoints discovered (#316)
tools:
allow: [create_issue, list_issues]
required: trueThen forge mcp login linear once — Forge discovers the endpoints and
registers a client automatically.
Vendor-hosted MCP with OAuth (explicit override)
For a server that doesn't advertise metadata or doesn't support dynamic client registration, pin the fields (this overrides discovery):
mcp:
servers:
- name: linear
transport: http
url: https://mcp.linear.app/mcp
auth:
type: oauth
client_id: ${LINEAR_OAUTH_CLIENT_ID}
scopes: [read, write]
authorize_url: https://linear.app/oauth/authorize
token_url: https://api.linear.app/oauth/token
tools:
allow: [create_issue, list_issues]
required: trueEnvironment placeholders (#321)
${VAR} / $VAR placeholders in the MCP connection fields — url,
client_id, authorize_url, token_url, and scopes — are expanded
at load from the process environment (and the .env file loaded by
forge run), matching the egress-domain expansion semantics. This is how
managed/platform mode ships a server: the generated forge.yaml bakes
only the shape (literal url + placeholders), and the values are
injected into the agent's env at deploy — so rotating a pinned client is
a redeploy, not a rebuild.
auth:
type: oauth
client_id: ${MCP_LINEAR_CLIENT_ID}
authorize_url: ${MCP_LINEAR_AUTHORIZE_URL}
token_url: ${MCP_LINEAR_TOKEN_URL}
scopes: ["${MCP_LINEAR_SCOPES}"] # one var carrying "read write" → two scopes- With all of
client_id/authorize_url/token_urlpresent after expansion, resolution takes the explicit branch — no runtime discovery or DCR (the managed-mode contract). - An unset variable expands to
"", so the block reads as unconfigured and falls to the discovery / fail-closed path — never a literal${…}dial. - A
scopesentry is split on whitespace post-expansion, so${MCP_LINEAR_SCOPES}="read write"becomes[read, write]. (The split is unconditional — a literal["read write"]splits too; OAuth scopes are space-delimited by RFC 6749, so a value never has an internal space.) token_env(forbearer/static) is not expanded — it is the name of an env var, resolved at runtime.
⚠️ Keep
urlliteral. Althoughurlaccepts placeholders for symmetry, the build-time egress freeze reads MCP hosts from the config at build time — where the deploy env is unset, so aurl: ${MCP_URL}expands to"". That empty value fails theurl is required for http transportvalidation (a loud build error), and even if it slipped through it wouldn't be in the frozenegress_allowlist.json/ NetworkPolicy, blocking the host at runtime in a container. Managed mode keepsurlliteral and places placeholders only on the auth fields — do the same.
In-cluster MCP with bearer token from a K8s Secret
mcp:
servers:
- name: internal
transport: http
url: http://internal-mcp.default.svc.cluster.local:8080/mcp
auth:
type: bearer
token_env: INTERNAL_MCP_TOKEN
tools:
allow: ["*"]
deny: [drop_table, truncate_table]
required: trueMigrating from the deprecated mcp_call builtin
If your forge.yaml lists mcp_call under tools:, remove it and
replace with the new block:
# Before (no longer works)
tools:
- name: mcp_call
# After
mcp:
servers:
- name: my-mcp
transport: http
url: https://...
tools:
allow: [...]LLM-side, each tool is now invoked directly by name
(e.g. linear__create_issue(...)) instead of through a single
mcp_call(...) wrapper. The LLM no longer needs to know the MCP
endpoint URL or RPC shape — Forge handles that.