Gateway
Gateway12 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."
      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:

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

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

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

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

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

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

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

yaml
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 arguments those use, and closes it sources.linger_ms after 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 429 with Retry-After, and one after it gets 410. When the allowance comes back, each upstream subscription resumes from its stored cursor. See Plan limits.
  • Read in the federation's mode. events.delivery: auto takes 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, push and poll insist 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 is gateway.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 answers 200 only 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 gets 410. 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_in an upstream subscription serves every subscriber of its arguments, so it is made as the gateway, with auth.import (or auth when that needs no caller), never as one of them, and downstream access is MCPG's own policy on the imported type. With sharing: per_principal each subscriber gets an upstream subscription of their own, held with the upstream credential the federation mints from their stored sign-in (auth.mode: oauth_impersonation with subject_token: idp_refresh_token or idp_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 e1 of federation gh is served as gh:e1, so ids two upstreams chose alike stay apart at a receiver that deduplicates on webhook-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. A 429, the upstream's request ceiling, is retried within seconds.
  • Follows the upstream's list. A notifications/events/list_changed from the upstream, or the TTL refresh, re-imports the types, and MCPG tells its own clients with notifications/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 from tools/list for that caller (visibility honours trust). Unset, it is header_asserted: governance.policy.tool_access.default_minimum_trust does 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) and tool_name, the prefixed name the client called. Same engine and semantics as native per-tool allow_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 with 403 and the insufficient_scope step-up challenge naming the missing scopes, and does not see the capability in its lists.
yaml
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 whose tool_name is the federated tool or prompt name, the URI a client reads, or the URI or uriTemplate the 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 search tightens a federated tool search, while a native prompt, resource or template binding named search does 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.

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

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

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

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

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

  1. 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, the audience (the upstream authorization server's issuer), resource and scope when set, the subject token with its subject_token_type, and the optional actor_token. A response whose issued_token_type is not the ID-JAG type is refused.
  2. At the upstream authorization server (redeem_token_url), the urn:ietf:params:oauth:grant-type:jwt-bearer grant with the ID-JAG as assertion, plus resource and scope. The access token it returns is the credential.
yaml
# 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 keyRequiredMeaning
idp_token_urlyesHop 1 endpoint
idp_issuernoThe IdP issuer: the assertion audience with assertion_audience: issuer, and checked against the stored sign-in
client_id, client_secretclient_idThe gateway's client at the IdP
client_authnoclient_secret_basic, client_secret_post or private_key_jwt. Unset: client_id (and client_secret) in the form body
private_key, key_id, signing_algprivate_key for private_key_jwtPEM 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_audiencenotoken_endpoint (default: the hop-1 URL, as Okta requires) or issuer
audienceyesThe upstream authorization server's issuer: the ID-JAG aud
resource, scopesnoRFC 8707 resource of the upstream MCP server; scopes to request
subject_token_typeyesid_token, refresh_token, saml2, access_token or jwt (short name or URN)
actor_token, actor_token_typenoA static RFC 8693 actor token; set both or neither
redeem_token_urlyesHop 2 endpoint
redeem_client_id, redeem_client_secret, redeem_client_auth, redeem_private_key, redeem_key_id, redeem_signing_alg, redeem_assertion_audiencenoThe gateway's client at the upstream authorization server, as for hop 1 (redeem_assertion_audience: issuer uses audience)
allow_insecure_http, allow_private_networknoDevelopment only
timeout_msnoPer 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_key and key_id are the login block's. The provider takes its algorithm from the key type, as the login block does, so the login key needs no signing_alg here.
  • 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 sets allow_insecure_http or allow_private_network.
  • Per-call values. A registry-synced federation may pass values from OAuth discovery: an audience must equal the configured one, a redeem_token_url must share the configured origin, and a resource replaces the configured one.
  • Errors. An upstream error body is never echoed; the failure names the hop and the OAuth error code. Subject tokens, ID-JAGs and client credentials are never logged.
  • Checked at boot, not by mcpg config check. The check does not read plugin config: 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.dynamic feature (Team, Enterprise); the stored sign-in needs sso.interactive_login (Enterprise).
  • Metrics. mcpg_oauth_id_jag_total{provider}, mcpg_oauth_id_jag_latency_ms{provider,hop} (hop is exchange or redeem) and mcpg_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_tokenSubject token the issuer exchanges
caller_bearer (default)the caller's own bearer
idp_refresh_tokenthe user's stored IdP refresh token (what Okta Cross App Access takes)
idp_id_tokenan ID token of the stored sign-in, refreshed at the IdP first when it has less than 60 s left
yaml
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_issuer joins 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. credential must name dev.mcpg.credential.oauth-id-jag or dev.mcpg.credential.oauth-token-exchange (directly, or as the ref of a plugins[] 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's idp_token_url (token_url) and client_id are the gateway's login client's, and idp_issuer (sts_issuer), when set, is its IdP. mcpg config check warns 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_tenant pins one), or an SSO caller of the provider its principal_issuer names. Any other caller (another IdP, an SSO provider no principal_issuer joins, 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 failed tools/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 a login block, and inside auth.import, whose sessions have no caller. The catalogue sessions connect anonymously unless auth.import is set.
  • Registry-synced federations accept it in defaults.auth and servers.<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:

VersionAnswerHow the client continues
2025-11-25JSON-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 itThe session receives notifications/elicitation/complete with the elicitationId once the user completes the link, and the client retries the call
2026-07-28InputRequiredResult whose inputRequests.connect_sign_in is a URL-mode elicitation/create (no id), with a requestStateThe 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_refused is 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 requestState is 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_page is 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 for resources/read and prompts/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:

yaml
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
  • mode is service_token (with token) or oauth_client_credentials (with credential, and optionally credential_config). The caller-derived modes and a nested import fail 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 filter and governance), and a tool the upstream withholds from a caller fails when that caller invokes it.
  • Registry-synced federations accept defaults.auth.import and servers.<name>.auth.import. {server} expands inside import.credential as it does in auth.credential, so a target_template issuer mints a catalogue token per server. OAuth discovery does not fill import.credential_config, and a service_token in defaults.auth.import goes to every server the registry lists.

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
The same, for users who sign in to the gatewayadd subject_token: idp_refresh_token
A caller-derived mode against an upstream that requires a token to list toolsadd 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:

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

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

yaml
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 GET SSE stream; on a modern (2026-07-28) upstream it is a long-lived subscriptions/listen stream (an upstream without the method degrades quietly to poll-only). (HTTP transports only.)
  • Poll: cache.capability_ttl_secs re-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

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
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_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/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

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/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

SymptomLikely cause
Federated tools missing from tools/listcaller 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 401a pass_through / oauth_impersonation upstream requires a token to list tools; set auth.import
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