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."
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), default governance (inherits the gateway default trust).
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: gateway default)
allow_if: "identity.has_group('notion-users')" # optional CEL; same engine as native per-tool rules
retry: # optional; upstream call retry
max_attempts: 2
backoff_ms: 500
upstream:
url: https://notion-mcp.example.com/mcp # required for streamable_http / legacy_sse; omit for stdio
transport: streamable_http # streamable_http (default) | stdio | legacy_sse (not yet — Phase 4-B)
# 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
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
naming: # prefixes applied to imported names/URIs (collision-avoidance)
tool_prefix: "notion."
resource_uri_prefix: "mcp://notion/"
prompt_prefix: "notion."
filter: # glob filter on imported TOOL names
include_tools: ["*"] # default ["*"]
exclude_tools: ["internal_*"]
cache:
capability_ttl_secs: 300 # poll-refresh interval (re-list even without a push); default 300
session:
mode: per_client # per_client (default). `shared` not yet supported.
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.
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 |
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:
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.
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).allow_if— a CEL expression evaluated per call against the caller's identity (groups, roles, claims). Same engine and semantics as native per-toolallow_if.
governance:
minimum_trust: verified
allow_if: "identity.has_group('notion-users') && !request.tool.endsWith('.delete_page')"
This is enforced at dispatch by the same policy gate that guards native tools — federation is not a governance bypass.
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:
upstream:
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.
upstream:
auth: { mode: pass_through }
Use when the upstream already understands your clients' tokens.
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/source-code/plugins/credential-oauth-client-credentials:1.0.0"
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 the
oauth-token-exchange issuer plugin. Per-caller (cached per caller); at
import/listen (no caller) the upstream is listed anonymously, like
pass_through.
plugins:
- id: dev.mcpg.credential.oauth-token-exchange
class: credential_issuer
source:
oci: "ghcr.io/mcpg-dev/source-code/plugins/credential-oauth-token-exchange:1.0.0"
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.
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 |
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:
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.
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.)
legacy_sse
The pre-Streamable HTTP+SSE transport — not yet implemented (Phase 4-B); configuring it is rejected at validation.
Runtime behaviour
Staying fresh
Two refresh triggers keep the federated catalog current:
- Push: a persistent listener reacts to upstream
notifications/{tools,resources,prompts}/list_changed, re-imports that federation, and broadcasts the samelist_changedto your connected clients. (HTTP transports only.) - Poll:
cache.capability_ttl_secsre-imports on an interval — the fallback for upstreams that never push (and for stdio).
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.
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 (client session, federation), torn down after session.idle_timeout_secs idle. On a config
reload, capabilities plus governance carry across with no flicker, and
satellites for unchanged federations are reused (no reconnect);
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 |
Federation also emits structured logs (target: mcpg::runtime::federation::…)
for import success/failure, list_changed refresh, and credential resolution
(never the token itself). 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/source-code/plugins/credential-oauth-client-credentials:1.0.0"
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: "identity.has_group('notion')"
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/source-code/plugins/credential-oauth-token-exchange:1.0.0"
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
legacy_ssetransport — not yet (Phase 4-B). Usestreamable_http.- Wildcard per-tenant federation — not yet (Phase 4-C).
session.mode: shared— not yet; onlyper_client.- 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 |
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
- Configuration reference — full federation config surface
- Identity setup — wire the inbound auth chain that
minimum_trustandallow_ifread - Observability — Prometheus / OTLP / audit