MCPG
Guides
Guides12 min

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.

text
  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.:

yaml
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:

yaml
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):

Surfaceimport flagPrefixed byDispatched via
Toolstoolstool_prefixtools/call
Resourcesresourcesresource_uri_prefixresources/read
Resource templatesresource_templatesresource_uri_prefixresources/read (URI matched, de-prefixed)
Promptspromptsprompt_prefixprompts/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:

yaml
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_trustunauthenticated < header_asserted < verified. A caller below the bar can't call the federated tool — and the tool is hidden from tools/list for 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-tool allow_if.
yaml
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:

yaml
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.

yaml
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.

yaml
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.

yaml
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_trust you require before enabling it against an upstream. Subject and exchanged tokens stay inside the issuer plugin and are never logged.

Choosing

WantMode
Public upstreamnone
One shared machine credentialservice_token or oauth_client_credentials
Auto-refreshing machine OAuth tokenoauth_client_credentials
Upstream understands your clients' tokenspass_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:

yaml
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.

yaml
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 same list_changed to your connected clients. (HTTP transports only.)
  • Poll: cache.capability_ttl_secs re-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

MetricMeaning
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_trust to the highest level your callers legitimately have, and add an allow_if group/role gate. Federation inherits — but only what you configure.
  • Use filter.exclude_tools to drop destructive/admin tools you don't want exposed (exclude_tools: ["*_delete", "admin_*"]).

Auth

  • Never inline secrets — use ${env.VAR} or a cred://… reference; the literal then never appears in YAML or logs.
  • Prefer oauth_client_credentials over a static service_token (rotation and expiry come for free).
  • Reserve oauth_impersonation for upstreams that genuinely need per-user identity, and pair it with a high minimum_trust — it hands a user-scoped token to the upstream.
  • pass_through only when you trust the upstream with your clients' raw tokens.

Transport safety

  • Keep allow_private_backends / allow_insecure_http / allow_stdio off unless you specifically need them; each widens the attack surface.
  • For stdio, pin an absolute command path and a minimal env; you own the trust of whatever you spawn.

Sizing and resilience

  • Set response.max_response_bytes to bound a misbehaving upstream.
  • Tune cache.capability_ttl_secs down 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

yaml
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

yaml
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

yaml
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_sse transport — not yet (Phase 4-B). Use streamable_http.
  • Wildcard per-tenant federation — not yet (Phase 4-C).
  • session.mode: shared — not yet; only per_client.
  • stdio notifications — drained during calls / TTL, not pushed in real time (stdio has no standalone notification channel).

Troubleshooting

SymptomLikely cause
Federated tools missing from tools/listcaller below minimum_trust (they're hidden), or import failed — check mcpg::runtime::federation logs
oauth_* requires auth.credential (a cred:// URI) at bootset auth.credential to cred://<plugin_id>/<provider>
no credential_issuer plugin id=… at dispatchthe referenced issuer plugin isn't configured under plugins
stdio federation rejected at bootset upstream_safety.allow_stdio: true and a command
upstream http://… rejectedset upstream_safety.allow_insecure_http: true (or use https)
loopback upstream rejectedset upstream_safety.allow_private_backends: true
impersonation tool fails with "subject token" errorthe caller presented no inbound bearer to exchange

See also