mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
95 lines
4.6 KiB
Markdown
95 lines
4.6 KiB
Markdown
# 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`](../../src/emcp_bus/downstream/models.py),
|
|
[`downstream/identity.py`](../../src/emcp_bus/downstream/identity.py). See
|
|
[`AS-BUILT.md`](../AS-BUILT.md#downstream--outbound-identity-ep-07).
|
|
|
|
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`:
|
|
|
|
```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
|
|
```
|
|
|
|
```yaml
|
|
# 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`) needs
|
|
`attributes.standard.token.exchange.enabled = "true"` and `serviceAccountsEnabled`.
|
|
- `audience` must be the id of a client **actually registered** in the realm — a free-form string
|
|
fails with `"Audience not found"`.
|
|
- Requesting `audience` without also requesting, via `scope`, a client scope that actually adds
|
|
that audience fails with `"Requested audience not available"` — the `audience` parameter only
|
|
**restricts**, never **adds**. That is why `token_exchange_scope` is mandatory in this mode.
|
|
- That client scope must carry an `oidc-audience-mapper` pointing at the backend's `audience`,
|
|
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`)
|
|
|
|
```python
|
|
BackendCredential(
|
|
backend="finance-domain",
|
|
url="http://finance-domain:8101/mcp",
|
|
token="...",
|
|
audience="finance-domain-service",
|
|
)
|
|
```
|
|
|
|
## How to integrate a new backend
|
|
|
|
1. Register the backend with the IdP if using `token_exchange` (client scope +
|
|
`oidc-audience-mapper` for the chosen `audience`) — or simply define the static token's env
|
|
var if using `service_account`.
|
|
2. Create `config/backends/<backend>.yaml` (`BackendConfig`).
|
|
3. Reference `backend.service` == that same id in every `CapabilityManifest` that routes to
|
|
it (see [`registry-capability-manifest.md`](registry-capability-manifest.md)).
|
|
4. If the backend is MCP-native (`backend.type: mcp`, the only one with live dispatch in this
|
|
RI), implement the `BackendCredentialGate` on the backend side (see
|
|
`services/example_mcp_servers/sales_domain/server.py` as 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. |
|