4.6 KiB
Interface: downstream / outbound identity
Outbound identity contract, for whoever is going to integrate a new backend/domain server.
Implementation: src/emcp_bus/downstream/models.py,
downstream/identity.py. See
AS-BUILT.md.
Principle: inbound token (MCP Client → Gateway) ≠ outbound token (Fabric → Backend). The Gateway
authenticates the caller; this contract resolves a separate question — which credential the
Fabric presents to the backend — and never resolves it by forwarding the inbound token
unmodified.
Registering a backend (BackendConfig)
One YAML file per backend in config/backends/*.yaml:
# service_account (default mode of the seed backends)
backend: sales-domain
url: http://sales-domain:8100/mcp
audience: sales-domain-service # never the same as the Gateway's inbound OIDC_AUDIENCE
credential_mode: service_account
service_account_token_env_var: SALES_DOMAIN_SERVICE_TOKEN # env var name, never a literal value
# token_exchange (available alternative, verified against real Keycloak)
backend: finance-domain
url: http://finance-domain:8101/mcp
audience: finance-domain-service
credential_mode: token_exchange
token_exchange_endpoint: http://keycloak:8080/realms/emcp/protocol/openid-connect/token
token_exchange_scope: aud-finance-domain-service
service_account mode
Static credential, read from an environment variable (name, never value, declared in
service_account_token_env_var) at the time of each call. Absence of the variable at runtime →
BackendCredentialUnavailableError.
token_exchange mode (RFC 8693, Keycloak Standard Token Exchange)
Two HTTP round trips per new exchange (TokenExchangeClient.exchange): (1) client_credentials
as the Gateway's own exchange client, producing a subject_token; (2) exchanging that
subject_token for a token with the target backend's audience. Cached per backend until
shortly before the exchange's own expires_in — a cache hit costs zero round trips.
Required IdP configuration (empirically verified against real Keycloak 26.7.3):
- The Gateway's exchange client (
GATEWAY_TOKEN_EXCHANGE_CLIENT_ID/_SECRET) needsattributes.standard.token.exchange.enabled = "true"andserviceAccountsEnabled. audiencemust be the id of a client actually registered in the realm — a free-form string fails with"Audience not found".- Requesting
audiencewithout also requesting, viascope, a client scope that actually adds that audience fails with"Requested audience not available"— theaudienceparameter only restricts, never adds. That is whytoken_exchange_scopeis mandatory in this mode. - That client scope must carry an
oidc-audience-mapperpointing at the backend'saudience, and be a default scope (or requested optional scope) of the Gateway's exchange client.
See deploy/keycloak/realm-export.json for a real, already-configured example (gateway-token-exchange
- client scopes
aud-sales-domain-service/aud-finance-domain-service).
Outbound contract (BackendCredential)
BackendCredential(
backend="finance-domain",
url="http://finance-domain:8101/mcp",
token="...",
audience="finance-domain-service",
)
How to integrate a new backend
- Register the backend with the IdP if using
token_exchange(client scope +oidc-audience-mapperfor the chosenaudience) — or simply define the static token's env var if usingservice_account. - Create
config/backends/<backend>.yaml(BackendConfig). - Reference
backend.service== that same id in everyCapabilityManifestthat routes to it (seeregistry-capability-manifest.md). - If the backend is MCP-native (
backend.type: mcp, the only one with live dispatch in this RI), implement theBackendCredentialGateon the backend side (seeservices/example_mcp_servers/sales_domain/server.pyas a reference) — a request without the Gateway's outbound credential should never reach a tool handler.
Errors
| Situation | Exception | Handling by the caller (Gateway) |
|---|---|---|
backend without a registered BackendConfig |
UnknownBackendError |
UnroutableCapabilityError in the Fabric → UNROUTABLE_CAPABILITY. |
service_account env var missing at runtime |
BackendCredentialUnavailableError |
backend_credential_unavailable (NOC event) → call denied. |
| RFC 8693 exchange rejected by the IdP | TokenExchangeError → re-raised as BackendCredentialUnavailableError |
Same as above. |