MCP federation
Make MCPG act as an MCP client to other MCP servers and re-serve their tools, resources, templates, and prompts under your own names, governance, and auth — one MCP endpoint that aggregates many upstreams.
Federation makes MCPG act as an MCP client to other MCP servers and re-serve their tools, resources, resource templates, and prompts to your clients under your own names, governance, and auth — a single MCP endpoint that aggregates many upstreams.
This guide is operator-facing: configuration, every auth mode and transport, runtime behaviour, and best practices, with copy-pasteable samples. For the full federation config surface, see the configuration reference.
How it works
Federation is built into the gateway. At boot, the engine connects to each
configured upstream, lists its capabilities, and publishes them as synthetic
capabilities into a runtime-mutable overlay on the gateway's capability
registry. To your clients they look native: they appear in tools/list /
resources/list / etc. under your prefixes, tagged with their source in
_meta, and enforce your governance. A tools/call (or resource read /
prompt get) for a federated capability is dispatched to the owning upstream over
a per-client satellite session and the result returned. Upstream changes
(list_changed, resources/updated) are forwarded to your clients, and
upstream server-requests (sampling / elicitation / roots) plus progress are
bridged through to the real client and back.
client ──MCP──▶ MCPG ──MCP──▶ upstream A (HTTP)
│ ──MCP──▶ upstream B (stdio child)
└ native tools + federated tools, one endpoint
Quick start
Federate one HTTP upstream's tools, namespaced under notion.:
mcp:
federations:
- name: notion
upstream:
url: https://notion-mcp.example.com/mcp
import:
tools: true
naming:
tool_prefix: "notion."
governance:
minimum_trust: unauthenticated
Boot MCPG; tools/list now includes notion.search, notion.create_page, …
and tools/call for them is proxied to the upstream. That's it — no auth (the
upstream is public). The governance line is there because this config has no
identity source, so every caller is unauthenticated. The default floor is
header_asserted, and a caller below the floor does not see the tools in
tools/list. When you add an identity source, remove the line or raise the
floor.
Configuration reference
Federations live under mcp.federations: []. Every field of one entry:
mcp:
federations:
- name: notion # REQUIRED. Source id + default prefix namespace.
# Must be unique; must not shadow a native binding.
governance: # Inherited by EVERY synthetic capability (like a native binding).
minimum_trust: verified # unauthenticated | header_asserted | verified (default: header_asserted)
allow_if: '"notion-users" in identity.groups' # optional CEL; same engine as native per-tool rules.
# Compiled by `config check`, at boot and on reload: a syntax
# error refuses the config.
required_scopes: [notion.read] # optional; the caller's credential must carry every scope
# (403 + insufficient_scope step-up challenge otherwise)
retry: # optional; upstream call retry
max_attempts: 2
initial_backoff_ms: 500
upstream:
url: https://notion-mcp.example.com/mcp # required for streamable_http; omit for stdio
transport: streamable_http # streamable_http (default) | stdio
protocol_version: auto # MCP wire MCPG speaks AS A CLIENT to this upstream.
# auto (default; probe server/discover, fall back to the
# legacy initialize handshake, cache the detected wire) |
# 2025-11-25 (pin the session-bound handshake) |
# 2026-07-28 (pin the stateless modern wire).
# 2026-07-28 is only honored on streamable_http; auto on
# stdio resolves to legacy without probing.
# stdio transport only:
command: my-mcp-server # the child process to spawn
args: ["--stdio"]
env: { API_TOKEN: "${env.UPSTREAM_TOKEN}" }
auth:
mode: none # none | service_token | pass_through
# | oauth_client_credentials | oauth_impersonation
token: "${env.SVC_TOKEN}" # for service_token
credential: "cred://<plugin_id>/<provider>" # for the oauth_* modes
import: # optional catalogue credential (import, refresh, listener)
mode: service_token # service_token | oauth_client_credentials
token: "${env.CATALOGUE_TOKEN}"
upstream_safety:
allow_private_backends: false # permit private/loopback upstream addresses (SSRF guard)
allow_insecure_http: false # permit http:// (non-TLS) upstreams
allow_stdio: false # permit the stdio transport (local process exec) — default-deny
import: # which surfaces to import (at least one true)
tools: true # default true
resources: false
resource_templates: false
prompts: false
events: false # MCP Events types; needs the 2026-07-28 wire over
# streamable_http (see "Importing MCP Events")
naming: # prefixes applied to imported names/URIs (collision-avoidance)
tool_prefix: "notion."
resource_uri_prefix: "mcp://notion/"
prompt_prefix: "notion."
event_prefix: "notion." # must keep event names valid: letters, digits, . _ -
filter: # glob filters on imported names
include_tools: ["*"] # default ["*"]
exclude_tools: ["internal_*"]
include_events: ["*"] # the same, for event type names
exclude_events: []
events: # how imported event types are read from the upstream
delivery: auto # auto (default; webhook when offered and this gateway has a
# public address, else push when offered, else poll)
# | webhook | push | poll
sharing: fan_in # fan_in (default; one upstream subscription per set of
# arguments, as the gateway) | per_principal (one per
# subscriber, with their stored sign-in)
retention: # what the log keeps of each imported type, as `retention`
max_age_ms: 86400000 # on a configured type (defaults shown)
max_events: 10000
max_bytes: 8388608
cache:
capability_ttl_secs: 300 # poll-refresh interval (re-list even without a push); default 300
synthesize: # change-notification synthesis for push-less upstreams
resources_updated: auto # auto (default; poll only when the upstream can't push:
# modern wire / stdio) | poll (always) | off (never)
poll_interval_ms: 30000 # poll cadence; subscriber-gated (no subscribers → no polling)
session:
idle_timeout_secs: 600 # idle satellite teardown; default 600
response:
max_response_bytes: 2097152 # per-call upstream response cap; default 2 MiB
Validation runs at boot and on reload; a bad federation fails fast with a
precise message. Names and prefixes must be unique across federations and must
not shadow native bindings. Validate any config you write with
mcpg config check.
Auto-federating an MCP registry
Instead of hand-writing one federation per server, point MCPG at an MCP
registry — the standard /v0.1 API implemented by the official registry
and enterprise sub-registries — and a background syncer materializes one
federation per usable server, kept in sync as the registry changes: added
servers appear, deleted servers are removed, deprecated ones follow
your policy, and connected clients receive list_changed either way.
mcp:
registries:
- name: acme # prefixes every synthesized federation name
url: https://registry.acme.internal
auth: { mode: bearer, token: "${env.REGISTRY_TOKEN}" } # none | bearer | headers | cred
registry_safety:
allow_private_registry: true # a private registry URL is an explicit opt-in
sync:
interval_secs: 300 # crawl cadence (floor 30)
max_servers: 100 # hard cap; excess skipped (name-sorted)
incremental: false # opt-in updated_since delta crawls
full_resync_hours: 24 # full-crawl backstop when incremental
filter:
namespaces: [com.acme] # publisher-namespace allowlist (anti-typosquat)
exclude: ["com.acme/experimental-*"]
on_deprecated: serve_and_warn # | exclude
defaults: # applied to every synthesized federation
governance: { minimum_trust: verified }
upstream_safety: { allow_private_backends: true } # internal remotes opt-in
auth: { mode: pass_through }
servers: # per-server overrides, keyed by registry name
"com.acme/crm":
version: "2.3.1" # pin (default: track latest)
variables: { tenant_id: acme-prod } # remote-URL {variable} values
headers: { X-API-Key: "${env.CRM_KEY}" } # declared request headers
What keeps this safe: synthesized federations always get a unique
per-server tool prefix derived from the reverse-DNS server name
(com.acme.crm.); operator-authored federations win on any name or
prefix collision; and the registry cannot relax transport security —
stdio, insecure HTTP, and tunnel:// stay denied regardless of what it
lists, with private backends an explicit per-registry opt-in. Every
synthesized federation is governed by its registry's defaults.governance
as the config in force declares it: a reload that tightens it applies at
once, a snapshot adopted from the cluster leader brings no governance of its
own, and a synthesized federation whose registry is no longer configured is
dropped. The gateway records which registry synthesized each federation, so
that claim never rests on a name that two registries' names could both
prefix. During a rolling upgrade, a replica on the new version claims the
federations an older leader publishes by name, as that leader's own
followers do, so they keep serving through the upgrade; one whose name two
registries' names both prefix is dropped until a newer leader republishes it.
Servers
whose remote declares required URL variables or secret headers you have
not supplied are skipped and reported, as are packages-only entries
(npm / pypi / oci installables are a provisioning concern, not an
auto-federation one). Each synthesized upstream uses
protocol_version: auto, so legacy and modern registry servers both work
with no per-server wire config — see
protocol versions.
Per-server credentials without per-server config
{server} in a synthesized federation's auth.credential expands to the
registry server name, and the ID-JAG / token-exchange credential issuers
accept a target_template block that derives a provider per target — one
issuer block covers the whole fleet:
mcp:
registries:
- name: acme
url: https://registry.acme.internal
# The registry itself can authenticate through an issuer too — the
# crawl bearer is minted under the gateway's machine identity:
auth: { mode: cred, credential: "cred://dev.mcpg.credential.oauth-client-credentials/registry" }
defaults:
auth:
mode: oauth_impersonation # per-caller Cross-App Access
credential: "cred://dev.mcpg.credential.oauth-id-jag/{server}"
plugins:
- id: dev.mcpg.credential.oauth-id-jag
class: credential_issuer
source:
oci: "ghcr.io/mcpg-dev/plugins/credential-oauth-id-jag:protocol-1"
granted_capabilities: [network_outbound]
config:
target_template:
allowed_targets: ["com.acme/*"] # fail-closed: only these expand
idp_token_url: https://idp.acme.example/oauth2/token
client_id: mcpg-fleet
client_secret: ${env.IDP_SECRET}
subject_token_type: id_token # required: what the callers' bearers are
audience_template: "https://{target_slug}.mcp.acme.internal"
redeem_token_url_template: "https://{target_slug}.mcp.acme.internal/oauth2/token"
For each caller and server, the issuer exchanges the caller's bearer
(RFC 8693) for an ID-JAG assertion carrying the server's expanded
audience, then redeems it (RFC 7523) at the server's expanded token
endpoint. {target_slug} is the server name reduced to hostname
characters (com.acme/crm becomes com-acme-crm); a raw {target} in
the host of redeem_token_url_template refuses names such as
com.acme/crm. An exact providers entry always beats the template,
and targets outside allowed_targets fail closed.
OAuth discovery
When a server's resource identifier and token endpoint are not known a
priori, enable sync-time discovery — the client half of MCP
authorization. For oauth-id-jag the authorization server's issuer
still comes from the issuer config; discovery confirms it:
mcp:
registries:
- name: acme
url: https://registry.acme.internal
defaults:
oauth_discovery: { enabled: true }
auth:
mode: oauth_impersonation
credential: "cred://dev.mcpg.credential.oauth-id-jag/{server}"
MCPG fetches each OAuth-mode server's RFC 9728 protected-resource
metadata and the advertised authorization server's RFC 8414 metadata
(both round-trip-validated and SSRF-guarded), then injects the derived
{audience, resource, redeem_token_url, issuer} onto the synthesized
federation as upstream.auth.credential_config, forwarded to the
credential issuer on every issuance. For oauth-id-jag the audience
is the upstream authorization server's issuer — hop 1 asks the IdP
for an ID-JAG addressed to that server, which refuses any other aud —
and the MCP server travels as resource. For every other OAuth issuer
the audience is the resource itself. The metadata is the upstream's
own document, so oauth-id-jag keeps its configured audience
authoritative: a discovered issuer that differs by more than a trailing
/ fails the issuance before the IdP is called, and the discovered
token endpoint must share the configured endpoint's origin.
An oauth-id-jag federation also needs its authorization server to
offer ID-JAG redemption: the jwt-bearer grant in
grant_types_supported, or the
urn:ietf:params:oauth:grant-profile:id-jag profile in
authorization_grant_profiles_supported. The grant without the profile
federates with a warning; a server whose AS offers neither is skipped
(reason="no_id_jag_support") instead of being sent ID-JAGs it has not
said it can redeem. On a transient metadata outage the server's
previously discovered values are reused; a server that has never been
discovered is skipped rather than federated broken.
credential_config also works on hand-written federations, so any
OAuth-protected MCP server can be federated by pinning its audience,
resource and token endpoint explicitly — for oauth-id-jag,
audience: https://as.crm.acme.example (the AS issuer) with
resource: https://crm.acme.example/mcp.
Clusters and incremental crawls
In a clustered gateway exactly one replica crawls each registry
(leader-elected via the cluster coordinator) and publishes the
synthesized overlay to the coordinator KV; the other replicas adopt
that snapshot, and every replica warm-starts from it at boot.
Single-node deployments are unchanged. sync.incremental: true
switches steady-state crawls to updated_since deltas (deletions bump
updatedAt, so tombstones arrive in deltas too) with a full crawl
every sync.full_resync_hours as the backstop.
Serving a registry view of the gateway
MCPG can also be the registry: mcp.registry (opt-in) serves
the v0.1 registry API with one entry — this gateway — so
registry-driven client policies (e.g. Copilot's allowed-registry
setting) resolve to exactly "the approved server is MCPG":
mcp:
# `registries` = the registries mcpg consumes;
# `registry` = the registry mcpg serves.
registry:
enabled: true
name: com.acme/gateway # reverse-DNS, required
description: Governed MCP catalog
# url defaults to governance.access.resource_metadata.resource
GET /v0.1/servers returns the standard envelope;
GET /v0.1/servers/{name}/versions/{latest|version} serves the pinned
fetches registry clients use.
What gets imported, and how it's named
import.* selects surfaces; naming.* prefixes keep federated capabilities
from colliding with native ones (or with each other):
| Surface | import flag | Prefixed by | Dispatched via |
|---|---|---|---|
| Tools | tools | tool_prefix | tools/call |
| Resources | resources | resource_uri_prefix | resources/read |
| Resource templates | resource_templates | resource_uri_prefix | resources/read (URI matched, de-prefixed) |
| Prompts | prompts | prompt_prefix | prompts/get |
| MCP Events types | events | event_prefix | the gateway's own event log (see Importing MCP Events) |
Every federated capability carries _meta.mcpg.source.federatedFrom: "<name>"
so clients (and your audit) can see where it came from. The original upstream
name/URI is preserved on the dispatch route, so the upstream always sees its own
un-prefixed names.
Resource templates are special: the client expands a uriTemplate into a
concrete URI you've never registered, so at read time MCPG matches the URI
against the federated template, strips the prefix, and dispatches the upstream
URI — no separate route type.
Filtering tools
filter is a minimal glob (* = all, prefix* = prefix glob, exact otherwise)
applied to upstream tool names before prefixing. There is no suffix glob: a
leading * is literal, so *_admin matches only a tool named *_admin.
mcp:
federations:
- name: notion
upstream:
url: https://notion-mcp.example.com/mcp
filter:
include_tools: ["search*", "read_*"] # only these import
exclude_tools: ["admin_*", "delete_*"] # …minus these (exclude wins)
Use it to expose a safe subset of a powerful upstream.
include_events and exclude_events filter imported event type names the
same way, on their own: the tool filter never applies to events.
Importing MCP Events
With import.events: true, MCPG lists the upstream's
MCP Events types and serves each one as its own
type, named event_prefix + the upstream name. Clients subscribe to it like
to any other type — webhook, poll or push, in every mode
mcp.configurations.events enables — whatever modes the upstream offers.
mcp:
configurations:
events: { enabled: true }
federations:
- name: gh
upstream:
url: https://github-mcp.example.com/mcp
protocol_version: auto # or 2026-07-28; events need the modern wire
auth:
mode: oauth_impersonation
credential: "cred://<plugin_id>/<provider>"
import: # the credential the event reads use
mode: oauth_client_credentials
credential: "cred://<plugin_id>/<provider>"
import: { tools: true, events: true }
naming: { tool_prefix: "gh.", event_prefix: "gh." }
filter: { include_events: ["issues.*", "pull_request.*"] }
events: { delivery: auto }
How it works:
- One upstream subscription per set of arguments in demand. A type has
demand while a webhook subscription, a poll lease or a push stream of it is
live. MCPG opens one upstream subscription for each distinct set of
argumentsthose use, and closes itsources.linger_msafter the last of them ends. A thousand clients that watch the same repository share one upstream read. - Served from the gateway's own log. What an upstream subscription reads
is written to the type's log, tagged with its arguments, so only the
readers of those arguments receive it. Cursors, replay, retention,
governance, payload gates and quotas apply as for a configured type. Each
imported type keeps
events.retention, and takes its byte budget from what the event-log store has left after the configured types; a type that does not fit is not served, with a warning. - Counted by the plan. On a gateway a control plane manages, each
upstream subscription counts toward the plan's reader cap beside the
readers it serves, so the first reader of a new set of arguments counts
twice. The plan's type cap serves configured types before imported ones.
While the plan's monthly ingest allowance is used, MCPG ends its upstream
subscriptions: a delivery that arrives before an upstream subscription
ends gets
429withRetry-After, and one after it gets410. When the allowance comes back, each upstream subscription resumes from its stored cursor. See Plan limits. - Read in the federation's mode.
events.delivery: autotakes an upstream webhook subscription when the upstream offers webhooks and this gateway has a public address, else streams (events/stream) when the upstream offers push, else polls (events/poll);webhook,pushandpollinsist on one, and a type that does not offer it is not imported. - Webhooks come back to this gateway. In webhook mode the upstream
delivers to
{address}/webhooks/mcp-events/{federation}/{id}, where the address isgateway.server.canonical_url, or the URL of a public tunnel while it is up. Every replica takes deliveries: it checks the Standard Webhooks signature with a secret each replica derives from the cluster key and never stores, and answers200only once the event is durably queued for the log. MCPG refreshes the upstream subscription before its grant ends, and ends it when its demand ends; a late delivery then gets410. On mcpg.cloud the edge forwards this path for an instance that imports events. - Held with the catalogue credential, or the subscriber's own. With
sharing: fan_inan upstream subscription serves every subscriber of its arguments, so it is made as the gateway, withauth.import(orauthwhen that needs no caller), never as one of them, and downstream access is MCPG's own policy on the imported type. Withsharing: per_principaleach subscriber gets an upstream subscription of their own, held with the upstream credential the federation mints from their stored sign-in (auth.mode: oauth_impersonationwithsubject_token: idp_refresh_tokenoridp_id_token, required), and only they receive what it reads. A subscriber with no stored sign-in gets nothing until they sign in. - Event ids carry the federation. An upstream event
e1of federationghis served asgh:e1, so ids two upstreams chose alike stay apart at a receiver that deduplicates onwebhook-id. - Resumes, and says when it cannot. Each upstream subscription keeps the
upstream's cursor. After a restart it resumes from there; when the upstream
reports events it no longer has, when the type's schema changes upstream,
or when there is no cursor to resume from, its readers get a gap
(
truncated: true). - Ends when the upstream ends it. When the upstream refuses the
subscription for good (forbidden, no such type, or arguments it refuses),
every subscription of those arguments ends here too, after the events
before it, with the gateway's own error:
-32012 {reason: "upstream_forbidden"},-32011 {kind: "event"}or-32602. A webhook subscriber is not tombstoned: its next refresh subscribes anew and MCPG asks the upstream again. Other refusals, such as an upstream limit, are retried after five minutes and end nothing. A429, the upstream's request ceiling, is retried within seconds. - Follows the upstream's list. A
notifications/events/list_changedfrom the upstream, or the TTL refresh, re-imports the types, and MCPG tells its own clients withnotifications/events/list_changed. A type the upstream drops ends its subscriptions here as a removed type does. While a federation's first import is still pending (for example, the upstream is down at boot), subscriptions to its types are kept, not ended.
An upstream on the legacy wire, or one that does not advertise events,
imports no event types, with a warning. import.events on a stdio upstream,
or on one pinned to 2025-11-25, is a config error.
Governance inheritance
A federation's governance block applies to every capability it imports,
exactly as if you'd written it on a native binding:
minimum_trust—unauthenticated<header_asserted<verified. A caller below the bar can't call the federated tool — and the tool is hidden fromtools/listfor that caller (visibility honours trust). Unset, it isheader_asserted:governance.policy.tool_access.default_minimum_trustdoes not reach federated capabilities, so set the floor on each federation.allow_if— a CEL expression evaluated per call against the caller's identity (identity.groups,identity.roles,identity.scopes,identity.attributes) andtool_name, the prefixed name the client called. Same engine and semantics as native per-toolallow_if; an expression that fails to evaluate denies the call.required_scopes— OAuth scopes the caller's credential must carry, enforced exactly as on a native binding. A caller missing any of them is refused with403and theinsufficient_scopestep-up challenge naming the missing scopes, and does not see the capability in its lists.
mcp:
federations:
- name: notion
upstream:
url: https://notion-mcp.example.com/mcp
naming:
tool_prefix: "notion."
governance:
minimum_trust: verified
required_scopes: [notion.read]
allow_if: '"notion-users" in identity.groups && !tool_name.endsWith(".delete_page")'
This is enforced at dispatch by the same policy gate that guards native tools —
federation is not a governance bypass. The rule covers every surface the
federation serves: tools and prompts by prefixed name, resources by URI, and
resource templates both by uriTemplate and by each concrete URI a template
matches that the router sends to this federation (a native binding, or a
federation declared earlier, that serves the URI takes it instead), with or
without a resource_uri_prefix. The gate applies it to
whatever the router sends to the federation, so a resource read answers to the
rule of the federation the read path routes it to, however the client spells
the URI (scheme or host case, a default port). The rule governs only what is
routed to the federation: a name or URI that the gateway serves itself (native
bindings are matched first, and the gateway's own tools, prompts and resources
are native) is never decided by a federation's rule, even where the federation
lists the same name on another surface or one of its URIs or templates also
matches it. Lists apply the same decision as calls and reads: a caller sees a
federated capability exactly when a call or read of it would be allowed.
Other rules can tighten a federation's rule, never loosen it. A call or read routed to a federation must pass the federation's rule and every other rule that names it:
- a central
governance.policy.tool_access.rules[]entry whosetool_nameis the federated tool or prompt name, the URI a client reads, or the URI oruriTemplatethe federation lists the resource under; - a native binding's rule filed under the same name, for the same kind of
surface — a binding's rule governs only its own kind, so a native tool
binding named
searchtightens a federated toolsearch, while a native prompt, resource or template binding namedsearchdoes not.
All of them apply together: the highest minimum_trust, every
required_scopes entry and every allow_if. A central rule with a lower floor
or a more permissive allow_if than the federation's therefore has no effect
on the federation's capabilities; use one to add a requirement, and set the
federation's own governance to relax it.
governance:
policy:
tool_access:
rules:
# crm.delete_record already needs `verified` from its federation;
# this adds a scope and a group on top.
- tool_name: crm.delete_record
minimum_trust: verified
required_scopes: [crm.admin]
cel_allow_if: '"crm-admins" in identity.groups'
An invalid rule fails closed. allow_if is compiled with the policy gate's
own CEL compiler when the config is validated, so mcpg config check, boot,
and every reload (file, SIGHUP, control-plane push) refuse an expression that
does not compile, or a key the block does not know (for example the central
rules' cel_allow_if), and the error names its path — for example
mcp.federations[notion].governance.allow_if or
mcp.registries[acme].defaults.governance.allow_if. A reload that fails this
way keeps the running config. If a federation's rule still cannot be compiled
at runtime, every capability that federation serves is refused: hidden from
every list and denied on call with an internal error (audit reason
federation_governance_uncompiled). It never falls back to the gateway-wide
policy, and every other federation keeps its own rules.
A name that two federations both list (for example the same resource URI from
two upstreams without a resource_uri_prefix) is served by the federation
declared first; the other federation's entry is withheld from lists and never
dispatched, so only the serving federation's rule applies. Give each
federation a naming prefix to serve both.
Authenticating to the upstream
upstream.auth.mode picks how MCPG presents itself (or the caller) to the
upstream:
none
No Authorization sent. For public or network-trusted upstreams.
service_token
A static bearer MCPG presents as itself. Source it from a secret, never inline:
mcp:
federations:
- name: jira
upstream:
url: https://jira-mcp.example.com/mcp
auth:
mode: service_token
token: "${env.JIRA_SERVICE_TOKEN}"
pass_through
Forward the inbound caller's Authorization bearer verbatim. The bearer is
captured per request in memory only — never persisted to the pipeline store or
logged. At import/listen time (no caller) the upstream is listed anonymously,
unless you set a catalogue credential.
mcp:
federations:
- name: notion
upstream:
url: https://notion-mcp.example.com/mcp
auth: { mode: pass_through }
Use when the upstream already understands your clients' tokens.
A bearer the gateway minted itself is never forwarded: an access token from
the embedded authorization server (enterprise-managed authorization) or the
supervised inspector's credential. Only this gateway can validate it, and an
upstream that received it could replay it here. Such a call fails before any
request to the upstream; the gateway log names the fix. For those callers,
use service_token, oauth_client_credentials, or oauth_impersonation
with the caller's stored enterprise sign-in.
oauth_client_credentials — machine identity
MCPG mints a machine token via the gateway's credential-issuer subsystem and
presents it. credential is a cred://<plugin_id>/<provider> URI pointing at a
configured oauth-client-credentials issuer. The token is cached and
auto-refreshed; no client secret lives in the federation config.
plugins:
- id: dev.mcpg.credential.oauth-client-credentials
class: credential_issuer
source:
oci: "ghcr.io/mcpg-dev/plugins/credential-oauth-client-credentials:protocol-1"
config:
providers:
notion:
token_url: https://auth.notion.example.com/oauth/token
client_id: mcpg-gateway
client_secret: "${env.NOTION_CLIENT_SECRET}"
scopes: ["read", "write"]
mcp:
federations:
- name: notion
upstream:
url: https://notion-mcp.example.com/mcp
auth:
mode: oauth_client_credentials
credential: cred://dev.mcpg.credential.oauth-client-credentials/notion
The same token is shared across all callers (the grant is identity-independent).
oauth_impersonation — on-behalf-of the caller
MCPG exchanges the caller's inbound bearer for an upstream token (RFC 8693
token exchange), so the upstream sees the end user. Backed by an issuer
plugin that exchanges tokens: oauth-token-exchange (one exchange at a token
service, below) or oauth-id-jag (Okta Cross App Access and other ID-JAG
upstreams, see Cross App Access upstreams). Per-caller
(cached per caller); at
import/listen (no caller) the upstream is listed anonymously, like
pass_through, unless you set a
catalogue credential.
plugins:
- id: dev.mcpg.credential.oauth-token-exchange
class: credential_issuer
source:
oci: "ghcr.io/mcpg-dev/plugins/credential-oauth-token-exchange:protocol-1"
config:
providers:
notion:
token_url: https://sts.example.com/oauth/token # the STS
client_id: mcpg-gateway
client_secret: "${env.STS_CLIENT_SECRET}" # optional
audience: https://notion-mcp.example.com
mcp:
federations:
- name: notion
upstream:
url: https://notion-mcp.example.com/mcp
auth:
mode: oauth_impersonation
credential: cred://dev.mcpg.credential.oauth-token-exchange/notion
Security review for impersonation: the exchanged token is user-scoped — vet the STS audience/scope and the
minimum_trustyou require before enabling it against an upstream. Subject and exchanged tokens stay inside the issuer plugin and are never logged.
Impersonation requires a verified caller that signed in with a token
an external identity provider issued. A bearer the gateway minted itself
(the embedded authorization server's access tokens for
enterprise-managed authorization, the supervised inspector's credential)
is refused before any request to the token service or the upstream,
also when the IdP sets principal_issuer: only this gateway can
validate it. For those callers, exchange their
stored enterprise sign-in instead, or authenticate the
upstream with service_token or oauth_client_credentials.
Cross App Access upstreams (oauth-id-jag)
An upstream MCP server whose authorization server redeems ID-JAGs (it
supports enterprise-managed authorization,
Okta Cross App Access) takes a token that the enterprise IdP vouched for. The
dev.mcpg.credential.oauth-id-jag issuer gets it in two hops, per caller:
- At the enterprise IdP (
idp_token_url), an RFC 8693 token exchange:grant_type=urn:ietf:params:oauth:grant-type:token-exchange,requested_token_type=urn:ietf:params:oauth:token-type:id-jag, theaudience(the upstream authorization server's issuer),resourceandscopewhen set, the subject token with itssubject_token_type, and the optionalactor_token. A response whoseissued_token_typeis not the ID-JAG type is refused. - At the upstream authorization server (
redeem_token_url), theurn:ietf:params:oauth:grant-type:jwt-bearergrant with the ID-JAG asassertion, plusresourceandscope. The access token it returns is the credential.
# The stored sign-in (subject_token below) needs the gateway's login client.
governance:
access:
require_authentication: true
resource_metadata:
resource: "https://mcp.acme.example/mcp"
authorization_server:
issuer: "https://mcp.acme.example"
signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
trusted_idps:
- issuer: "https://acme.okta.com"
allowed_hosts: ["acme.okta.com"]
allowed_algs: ["RS256"]
login:
client_id: "0oa1mcpgagent"
private_key: "${env.OKTA_AGENT_KEY}"
key_id: "mcpg-2026-09"
clients:
- client_id: "acme-mcp-cli" # a client that signs its users in
token_endpoint_auth_method: none
redirect_uris: ["http://127.0.0.1/callback"]
plugins:
- id: dev.mcpg.credential.oauth-id-jag
class: credential_issuer
source:
oci: "ghcr.io/mcpg-dev/plugins/credential-oauth-id-jag:protocol-1"
granted_capabilities: [network_outbound]
config:
providers:
vendor:
# Hop 1: the enterprise IdP. For Okta, the org token endpoint.
idp_token_url: "https://acme.okta.com/oauth2/v1/token"
idp_issuer: "https://acme.okta.com"
client_id: "0oa1mcpgagent" # the login client
client_auth: private_key_jwt # client_secret_basic | client_secret_post | private_key_jwt
private_key: "${env.OKTA_AGENT_KEY}" # the login key: an RSA PKCS#8 PEM, so RS256
key_id: "mcpg-2026-09"
subject_token_type: refresh_token # id_token | refresh_token | saml2 | access_token | jwt
audience: "https://auth.vendor.example" # the upstream AS issuer
resource: "https://mcp.vendor.example/mcp"
scopes: ["tools.read"]
# Hop 2: the upstream authorization server.
redeem_token_url: "https://auth.vendor.example/oauth/token"
redeem_client_id: "acme-mcpg"
redeem_client_auth: private_key_jwt
redeem_private_key: "${env.VENDOR_CLIENT_KEY}"
redeem_key_id: "acme-mcpg-1"
mcp:
federations:
- name: vendor
upstream:
url: "https://mcp.vendor.example/mcp"
auth:
mode: oauth_impersonation
credential: "cred://dev.mcpg.credential.oauth-id-jag/vendor"
subject_token: idp_refresh_token # the stored sign-in; see below
The interactive sign-in license (sso.interactive_login) and the rest of the
login block are on Interactive sign-in.
| Provider key | Required | Meaning |
|---|---|---|
idp_token_url | yes | Hop 1 endpoint |
idp_issuer | no | The IdP issuer: the assertion audience with assertion_audience: issuer, and checked against the stored sign-in |
client_id, client_secret | client_id | The gateway's client at the IdP |
client_auth | no | client_secret_basic, client_secret_post or private_key_jwt. Unset: client_id (and client_secret) in the form body |
private_key, key_id, signing_alg | private_key for private_key_jwt | PEM key, kid, and RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384 or EdDSA. Unset, signing_alg is the one the key type implies (RSA: RS256, P-256: ES256, P-384: ES384, Ed25519: EdDSA); a value the key cannot sign with refuses to load |
assertion_audience | no | token_endpoint (default: the hop-1 URL, as Okta requires) or issuer |
audience | yes | The upstream authorization server's issuer: the ID-JAG aud |
resource, scopes | no | RFC 8707 resource of the upstream MCP server; scopes to request |
subject_token_type | yes | id_token, refresh_token, saml2, access_token or jwt (short name or URN) |
actor_token, actor_token_type | no | A static RFC 8693 actor token; set both or neither |
redeem_token_url | yes | Hop 2 endpoint |
redeem_client_id, redeem_client_secret, redeem_client_auth, redeem_private_key, redeem_key_id, redeem_signing_alg, redeem_assertion_audience | no | The gateway's client at the upstream authorization server, as for hop 1 (redeem_assertion_audience: issuer uses audience) |
allow_insecure_http, allow_private_network | no | Development only |
timeout_ms | no | Per hop; default 5000, 100–60000 |
target_template serves a fleet from one block: allowed_targets (required)
bounds the targets, and audience_template, resource_template and
redeem_token_url_template expand {target} or {target_slug}.
- Which callers. The caller must be verified. With the default
subject_token: caller_bearer, the caller's own bearer is the subject token, so it must be a token the IdP accepts for exchange; Okta takes an ID token or a refresh token, not an SSO access token. A bearer the gateway minted (an ID-JAG caller, an interactive sign-in, the supervised inspector) is refused before any request. Present the user's stored sign-in instead, which works for every caller the IdP knows. - The login key. With the stored sign-in,
private_keyandkey_idare the login block's. The provider takes its algorithm from the key type, as the login block does, so the login key needs nosigning_alghere. - Egress. Both endpoints must be
https, must not resolve to private, loopback or link-local addresses, and must carry no user info or fragment, unless the provider setsallow_insecure_httporallow_private_network. - Per-call values. A registry-synced federation may pass values from OAuth
discovery: an
audiencemust equal the configured one, aredeem_token_urlmust share the configured origin, and aresourcereplaces the configured one. - Errors. An upstream error body is never echoed; the failure names the hop
and the OAuth
errorcode. Subject tokens, ID-JAGs and client credentials are never logged. - Checked at boot, not by
mcpg config check. The check does not read pluginconfig:blocks, and this plugin ignores keys it does not know, so a misspelled optional key is silently dropped. Compare each key with the table above. - License. The plugin needs the
secrets.dynamicfeature (Team, Enterprise); the stored sign-in needssso.interactive_login(Enterprise). - Metrics.
mcpg_oauth_id_jag_total{provider},mcpg_oauth_id_jag_latency_ms{provider,hop}(hopisexchangeorredeem) andmcpg_oauth_id_jag_error_total{provider,hop}.
The caller's stored enterprise sign-in (subject_token)
When users sign in to the gateway itself
(interactive sign-in, a login
block on a governance.access.authorization_server.trusted_idps entry),
the gateway keeps each user's IdP sign-in: one per user, for every MCP
client of that user. It is also how callers that reached the gateway with an
ID-JAG (enterprise-managed authorization)
are served by Cross App Access upstreams. upstream.auth.subject_token makes an
oauth_impersonation federation exchange that sign-in instead of the
caller's bearer:
subject_token | Subject token the issuer exchanges |
|---|---|
caller_bearer (default) | the caller's own bearer |
idp_refresh_token | the user's stored IdP refresh token (what Okta Cross App Access takes) |
idp_id_token | an ID token of the stored sign-in, refreshed at the IdP first when it has less than 60 s left |
governance:
access:
require_authentication: true
resource_metadata:
resource: "https://mcp.acme.example/mcp"
authorization_server:
issuer: "https://mcp.acme.example"
signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
trusted_idps:
- issuer: "https://acme.okta.com"
allowed_hosts: ["acme.okta.com"]
login: # the stored sign-in comes from here
client_id: "0oa1mcpgagent"
private_key: "${env.OKTA_AGENT_KEY}"
clients:
- client_id: "acme-mcp-cli"
token_endpoint_auth_method: none
redirect_uris: ["http://127.0.0.1/callback"]
mcp:
federations:
- name: partner
upstream:
url: https://mcp.partner.example/mcp
auth:
mode: oauth_impersonation
credential: cred://dev.mcpg.credential.oauth-id-jag/partner
subject_token: idp_refresh_token
import:
mode: oauth_client_credentials
credential: cred://dev.mcpg.credential.oauth-client-credentials/partner-catalogue
- Every caller of the user presents it: a client that signed in to the
gateway, a client that redeemed an ID-JAG here, and an SSO caller the
IdP's
principal_issuerjoins to the same user. The gateway picks the sign-in by the caller's principal; the caller's bearer is never sent. - The stored token goes only to the IdP that issued it.
credentialmust namedev.mcpg.credential.oauth-id-jagordev.mcpg.credential.oauth-token-exchange(directly, or as therefof aplugins[]entry): validation refuses any other issuer plugin, which could send the token anywhere, and the gateway refuses one again at call time, before the sign-in is read. Those two refuse the call before any request unless the provider'sidp_token_url(token_url) andclient_idare the gateway's login client's, andidp_issuer(sts_issuer), when set, is its IdP.mcpg config checkwarns about such a mismatch. - A caller with no stored sign-in, for example a client that only ever
redeemed ID-JAGs, is refused before any request, and so is a caller
that is not verified. The failed call names where the user stores one:
{issuer}/oauth/connect, a page that signs the user in at the IdP once (idp_sessions.connect_page, on by default). - Only a caller whose principal a sign-in through the login IdP is
stored under can ever present one: an ID-JAG caller of that IdP (of any
tenant, unless
required_tenantpins one), or an SSO caller of the provider itsprincipal_issuernames. Any other caller (another IdP, an SSO provider noprincipal_issuerjoins, the supervised inspector) is told that none can be stored for them, without the connect page or a link. - A client that declares URL-mode elicitation (
elicitation.url) gets a link to store the sign-in instead of the failedtools/call(URL-mode elicitation). - The upstream token is cached per user and sign-in, so the IdP rotating the stored refresh token costs no new exchange.
- It needs interactive sign-in: validation refuses
idp_*without aloginblock, and insideauth.import, whose sessions have no caller. The catalogue sessions connect anonymously unlessauth.importis set. - Registry-synced federations accept it in
defaults.authandservers.<name>.auth.
A link to store the sign-in (URL-mode elicitation)
When a verified caller has no stored sign-in and its client declared
elicitation.url, the tools/call answers with a link that stores it,
in the shape of the negotiated MCP version:
| Version | Answer | How the client continues |
|---|---|---|
2025-11-25 | JSON-RPC error -32042 whose data.elicitations holds one URL-mode elicitation (mode, elicitationId, url, message); the error's own message repeats that text and the link, for a client that shows only it | The session receives notifications/elicitation/complete with the elicitationId once the user completes the link, and the client retries the call |
2026-07-28 | InputRequiredResult whose inputRequests.connect_sign_in is a URL-mode elicitation/create (no id), with a requestState | The client retries with the requestState and its answer in inputResponses: accept waits up to 10 s (a third of request_timeout_ms at most) for the user's sign-in to be stored; decline or cancel returns the failed call; no answer offers the same link again. A requestState whose link expired runs the call again: it succeeds when the sign-in is stored, else offers a link |
- The link is
{issuer}/oauth/connect?e=<id>. The id is random and names nobody; the link carries no token and does not sign anyone in. - The link is for the caller's principal only. The user opens it, confirms
on the connect page and signs in at the IdP. When the IdP names another
user, nothing is stored, the page says so, and
mcpg.as.connect_refusedis audited. A link is completed once, and lives as long as a sign-in transaction (transaction_ttl_secs). - A retry on the same MCP session is offered the link that session is
pending, so the store holds one link per user and session. A user is
offered at most 20 new links per
transaction_ttl_secs; past that, a call fails with the message that names the connect page. - The
requestStateis sealed with the sign-in state key and bound to the caller and the tool; another user or another tool cannot present it. - The message next to the link names the federation only when its name
is plain (
[A-Za-z0-9._-], at most 128 characters); a registry-synced name with any other text reads as "a federated tool". - No link is offered when
idp_sessions.connect_pageis off, to a caller no sign-in can be stored for (see above), to a caller that is not verified, for a call with an idempotency key, for a call run as a task, or forresources/readandprompts/get; those calls fail with the message that names the connect page, or says none can be stored.
Catalogue credential (auth.import)
Some upstream sessions have no caller: the catalogue import at boot and
reload, the list_changed and TTL refreshes, and the notification
listener. With pass_through and oauth_impersonation there is nothing
to forward or exchange, so these sessions are anonymous by default. An
upstream that requires a token to list its tools (enterprise-managed,
Cross-App Access servers usually do) refuses them, and the federation
imports nothing: MCPG logs a warning that names upstream.auth.import
and counts mcpg_federation_import_failed_total{reason="unauthorized"}.
upstream.auth.import gives those sessions their own credential:
mcp:
federations:
- name: partner
upstream:
url: https://mcp.partner.example/mcp
auth:
mode: oauth_impersonation # tool calls: the caller
credential: cred://dev.mcpg.credential.oauth-id-jag/partner
import: # catalogue: the gateway
mode: oauth_client_credentials # or service_token + token
credential: cred://dev.mcpg.credential.oauth-client-credentials/partner-catalogue
modeisservice_token(withtoken) oroauth_client_credentials(withcredential, and optionallycredential_config). The caller-derived modes and a nestedimportfail validation.- Only the catalogue sessions use it. Tool calls, resource reads and
prompt fetches keep the outer
mode: an impersonated call carries the caller's exchanged token, and a call without a caller bearer never falls back to the catalogue credential. - The imported catalogue is what this credential can list. Per-user
differences in what the upstream shows are not reflected: every caller
sees the same federated catalogue (still narrowed by
filterandgovernance), and a tool the upstream withholds from a caller fails when that caller invokes it. - Registry-synced federations accept
defaults.auth.importandservers.<name>.auth.import.{server}expands insideimport.credentialas it does inauth.credential, so atarget_templateissuer mints a catalogue token per server. OAuth discovery does not fillimport.credential_config, and aservice_tokenindefaults.auth.importgoes to every server the registry lists.
Choosing
| Want | Mode |
|---|---|
| Public upstream | none |
| One shared machine credential | service_token or oauth_client_credentials |
| Auto-refreshing machine OAuth token | oauth_client_credentials |
| Upstream understands your clients' tokens | pass_through |
| Upstream must see the end user (per-user authz/audit) | oauth_impersonation |
| The same, for users who sign in to the gateway | add subject_token: idp_refresh_token |
| A caller-derived mode against an upstream that requires a token to list tools | add auth.import |
Transports
streamable_http (default)
Modern MCP Streamable HTTP (POST + SSE). All HTTP upstreams go through the
gateway's DNS-rebinding / SSRF guard: the upstream host is resolved and
pinned to a validated public address. Private/loopback addresses and http://
are rejected unless explicitly permitted:
mcp:
federations:
- name: local-dev
upstream:
url: http://127.0.0.1:8931/mcp
upstream_safety:
allow_private_backends: true # needed for loopback / RFC-1918
allow_insecure_http: true # needed for http://
A loop-detection header (Mcpg-Upstream-Via) is sent on every upstream request
so an MCPG-federates-MCPG topology can detect cycles.
stdio
Federate a local MCP server run as a child process (JSON-RPC over the
child's stdin/stdout). This spawns an arbitrary local process, so it's a
different threat model than HTTP and is default-deny: you must set
allow_stdio: true.
mcp:
federations:
- name: localtools
upstream:
transport: stdio
command: /usr/local/bin/my-mcp-server
args: ["--stdio"]
env: { API_TOKEN: "${env.UPSTREAM_TOKEN}" }
upstream_safety:
allow_stdio: true
url is unused for stdio. The child is reaped on shutdown / reload. (stdio has
no separate notification channel, so */list_changed pushes between calls are
picked up on the next call or the TTL refresh, not in real time.)
Reaching a private tunnel (tunnel://)
A federation upstream can point at a gateway that binds no public port — one
running in a customer's own infrastructure and reachable only through an MCPG
relay. Write the URL as tunnel://<name>/<path> and configure the relay ingress
once:
gateway:
server:
tunnel_federation:
relay_ingress_url: "https://relay.tunnels.mcpg.cloud"
token: "${env.MCPG_TUNNEL_TOKEN}" # authenticates your org to the relay
mcp:
federations:
- name: acme-crm
upstream:
url: "tunnel://acme-crm/mcp" # resolves through the relay to the private tunnel
import: { tools: true }
naming: { tool_prefix: "acme." }
This is reverse federation: the tunnelled gateway holds the data-source
credentials and network access, and this gateway re-exports its tools without
ever holding those secrets. Everything else on this page — prefixing,
filtering, minimum_trust, allow_if, impersonation, list_changed refresh —
applies unchanged; a tunnel:// upstream is just a streamable_http upstream
whose transport is a private tunnel. The full setup (the customer-side
mcpg --tunnel --private, the two tokens, the security model) is in the
reverse tunnels guide.
Runtime behaviour
Protocol versions — federation as a wire adapter
The wire MCPG serves each client and the wire it speaks to each
upstream are independent. Clients negotiate their revision per request
(see protocol versions);
upstream.protocol_version selects the upstream side. One federation entry
therefore adapts a remote MCP server to whichever revision each caller
uses, in both directions: a legacy (2025-11-25) server gets a stateless
2026-07-28 face — MCPG holds its session and handshake — and a modern
stateless server stays usable by legacy clients — MCPG answers
initialize and holds the client session.
With the default protocol_version: auto, MCPG detects the upstream's
revision at connect: it attempts the modern server/discover and, when the
peer rejects it, falls back to the legacy initialize handshake. The
verdict is cached per federation, so later connections skip the probe. Pin
a dated revision to skip probing entirely.
Staying fresh
Two refresh triggers keep the federated catalog current:
- Push: a persistent listener reacts to upstream catalog-change pushes
and re-imports that federation. On a legacy upstream this is the
standalone
GETSSE stream; on a modern (2026-07-28) upstream it is a long-livedsubscriptions/listenstream (an upstream without the method degrades quietly to poll-only). (HTTP transports only.) - Poll:
cache.capability_ttl_secsre-imports on an interval — the fallback for upstreams that never push (and for stdio).
Either trigger broadcasts list_changed to your connected clients only
for the kinds whose client-visible catalog actually changed (a
descriptor-level diff against the previous import) — a TTL poll that
detects a change notifies clients just like a push does, and upstream
churn that your filters make invisible wakes nobody.
A single upstream's change re-imports only that federation; the others keep their capabilities.
Resource subscriptions
If a client resources/subscribes to a federated resource, an upstream
notifications/resources/updated is re-prefixed and forwarded to that
subscriber. When the upstream cannot push resource updates (the modern
wire's listener carries only catalog changes; stdio has no push channel),
the synthesize block manufactures them: the first subscriber starts a
poll watcher that re-reads the resource through the normal federated
dispatch every poll_interval_ms and emits resources/updated when the
content hash changes; the last unsubscribe stops it. Polling runs under
the gateway's machine identity — per-caller upstream resources are not
poll-watchable.
Server-request bridging (sampling / elicitation / roots)
If an upstream needs to sample an LLM, elicit user input, or list roots during
a tool call, MCPG bridges the request to the real client and the answer
back. MCPG advertises to the upstream only the capabilities the downstream
session actually supports (so it never surfaces a request the client can't
handle). Upstream notifications/progress is forwarded too, correlated to the
client's own progress token.
Sessions and reload
Dispatch uses one satellite (an upstream session) per (caller, federation), torn down after session.idle_timeout_secs idle. The caller
key is the authenticated principal — stable across that principal's
sessions and gateway replicas — falling back to the session id for
anonymous callers; for pass_through / oauth_impersonation a
fingerprint of the caller's bearer joins the key, so two tokens of the
same principal (scope change, rotation) never share an upstream session.
The upstream credential is re-resolved on every dispatch: a rotated token
replaces the satellite's connection instead of riding the stale one into
an upstream 401. On a config reload, capabilities carry across with no
flicker and are governed by the reloaded config from the first request:
a tightened minimum_trust, required_scopes or allow_if, a newly excluded
tool (filter.exclude_tools) or an import switched off applies before the
re-import finishes. Satellites (and detected wires) for unchanged
federations are reused (no reconnect, no re-probe); changed/removed ones
re-establish.
Observability
| Metric | Meaning |
|---|---|
mcpg_oauth_token_exchange_total{provider} | impersonation token exchanges |
mcpg_oauth_token_exchange_error_total{provider} | failed exchanges |
mcpg_oauth_token_cache_hit_total{provider} | client-credentials cache hits |
mcpg_credential_cache_total{plugin_id,outcome} | host credential-cache hit/miss |
mcpg_federation_import_failed_total{reason} | failed catalogue imports and refreshes; unauthorized = the upstream answered 401/403, error = anything else |
mcpg_federation_subject_token_total{mode,outcome} | calls of idp_refresh_token / idp_id_token federations; used = the issuer exchanged the stored sign-in, not_linked = the caller has none, refused = the caller, the sign-in or the issuer plugin cannot be used, or the issuer refused the exchange, unavailable = the sign-in state, the issuer or the IdP cannot be reached now |
mcpg_federation_connect_link_total{outcome} | links offered to callers with no stored sign-in; offered = a new link, resumed = the pending link of a retry or of the session, limited = the user was offered as many new links as one link lifetime allows, unavailable = the sign-in state could not store one |
mcpg_federation_url_elicitation_total{protocol_version} | tool calls answered with a link, by MCP version |
mcpg_as_connect_total{purpose,outcome} | the connect page; purpose = connect or link, outcome = shown, refused, approved or denied |
mcpg_registry_server_skipped_total{registry,reason} | registry servers left unfederated; oauth_discovery = no metadata ever discovered, no_id_jag_support = an oauth-id-jag server whose AS does not offer jwt-bearer |
mcpg_registry_oauth_discovery_warning_total{registry,reason} | servers federated despite a discovery warning; id_jag_profile_missing = the AS lists jwt-bearer but not the id-jag grant profile |
Federation also emits structured logs (target: mcpg::runtime::federation::…)
for import success/failure, list_changed refresh, and credential resolution
(never the token itself). A call refused for its stored sign-in (every
outcome but used, an exchange the issuer refused or that failed
included) is audited as mcpg.federation.idp_subject_token, with the
federation, the mode and the reason (the issuer's, for an exchange);
never the token. A link completed
by another user than the one it was offered to is audited as
mcpg.as.connect_refused, with the user who signed in and the principal
the link was for; never the link id. See the observability guide
for wiring Prometheus and OTLP.
Best practices
Naming and collisions
- Always set a
tool_prefix/resource_uri_prefix/prompt_prefix— even for a single upstream. It future-proofs against name collisions when you add more federations or native tools, and makes the source obvious to clients. - Keep prefixes short and stable; clients hard-code tool names.
Trust and governance
- Treat a federated upstream as untrusted code: set
minimum_trustto the highest level your callers legitimately have, and add anallow_ifgroup/role gate. Federation inherits — but only what you configure. - Use
filter.exclude_toolsto drop destructive/admin tools you don't want exposed (exclude_tools: ["delete_*", "admin_*"]).
Auth
- Never inline secrets — use
${env.VAR}or acred://…reference; the literal then never appears in YAML or logs. - Prefer
oauth_client_credentialsover a staticservice_token(rotation and expiry come for free). - Reserve
oauth_impersonationfor upstreams that genuinely need per-user identity, and pair it with a highminimum_trust— it hands a user-scoped token to the upstream. pass_throughonly when you trust the upstream with your clients' raw tokens.
Transport safety
- Keep
allow_private_backends/allow_insecure_http/allow_stdiooff unless you specifically need them; each widens the attack surface. - For
stdio, pin an absolutecommandpath and a minimalenv; you own the trust of whatever you spawn.
Sizing and resilience
- Set
response.max_response_bytesto bound a misbehaving upstream. - Tune
cache.capability_ttl_secsdown for upstreams that change often (and can't push), up for stable ones. - A failing upstream is logged and skipped at import — it never takes down the gateway or other federations.
Topology
- You can federate one MCPG through another (the loop-detection header guards cycles) — handy for tiered/edge aggregation.
Worked examples
A. SaaS upstream over HTTP, machine OAuth, verified-only
plugins:
- id: dev.mcpg.credential.oauth-client-credentials
class: credential_issuer
source:
oci: "ghcr.io/mcpg-dev/plugins/credential-oauth-client-credentials:protocol-1"
config:
providers:
notion:
token_url: https://auth.notion.example.com/oauth/token
client_id: mcpg-gateway
client_secret: "${env.NOTION_SECRET}"
scopes: ["read"]
mcp:
federations:
- name: notion
governance:
minimum_trust: verified
allow_if: '"notion" in identity.groups'
upstream:
url: https://notion-mcp.example.com/mcp
auth:
mode: oauth_client_credentials
credential: cred://dev.mcpg.credential.oauth-client-credentials/notion
import:
tools: true
resources: true
prompts: true
naming:
tool_prefix: "notion."
resource_uri_prefix: "mcp://notion/"
prompt_prefix: "notion."
filter:
exclude_tools: ["delete_*"]
B. Local tool server over stdio
mcp:
federations:
- name: localtools
upstream:
transport: stdio
command: /opt/mcp/localtools
args: ["--stdio"]
upstream_safety:
allow_stdio: true
import:
tools: true
naming:
tool_prefix: "local."
C. Per-user impersonation
plugins:
- id: dev.mcpg.credential.oauth-token-exchange
class: credential_issuer
source:
oci: "ghcr.io/mcpg-dev/plugins/credential-oauth-token-exchange:protocol-1"
config:
providers:
drive:
token_url: https://sts.example.com/token
client_id: mcpg
audience: https://drive-mcp.example.com
mcp:
federations:
- name: drive
governance:
minimum_trust: verified
upstream:
url: https://drive-mcp.example.com/mcp
auth:
mode: oauth_impersonation
credential: cred://dev.mcpg.credential.oauth-token-exchange/drive
import:
tools: true
resources: true
naming:
tool_prefix: "drive."
resource_uri_prefix: "mcp://drive/"
Limitations and roadmap
- Wildcard per-tenant federation — not yet implemented.
- stdio notifications — drained during calls / TTL, not pushed in real time (stdio has no standalone notification channel).
Troubleshooting
| Symptom | Likely cause |
|---|---|
Federated tools missing from tools/list | caller below minimum_trust (they're hidden), or import failed — check mcpg::runtime::federation logs |
upstream refused the anonymous catalogue session warning, import fails with HTTP 401 | a pass_through / oauth_impersonation upstream requires a token to list tools; set auth.import |
oauth_* requires auth.credential (a cred:// URI) at boot | set auth.credential to cred://<plugin_id>/<provider> |
no credential_issuer plugin id=… at dispatch | the referenced issuer plugin isn't configured under plugins |
| stdio federation rejected at boot | set upstream_safety.allow_stdio: true and a command |
upstream http://… rejected | set upstream_safety.allow_insecure_http: true (or use https) |
| loopback upstream rejected | set upstream_safety.allow_private_backends: true |
| impersonation tool fails with "subject token" error | the caller presented no inbound bearer to exchange |
See also
- Reverse tunnels — federate a gateway behind NAT over
tunnel://, secrets never leaving its boundary - Configuration reference — full federation config surface
- Identity setup — wire the inbound auth chain that
minimum_trustandallow_ifread - Observability — Prometheus / OTLP / audit