M0 e M1 revisados e fixes implementados

This commit is contained in:
Marcos Lohmann
2026-09-14 08:18:53 -03:00
parent 439f60598e
commit f3efcc3414
72 changed files with 4831 additions and 128 deletions

52
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: RI CI
# EP-00-T03 (docs/RI-PLANNING.md): lint, type-check, validate every config/
# YAML against its JSON Schema/Pydantic model, then run the test suite.
# Acceptance criterion: a YAML under config/ that violates its schema must
# fail CI before any integration test runs -- `validate-schemas` is
# deliberately its own early step, ahead of `test`, not folded into it.
on:
push:
branches: [main]
pull_request:
defaults:
run:
working-directory: RI
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install OPA (static binary, no Docker needed -- PDP for real e2e tests)
run: |
curl -sL -o /usr/local/bin/opa "https://openpolicyagent.org/downloads/v1.20.2/opa_linux_amd64_static"
chmod +x /usr/local/bin/opa
opa version
- name: Install project (editable, dev extras)
run: make install
- name: Lint (ruff)
run: make lint
- name: Type-check (mypy --strict)
run: make typecheck
- name: Validate config/ YAML against its schema
run: make validate-schemas
- name: Schemas/*.schema.json are up to date with the Pydantic models
run: |
make export-schemas
git diff --exit-code -- schemas/
- name: Test suite (real OPA/JWKS/Docker/Keycloak; --forked, see RI/README.md)
run: .venv/bin/python3 -m pytest -q --forked

View File

@@ -12,8 +12,53 @@ SALES_DOMAIN_PORT=8100
# use 127.0.0.1 for `make dev`, which runs both processes on the host). # use 127.0.0.1 for `make dev`, which runs both processes on the host).
SALES_DOMAIN_URL=http://sales-domain:8100/mcp SALES_DOMAIN_URL=http://sales-domain:8100/mcp
# --- Example Finance-domain MCP server (EP-06-T03) ---
FINANCE_DOMAIN_HOST=0.0.0.0
FINANCE_DOMAIN_PORT=8101
FINANCE_DOMAIN_URL=http://finance-domain:8101/mcp
# --- Observability (EP-08) --- # --- Observability (EP-08) ---
# Unset (default): traces print to stdout via ConsoleSpanExporter. # Unset (default): traces print to stdout via ConsoleSpanExporter.
# Set to enable OTLP/HTTP export instead (requires the `emcp-bus[otlp]` extra # Set to "http://otel-collector:4318" and run with `--profile observability`
# and a running collector -- see EP-08-T04). # to export real OTLP traces to the local otel-collector + Jaeger
# (EP-08-T04; UI at http://localhost:16686). Requires the `emcp-bus[otlp]`
# extra (already installed in the Docker image, see deploy/docker/Dockerfile).
# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 # OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
# Where the Gateway and example domain servers append audit events
# (EP-08-T02) -- JSONL, append-only, never contains raw token/secret values.
EMCP_AUDIT_LOG_PATH=/tmp/emcp-audit.jsonl
# TTL (milliseconds) the Gateway hints clients to cache a `tools/list`
# response for (EP-05-T03, SEP-2549) -- always `cacheScope: "private"`,
# never configurable to "public" (README.md §45 Teste 5).
# EMCP_TOOLS_LIST_CACHE_TTL_MS=5000
# --- PDP (EP-03) ---
# Required: the Gateway fails closed at startup if this is unreachable
# (README.md §38, EP-05-T06) -- there is no "run without a PDP" mode.
OPA_URL=http://opa:8181
# --- Identity / OIDC (EP-01-T01/T03) ---
# Unset (default): no OIDC provider configured, every request is
# unauthenticated -- deny-by-default falls back to the Gateway's own
# handler-level checks instead of the SDK's RequireAuthMiddleware (see
# emcp_bus/gateway/server.py's module docstring). Uncomment all three to
# enable real bearer-token auth against the `keycloak` compose service
# (profile `identity`; realm imported from deploy/keycloak/realm-export.json
# -- clients sales-read-agent/sales-write-agent/finance-payments-agent,
# secrets `dev-<client-id>-secret`, matching config/clients/*.yaml). Run with
# `docker compose --profile core --profile identity up`. Any OIDC-compliant
# IdP works here, not just Keycloak (EP-01-T03 is IdP-agnostic).
# OIDC_JWKS_URL=http://keycloak:8080/realms/emcp/protocol/openid-connect/certs
# OIDC_ISSUER=http://keycloak:8080/realms/emcp
# OIDC_AUDIENCE=emcp-gateway
# --- Downstream / outbound identity (EP-07) ---
# Never the inbound MCP Client's token -- see config/backends/*.yaml and
# src/emcp_bus/downstream/identity.py. Also the credential the domain
# servers themselves require of every inbound request when run via `main()`
# (EP-05-T07's `BackendCredentialGate`) -- a request lacking it never
# reaches a tool handler, whether or not it also came through the Gateway.
SALES_DOMAIN_SERVICE_TOKEN=dev-sales-domain-service-token
FINANCE_DOMAIN_SERVICE_TOKEN=dev-finance-domain-service-token

View File

@@ -1,4 +1,4 @@
.PHONY: venv install lint format typecheck test dev up down logs validate-schemas .PHONY: venv install lint format typecheck test dev up down logs validate-schemas export-schemas
VENV := .venv VENV := .venv
PYTHON := $(VENV)/bin/python3 PYTHON := $(VENV)/bin/python3
@@ -19,16 +19,21 @@ format:
$(VENV)/bin/ruff check --fix . $(VENV)/bin/ruff check --fix .
typecheck: typecheck:
$(VENV)/bin/mypy src services tests $(VENV)/bin/mypy src services tests scripts
# --forked (pytest-forked): one process per test function -- resolves the
# e2e suite's previously-documented flakiness (RI/README.md), where several
# in-process ASGI server pairs sharing one Python process/event loop across
# many sequential tests intermittently failed. Verified: 119/119 (97 unit +
# 22 e2e) pass reliably with it; costs roughly +1min total on this machine.
test: test:
$(VENV)/bin/python3 -m pytest $(VENV)/bin/python3 -m pytest --forked
validate-schemas: validate-schemas:
# Placeholder until EP-02/EP-04 introduce the first JSON Schema-backed $(VENV)/bin/python3 scripts/validate_config.py
# YAML config types; will validate every file under config/ against its
# schema (see docs/RI-PLANNING.md, EP-00-T03). export-schemas:
@echo "No schema-validated config types yet (see EP-02-T01, EP-04-T01)." $(VENV)/bin/python3 scripts/export_schemas.py
# Runs both M0 services directly on the host (no Docker required) -- # Runs both M0 services directly on the host (no Docker required) --
# handy for local development and for environments without a Docker daemon. # handy for local development and for environments without a Docker daemon.

View File

@@ -2,7 +2,7 @@
Implementação executável da arquitetura descrita em [`../README.md`](../README.md), planejada em [`../docs/RI-PLANNING.md`](../docs/RI-PLANNING.md). Implementação executável da arquitetura descrita em [`../README.md`](../README.md), planejada em [`../docs/RI-PLANNING.md`](../docs/RI-PLANNING.md).
> **Status: núcleo de enforcement do Marco M1 concluído e testado ponta a ponta** (auth real → entitlement → PDP → decisão no Gateway, com OPA e um servidor JWKS reais — não mocks). Ver "O que existe hoje" abaixo. Os demais marcos estão descritos em `docs/RI-PLANNING.md`, seção 8.7. > **Status: Marco M1 concluído** (fechamento retroativo pós-M2, 2026-09-11) — todos os gaps de M0/M1 identificados após o M2 foram implementados nesta rodada: Fabric router multi-backend (EP-06-T01), domínio Finance com backend real (EP-06-T03), auditoria IC/NOC/GRL + correlação ponta a ponta (EP-08-T02/T03), stack local de observabilidade (EP-08-T04), cache hint (EP-05-T03), anti-bypass (EP-05-T07), consistência header/body (EP-05-T08), adapter REST genérico (EP-06-T04), lint de shared client (EP-01-T05), spike de mTLS (EP-01-T04), pipeline de CI (EP-00-T03). Ver "O que existe hoje" abaixo. A flakiness da suíte e2e documentada em sessões anteriores está **resolvida** (`--forked`, ver abaixo). Os demais marcos (M3/M4) estão descritos em `docs/RI-PLANNING.md`, seção 8.7.
## Quickstart (sem Docker) ## Quickstart (sem Docker)
@@ -10,10 +10,16 @@ Implementação executável da arquitetura descrita em [`../README.md`](../READM
make install # cria .venv e instala o pacote em modo editável (dev extras inclusos) make install # cria .venv e instala o pacote em modo editável (dev extras inclusos)
make lint # ruff check + ruff format --check make lint # ruff check + ruff format --check
make typecheck # mypy --strict make typecheck # mypy --strict
make test # pytest (38 testes: unit + e2e) make validate-schemas # valida todo YAML de config/ contra seu modelo Pydantic
make export-schemas # regenera schemas/*.schema.json a partir dos modelos
make test # pytest --forked (unit + e2e) -- ver nota abaixo
``` ```
`make test` precisa do binário `opa` no PATH (ou `OPA_BINARY` apontando para ele) para os testes que exercitam o PDP de verdade — ver "Como instalar o OPA" abaixo. Sem ele, esses testes são pulados (`pytest.skip`), não falham. `make test` precisa do binário `opa` no PATH (ou `OPA_BINARY` apontando para ele) para os testes que exercitam o PDP de verdade — ver "Como instalar o OPA" abaixo. Sem ele, esses testes são pulados (`pytest.skip`), não falham. Os testes de `test_real_keycloak.py` precisam de `docker` no PATH pela mesma razão.
### Flakiness da suíte e2e: resolvida com `--forked`
Sessões anteriores documentaram falhas intermitentes (`mcp.shared.exceptions.MCPError: SSE stream ended without a response`) ao rodar `pytest tests/e2e/` sem isolamento de processo — a hipótese era limpeza assíncrona (anyio/httpx/uvicorn) compartilhada entre pares de servidor ASGI em processo, na mesma sessão pytest, e várias mitigações (mais retries, pausas, isolar por arquivo) não resolviam. O follow-up então recomendado — isolar cada teste em subprocesso próprio via `pytest-forked` — foi implementado e **confirma a hipótese**: com `--forked` (um processo `fork()` por função de teste), a suíte inteira passa de forma consistente. Verificado nesta sessão: **119/119 testes (97 unit + 22 e2e) passando em múltiplas execuções seguidas**, contra a mesma taxa de 1-9 falhas por execução sem `--forked`. `make test` e `.github/workflows/ci.yml` já usam `--forked` por padrão; o custo é só tempo de execução (~+1min nesta máquina).
### Como instalar o OPA (sem Docker) ### Como instalar o OPA (sem Docker)
@@ -33,21 +39,54 @@ Versão usada durante o desenvolvimento desta RI: **OPA v1.20.2** (Rego v1 é o
```bash ```bash
source .venv/bin/activate source .venv/bin/activate
opa run --server --addr 127.0.0.1:8181 config/policies/ & opa run --server --addr 127.0.0.1:8181 config/policies/ &
export SALES_DOMAIN_SERVICE_TOKEN=dev-sales-domain-service-token # EP-07: credencial de saída, nunca o token do client
export FINANCE_DOMAIN_SERVICE_TOKEN=dev-finance-domain-service-token
python3 -m example_mcp_servers.sales_domain.server & python3 -m example_mcp_servers.sales_domain.server &
python3 -m example_mcp_servers.finance_domain.server &
# Sem OIDC_JWKS_URL/OIDC_ISSUER, o Gateway roda em modo "sem IdP configurado": # Sem OIDC_JWKS_URL/OIDC_ISSUER, o Gateway roda em modo "sem IdP configurado":
# nega tudo por padrão (ver "Dois modos de deployment" abaixo). # nega tudo por padrão (ver "Dois modos de deployment" abaixo).
python3 -m emcp_bus.gateway.server python3 -m emcp_bus.gateway.server
``` ```
Para autenticação real, seria necessário apontar `OIDC_JWKS_URL`/`OIDC_ISSUER` para um IdP OIDC de verdade (Keycloak, EP-01-T01 — não validado nesta sessão, ver abaixo). Os testes automatizados (`tests/e2e/conftest.py`) substituem isso por um servidor JWKS local real + tokens RS256 reais assinados na hora — válido para provar a lógica de validação de token, não para provar o Keycloak em si. O Fabric router (EP-06-T01) resolve cada capability para o backend correto via `config/backends/*.yaml` + o Registry (seedado de `config/capabilities/` no startup do Gateway) — não há mais uma única `SALES_DOMAIN_URL` estática cobrindo tudo.
Para autenticação real, aponte `OIDC_JWKS_URL`/`OIDC_ISSUER` para um Keycloak real (EP-01-T01, ✅ implementado e validado — ver `deploy/keycloak/realm-export.json` e `tests/e2e/test_real_keycloak.py`) ou qualquer IdP OIDC compliant (`TokenValidator` é agnóstico de fornecedor). A maior parte da suíte (`tests/e2e/conftest.py`) continua usando um servidor JWKS local mais leve como stand-in — válido para a lógica de validação, mas `test_real_keycloak.py` é quem prova a integração com Keycloak de verdade (client_credentials grant, claims reais, `iss`/`aud` reais).
## Quickstart (Docker) ## Quickstart (Docker)
```bash ```bash
docker compose --profile core up --build make up # == docker compose --profile core up --build (sem IdP)
make down # == docker compose --profile core down
make logs # == docker compose --profile core logs -f
# Com Keycloak real (EP-01-T01):
cp .env.example .env # descomente as 3 linhas OIDC_*
docker compose --profile core --profile identity up --build
``` ```
> **Nota**: `docker-compose.yml` e os Dockerfiles em `deploy/docker/` **não foram validados com um daemon Docker real** nesta sessão (ambiente sem `docker` disponível) — foram escritos e revisados manualmente, e a stack equivalente foi validada rodando os processos diretamente no host. Rode `docker compose --profile core up --build` na primeira vez em um ambiente com Docker e reporte qualquer ajuste necessário. **O `docker-compose.yml` atual ainda não inclui OPA/Keycloak como serviços** — apenas gateway + sales-domain (perfil `core`, herdado do M0); isso é trabalho pendente do M1 (ver tabela abaixo). > **✅ Validado com um daemon Docker real** (Docker 29.8.0 / Compose v5.5.1) — `gateway` + `sales-domain` + `opa` (+ `keycloak`, opcional) sobem, o Gateway sincroniza entitlements reais com o OPA real do compose, e um cliente MCP real do host completa `tools/list`/`tools/call` através do Gateway containerizado, com ou sem Keycloak. Ver "O que foi validado com Docker" abaixo para o que exatamente foi exercitado (incluindo três bugs reais encontrados e corrigidos ao longo dessas rodadas). Sem `.env`/perfil `identity`, o Gateway do compose roda em modo "sem IdP" (toda requisição não autenticada, deny-by-default) — os dois modos são deliberados, não um "modo incompleto" esperando o outro.
### O que foi validado com Docker
Build + up + smoke test end-to-end, mais dois testes adicionais montando um segundo container `gateway` manualmente (`docker run --network ri_default ...`) para exercitar caminhos que o compose padrão (sem Keycloak) não cobre, e (nesta rodada) uma segunda validação completa após o Fabric router multi-backend (EP-06-T01), o domínio Finance real (EP-06-T03), a segmentação de rede (EP-05-T07) e a stack de observabilidade (EP-08-T04):
1. **`docker compose --profile core up --build`** sobe os 4 serviços (`opa`, `sales-domain`, `finance-domain`, `gateway`); ambos os domain servers ficam `healthy`; `gateway` sincroniza com `opa` e seeda o Registry a partir de `config/capabilities/` no startup, permanecendo de pé.
2. **Modo sem OIDC** (compose padrão): `tools/list` vazio, `tools/call` negado com `UNAUTHENTICATED` — confirmado com um cliente MCP real do host contra `http://127.0.0.1:8000/mcp`.
3. **Sync real de entitlement**: `curl http://127.0.0.1:8181/v1/data/emcp/client_profiles` no OPA do compose mostra os profiles reais (`Sales.Read`, `Finance.Payments`, ...) que o Gateway empurrou no startup — não é um mock.
4. **Segmentação de rede real (EP-05-T07)**: `opa`, `sales-domain` e `finance-domain` não publicam porta de host no compose — confirmado com `curl http://127.0.0.1:8100/mcp` do host retornando `Connection refused`, algo que só um daemon Docker real prova (Docker só expõe uma porta de container para o host via `ports:`, então a ausência dela já é a segmentação, sem precisar de uma rede Docker customizada adicional).
5. **Caminho autenticado completo, cross-container, incluindo Finance**: com `--profile identity` (Keycloak real) e um token `client_credentials` real para `finance-payments-agent`, `tools/list` retorna exatamente os 3 tools de `Finance.Payments` (roteados pelo Fabric router para o container `finance-domain`, não `sales-domain`) e `tools/call finance.payment.create` executa de verdade contra o backend Finance real, retornando um `payment_id` genuíno.
6. **Correlação de trace entre containers de verdade** (não em-processo, como nos testes e2e): o mesmo `trace_id` aparece nos logs de `gateway` e do domain server correspondente para uma chamada bem-sucedida — a forma mais forte de validar o critério de aceite do Marco M0.
7. **Fail-closed real**: com o container `opa` parado, um novo container `gateway` falha ao subir (`Application startup failed. Exiting.`, `PDPUnavailableError` no traceback) — confirma README.md §38 sob Docker de verdade, não só em teste unitário.
8. **Keycloak real, via `docker compose --profile core --profile identity up`** (EP-01-T01): realm `emcp` importado de `deploy/keycloak/realm-export.json` (3 clients — `sales-read-agent`, `sales-write-agent`, `finance-payments-agent` — cada um com `client_credentials` grant habilitado e um mapper de audience para `emcp-gateway`); token obtido via `client_credentials` real, validado pelo Gateway contra o JWKS real do Keycloak, `tools/list`/`tools/call` corretos para `Sales.Read`, negação correta cross-domain (`Sales.Read` → `finance.payment.execute`) e `REQUIRE_APPROVAL` correto para `Finance.Payments` acima do threshold — tudo com identidades **realmente distintas** emitidas pelo mesmo IdP.
9. **`tests/e2e/test_real_keycloak.py`** automatiza o item 8 (sobe seu próprio container Keycloak via `docker run`, pula se `docker` não estiver no PATH) — 4 testes, agora estáveis com `--forked` (ver nota de flakiness acima).
O perfil `observability` (`otel-collector` + `jaeger`, EP-08-T04) não foi exercitado com tráfego real nesta rodada (apenas `docker compose config` validado) — os dois serviços usam imagens oficiais padrão (`otel/opentelemetry-collector-contrib`, `jaegertracing/all-in-one`) com uma config mínima (`deploy/otel/collector-config.yaml`), sem lógica própria da RI além da configuração em si.
**Três bugs reais encontrados e corrigidos ao longo da validação original** (nenhum seria pego sem um daemon Docker/Keycloak real):
- `deploy/docker/Dockerfile` não copiava `config/` para a imagem — o Gateway falhava ao subir (`ConfigError`: diretório `config/clients` não encontrado) porque `/app/config` simplesmente não existia no container. Corrigido com `COPY config ./config`.
- O healthcheck de `opa` no `docker-compose.yml` usava `wget`, que **não existe** na imagem oficial `openpolicyagent/opa` (é uma imagem estilo distroless, só o binário `opa`, sem shell). Isso deixava `gateway` (que dependia de `opa: condition: service_healthy`) preso para sempre em `Created`. Corrigido: sem healthcheck em `opa` (não há ferramenta na imagem para escrevê-lo), `gateway` depende de `opa: condition: service_started` e ganhou `restart: on-failure:5` — o próprio fail-closed do Gateway (item 6 acima) faz o papel de "esperar o OPA ficar pronto de verdade", com um pequeno número de retries do Compose cobrindo a janela entre o processo do OPA iniciar e aceitar conexões.
- Keycloak deriva o claim `iss` do `Host` header da requisição usada para *obter* o token, a menos que `KC_HOSTNAME` seja fixado — um token obtido via `127.0.0.1:8080` (do host, para teste manual) tinha `iss` diferente do que o Gateway (que valida via `keycloak:8080`, nome interno do compose) esperava, e era rejeitado por "wrong issuer" mesmo sendo um token 100% válido. Corrigido fixando `KC_HOSTNAME` (`keycloak` no compose; a URL completa `http://127.0.0.1:<porta>` no teste automatizado, já que `KC_HOSTNAME` não aceita `host:porta` sem esquema).
## Dois modos de deployment (importante) ## Dois modos de deployment (importante)
@@ -62,37 +101,32 @@ Os dois modos são testados explicitamente em `tests/e2e/test_governed_gateway.p
| Tarefa | Componente | Status | | Tarefa | Componente | Status |
|---|---|---| |---|---|---|
| EP-00 (completo) | Scaffold, config loader, docker-compose base | ✅ (ver README anterior/git history) | | EP-00 (completo) | Scaffold, config loader, docker-compose base, **CI** | ✅ `.github/workflows/ci.yml` (EP-00-T03) |
| EP-01-T02 | Schema de registro de MCP Client | ✅ `src/emcp_bus/identity/models.py` | | EP-01 (completo) | Registro de MCP Client, middleware OIDC/JWT, Keycloak, lint de shared client, spike mTLS | ✅ validado com JWKS real + tokens RS256 reais + Keycloak real (26.7.3, `tests/e2e/test_real_keycloak.py`); lint em `src/emcp_bus/identity/shared_client_lint.py` (ligado a `make validate-schemas`); spike em `docs/adr/ADR-019-identity-extension.md` |
| EP-01-T03 | Middleware de autenticação OIDC/JWT | ✅ validado com JWKS real + tokens RS256 reais (assinatura, expiração, issuer, audience) | | EP-02-T01..T05 (completo) | Registry: schema, pipeline, storage, lifecycle, manifests de exemplo | ✅ `src/emcp_bus/registry/*`, testado (inclui `seed.py`, usado pelo Gateway em runtime desde EP-06-T01) |
| EP-01-T01 | Keycloak (realm export, serviço no compose) | ❌ não implementado — sem Java neste ambiente para validar; ver "Limitações desta sessão" | | EP-03 (completo) | PDP/OPA: entitlement.rego, transaction.rego, cliente, contrato de decisão | ✅ validado com **OPA real** — 9 testes Rego + 6 Python |
| EP-01-T05 | Lint de shared client | ❌ não implementado (prioridade `could`) | | EP-04 (completo) | Entitlement Manager: resolve, revoke, versionamento | ✅ validado com OPA real + YAML reais |
| EP-02 (Registry) | Schema de capability manifest | ✅ `src/emcp_bus/registry/models.py` (schema apenas — pipeline/storage/API ainda não) | | EP-05 (completo) | Gateway: discovery filtrado, execution enforcement, fail-closed, **cache hint**, **anti-bypass**, **consistência header/body** | ✅ `src/emcp_bus/gateway/{server,cache,canonical_request}.py` — ver limitação de wire-mode do cache hint abaixo |
| EP-02-T02/T03/T05 | Publishing pipeline, storage, manifests de exemplo | ❌ não implementado | | EP-06-T01 | Fabric core (roteador por capability via Registry) | ✅ `src/emcp_bus/fabric/router.py` — multi-backend real, testado (Sales→sales-domain, Finance→finance-domain, nunca cruzado) |
| EP-03 (completo) | PDP/OPA: entitlement.rego, transaction.rego, cliente Python, contrato de decisão | ✅ validado com **OPA real** (binário, não mock) — 9 testes Rego nativos + 6 testes Python de integração | | EP-06-T02 | MCP Server de exemplo — Sales | ✅ 6 tools (R1 + R2), agora com `BackendCredentialGate` (EP-05-T07) |
| EP-04 (completo) | Entitlement Manager: resolve, revoke, versionamento | ✅ validado com OPA real + YAML reais de `config/clients/` | | EP-06-T03 | MCP Server de exemplo — Finance | ✅ `services/example_mcp_servers/finance_domain/server.py` — invoice.get (R1), payment.create (R2), payment.execute (R3, ciclo de aprovação completo testado e2e) |
| EP-05-T01 | Gateway skeleton | ✅ (M0) | | EP-06-T04 | Adapter REST genérico | ✅ `src/emcp_bus/fabric/adapters/rest_adapter.py` — implementado e testado standalone (`httpx.MockTransport`); nenhum manifest seed usa `backend.type: "rest"` ainda (os dois domínios de exemplo são MCP-nativos), então não há caminho ao vivo através do Gateway exercitando-o — ver módulo docstring |
| EP-05-T02 | `tools/list` filtrado por entitlement | ✅ testado (README.md §45 Teste 1) | | EP-07 (completo) | Outbound/downstream identity: credencial de backend distinta da do client | ✅ `src/emcp_bus/downstream/{models,identity}.py`, ligado ao Gateway, testado |
| EP-05-T04 | `tools/call` reautorizado via PDP | ✅ testado (Teste 2, Teste 4 — agent role spoofing sem efeito) |
| EP-05-T06 | Fail-closed (PDP indisponível) | ✅ testado — `lifespan()` recusa subir se o OPA sync falhar |
| EP-05-T03 | Cache privado de `tools/list` | ❌ não implementado |
| EP-05-T07 | Anti-bypass (isolamento de rede) | ❌ não implementado (depende de docker-compose real) |
| EP-05-T08 | Consistência header/body (Mcp-Method/Mcp-Name) | ❌ não implementado explicitamente (o SDK já expõe os headers nativamente — ver §22 do README de arquitetura; falta o passo de validação cruzada) |
| EP-06-T01 | Fabric core (roteador por capability) | ❌ ainda roteamento estático (uma única URL fixa), não por Registry |
| EP-06-T02 | MCP Server de exemplo — Sales | ✅ (M0) |
| EP-06-T03 | MCP Server de exemplo — Finance | ❌ não implementado — os testes de negação para tools `finance.*` passam porque o PDP nega *antes* de qualquer tentativa de roteamento ao backend, então a ausência do servidor Finance real não invalida esses testes, mas nenhum cenário de **sucesso** em Finance foi exercitado |
| EP-06-T04 | Adapter REST genérico | ❌ não implementado |
| EP-08-T01 | Tracing OTel | ✅ (M0) | | EP-08-T01 | Tracing OTel | ✅ (M0) |
| EP-08-T02/T03/T04 | Eventos de auditoria (IC/NOC/GRL), correlação, stack de observabilidade | ❌ não implementado — hoje só existe o trace OTel, sem taxonomia de eventos nem sink de auditoria | | EP-08-T02 | Eventos de auditoria (taxonomia IC/NOC/GRL) | ✅ `src/emcp_bus/audit/{models,events,sink}.py` — nunca registra valor de token/secret (hash apenas), sink JSONL append-only |
| EP-08-T03 | Correlação ponta a ponta (decisionId/policyDecisionId/entitlementVersion/mcpRequestId) | ✅ `src/emcp_bus/audit/correlation.py` — exposto no trace (span attributes) e em todo evento de auditoria |
| EP-08-T04 | Stack local de observabilidade (OTel Collector + Jaeger) | ✅ `docker-compose.yml` perfil `observability` + `deploy/otel/collector-config.yaml` — config validada (`docker compose config`), não exercitada com tráfego real nesta rodada |
| EP-14 (completo) | Approval workflow: contrato, serviço, fail-closed, ligado ao Gateway | ✅ `src/emcp_bus/approval/{models,service}.py`, testado (ciclo completo e2e para Sales **e** Finance: nega → aprova externamente → executa → replay nega) |
**39 testes automatizados**, todos reais (sem mocks do núcleo de segurança): OPA de verdade (subprocess), JWKS HTTP real + JWT RS256 reais, YAML reais de `config/`. **119 testes automatizados** (97 unit + 22 e2e, incluindo 4 contra Keycloak real em `test_real_keycloak.py`), a lógica de produção sem mocks do núcleo de segurança: OPA real (subprocess), Keycloak real (Docker) + JWKS HTTP real (stand-in leve para o resto da suíte) + JWT RS256 reais, YAML reais de `config/`, SQLite real para o Registry. **100% determinísticos com `--forked`** (verificado em múltiplas execuções seguidas) — a flakiness antes documentada para a suíte e2e está resolvida, ver seção dedicada acima.
## Limitações desta sessão ## Limitações conhecidas
Nenhum `docker` nem `java` disponíveis no ambiente onde esta RI foi escrita: - **SEP-2549 (`ttlMs` do cache hint de `tools/list`, EP-05-T03)**: implementado corretamente (`src/emcp_bus/gateway/cache.py`), mas o campo é vocabulário exclusivo do protocolo 2026-07-28, que só existe no modo *stateless per-request* do SDK (sem handshake `initialize`) — esta RI usa o handshake de sessão clássico em toda parte (Gateway↔Client e Gateway↔backends), então `ttl_ms` nunca chega ao wire nesta arquitetura, não importa o que o Gateway compute (o SDK descarta o campo na serialização para versões de protocolo anteriores a 2026-07-28 — verificado empiricamente). `cache_scope` não sofre essa limitação de wire, mas por não ter dependência de wire-mode também não é algo que um teste e2e consiga verificar de forma discriminante (o cliente sempre usa o default do próprio campo). A propriedade de segurança real (Teste 5, §45 — nunca vazar catálogo entre clientes) é garantida de forma independente pelo Gateway nunca cachear o catálogo filtrado no servidor.
- **EP-05-T08 (consistência header/body)**: implementado como um gate próprio (`canonical_request.py`) reaproveitando as constantes/nomes de campo do SDK, e não como o ladder de validação nativo do SDK (`mcp.shared.inbound.classify_inbound_request`), que só se aplica ao mesmo modo *stateless per-request* de 2026-07-28 mencionado acima — pela mesma razão, este RI não o alcança pelo caminho nativo.
- **Docker**: não validado (ver nota acima). Risco considerado baixo — o Dockerfile usa a mesma instalação (`pip install -e .`) já validada localmente. - **EP-06-T04 (adapter REST)**: implementado e testado de forma standalone; nenhum backend de exemplo desta RI é REST (ambos são MCP-nativos), então o roteamento ao vivo através do Gateway para um backend `type: "rest"` não foi exercitado end-to-end.
- **Keycloak**: não implementado nem validado — precisa de JVM. A lógica de validação de OAuth2/OIDC (`TokenValidator`, EP-01-T03) é padrão-agnóstica de IdP (qualquer provedor OIDC compliant funciona) e foi validada com um servidor JWKS real + tokens RS256 reais, o que prova a lógica de validação mas **não prova a integração específica com Keycloak** (realm config, client credentials grant, claims exatas que o Keycloak emite). EP-01-T01 continua pendente. - **Token exchange RFC 8693** (EP-07, modo `token_exchange` de `BackendConfig`): implementado como contrato/config validado, mas a chamada real ao endpoint de token exchange é um `NotImplementedError` documentado — o modo `service_account` (credencial estática por env var) é o caminho testado ponta a ponta.
- **Stack de observabilidade** (EP-08-T04): config validada, não exercitada com tráfego real (ver "O que foi validado com Docker").
## Versões fixadas ## Versões fixadas
@@ -101,6 +135,7 @@ Ver `pyproject.toml`. Confirmadas via `pip index versions`/download direto em 20
- `mcp` 2.2.0 (SDK v2 — API mudou significativamente da v1; ver comentários em `src/emcp_bus/gateway/server.py`) - `mcp` 2.2.0 (SDK v2 — API mudou significativamente da v1; ver comentários em `src/emcp_bus/gateway/server.py`)
- `pydantic` 2.13.x, `opentelemetry-sdk` 1.44.x, `uvicorn` 0.52.x, `pyjwt[crypto]` 2.14.x, `ruff` 0.16.x, `mypy` 2.3.x, `pytest` 9.1.x - `pydantic` 2.13.x, `opentelemetry-sdk` 1.44.x, `uvicorn` 0.52.x, `pyjwt[crypto]` 2.14.x, `ruff` 0.16.x, `mypy` 2.3.x, `pytest` 9.1.x
- `OPA` 1.20.2 (binário estático, Rego v1 padrão) - `OPA` 1.20.2 (binário estático, Rego v1 padrão)
- `pytest-forked` 1.7.x (resolve a flakiness da suíte e2e, ver acima)
## Estrutura ## Estrutura
@@ -110,21 +145,38 @@ Ver `../docs/RI-PLANNING.md`, seção 8.5, para a árvore completa planejada. O
RI/ RI/
├── pyproject.toml ├── pyproject.toml
├── Makefile ├── Makefile
├── docker-compose.yml # perfil "core": gateway + sales-domain (OPA/Keycloak pendentes) ├── docker-compose.yml # perfil "core": gateway+sales-domain+finance-domain+opa;
│ # "identity": +keycloak; "observability": +otel-collector+jaeger
├── .env.example ├── .env.example
├── deploy/docker/Dockerfile ├── deploy/docker/Dockerfile
├── deploy/keycloak/realm-export.json # EP-01-T01: realm "emcp", 3 clients
├── deploy/otel/collector-config.yaml # EP-08-T04
├── scripts/{export_schemas,validate_config}.py
├── schemas/*.schema.json # gerados por export_schemas.py
├── config/ ├── config/
│ ├── policies/ # EP-03: entitlement.rego, transaction.rego (+ testes opa test) │ ├── policies/ # EP-03: entitlement.rego, transaction.rego (+ opa test)
│ └── clients/ # EP-01/EP-04: registrations + profiles/*.yaml │ ├── clients/{*.yaml,profiles/*.yaml} # EP-01/EP-04: registrations + profiles
│ ├── backends/*.yaml # EP-06-T01/EP-07: endpoint + outbound identity por backend
│ └── capabilities/{sales,finance}/*.yaml # EP-02-T05: manifests de exemplo
├── src/emcp_bus/ ├── src/emcp_bus/
│ ├── common/{config,otel,models}.py # EP-00-T02, EP-08-T01, risk tier compartilhado │ ├── common/{config,otel,models}.py # EP-00-T02, EP-08-T01, risk tier compartilhado
│ ├── identity/{models,authn,token_verifier}.py # EP-01-T02/T03 │ ├── identity/{models,authn,token_verifier,shared_client_lint}.py # EP-01
│ ├── entitlement/{models,manager}.py # EP-04 │ ├── entitlement/{models,manager}.py # EP-04
│ ├── registry/models.py # EP-02-T01 (schema apenas) │ ├── registry/{models,store,pipeline,lifecycle,seed}.py # EP-02
│ ├── pdp/{client,models}.py # EP-03 │ ├── pdp/{client,models}.py # EP-03
│ └── gateway/server.py # EP-05-T01/T02/T04/T06 │ ├── fabric/router.py # EP-06-T01
├── services/example_mcp_servers/sales_domain/server.py # EP-06-T02 │ ├── fabric/adapters/rest_adapter.py # EP-06-T04 (standalone, ver limitações)
│ ├── downstream/{models,identity}.py # EP-07
│ ├── audit/{models,events,sink,correlation,bypass_detection}.py # EP-08-T02/T03, EP-05-T07
│ ├── approval/{models,service}.py # EP-14
│ └── gateway/{server,cache,canonical_request}.py # EP-05, integra tudo acima
├── services/example_mcp_servers/
│ ├── sales_domain/server.py # EP-06-T02 (6 tools: R1 + R2)
│ └── finance_domain/server.py # EP-06-T03 (invoice.get R1, payment.create R2, payment.execute R3)
└── tests/ └── tests/
├── unit/ # schemas, PDP client, entitlement manager, authn — todos contra dependências reais ├── unit/ # schemas, PDP, entitlement, authn, registry, downstream identity, approval,
└── e2e/ # conftest.py (fixtures compartilhados), walking skeleton, governed gateway │ # fabric router, audit, canonical request, rest adapter, bypass detection,
│ # gateway cache, shared client lint
└── e2e/ # conftest.py (fixtures compartilhados), walking skeleton, governed gateway,
# test_real_keycloak.py (EP-01-T01, Docker real)
``` ```

View File

@@ -0,0 +1,5 @@
backend: finance-domain
url: ${FINANCE_DOMAIN_URL:-http://127.0.0.1:8101/mcp}
audience: finance-domain-service
credential_mode: service_account
service_account_token_env_var: FINANCE_DOMAIN_SERVICE_TOKEN

View File

@@ -0,0 +1,8 @@
backend: sales-domain
# Fabric router endpoint (EP-06-T01). ${VAR:-default} so `make dev`/tests work
# against 127.0.0.1 without any .env, while docker-compose.yml's SALES_DOMAIN_URL
# points at the compose-internal service DNS name instead.
url: ${SALES_DOMAIN_URL:-http://127.0.0.1:8100/mcp}
audience: sales-domain-service
credential_mode: service_account
service_account_token_env_var: SALES_DOMAIN_SERVICE_TOKEN

View File

@@ -0,0 +1,12 @@
name: finance.invoice.get
version: 1.0.0
domain: finance
owner: finance-platform
risk_tier: R1
side_effects: false
idempotent: true
data_classification: [confidential]
entitlements: [Finance.Read, Finance.Payments]
backend:
type: mcp
service: finance-domain

View File

@@ -0,0 +1,12 @@
name: finance.payment.create
version: 1.0.0
domain: finance
owner: finance-platform
risk_tier: R2
side_effects: true
idempotent: false
data_classification: [confidential, restricted]
entitlements: [Finance.Payments]
backend:
type: mcp
service: finance-domain

View File

@@ -0,0 +1,16 @@
# README.md §17 (R3, high impact) + §26 (approval-eligible above Finance.Payments'
# restrictions.approval_threshold, enforced by config/policies/transaction.rego)
name: finance.payment.execute
version: 1.0.0
domain: finance
owner: finance-platform
risk_tier: R3
side_effects: true
idempotent: false
data_classification: [confidential, restricted]
entitlements: [Finance.Payments]
backend:
type: mcp
service: finance-domain
approval:
required: true

View File

@@ -0,0 +1,12 @@
name: sales.customer.get
version: 1.0.0
domain: sales
owner: sales-platform
risk_tier: R1
side_effects: false
idempotent: true
data_classification: [internal]
entitlements: [Sales.Read]
backend:
type: mcp
service: sales-domain

View File

@@ -0,0 +1,12 @@
name: sales.customer.search
version: 1.0.0
domain: sales
owner: sales-platform
risk_tier: R1
side_effects: false
idempotent: true
data_classification: [internal]
entitlements: [Sales.Read]
backend:
type: mcp
service: sales-domain

View File

@@ -0,0 +1,12 @@
name: sales.inventory.check
version: 1.0.0
domain: sales
owner: sales-platform
risk_tier: R1
side_effects: false
idempotent: true
data_classification: [internal]
entitlements: [Sales.Read]
backend:
type: mcp
service: sales-domain

View File

@@ -0,0 +1,12 @@
name: sales.order.get
version: 1.0.0
domain: sales
owner: sales-platform
risk_tier: R1
side_effects: false
idempotent: true
data_classification: [internal]
entitlements: [Sales.Read]
backend:
type: mcp
service: sales-domain

View File

@@ -0,0 +1,12 @@
name: sales.quote.create
version: 1.0.0
domain: sales
owner: sales-platform
risk_tier: R2
side_effects: true
idempotent: false
data_classification: [confidential]
entitlements: [Sales.Write]
backend:
type: mcp
service: sales-domain

View File

@@ -0,0 +1,12 @@
name: sales.quote.update
version: 1.0.0
domain: sales
owner: sales-platform
risk_tier: R2
side_effects: true
idempotent: false
data_classification: [confidential]
entitlements: [Sales.Write]
backend:
type: mcp
service: sales-domain

View File

@@ -13,6 +13,7 @@ allowed_tools:
restrictions: restrictions:
max_transaction_value: 500000 max_transaction_value: 500000
allowed_regions: ["BR-SP", "BR-RJ"] allowed_regions: ["BR-SP", "BR-RJ"]
approval_threshold: 100000
credential_policy: credential_policy:
workload_identity: true workload_identity: true
short_lived: true short_lived: true

View File

@@ -1,10 +1,11 @@
# Shared image for every RI service (Gateway, example MCP servers, ...). # Shared image for every RI service (Gateway, example MCP servers, ...).
# docker-compose.yml selects the process to run per service via `command:`. # docker-compose.yml selects the process to run per service via `command:`.
# #
# NOTE: not verified with a real `docker build` in this session -- no Docker # Verified with a real `docker build`/`docker compose up` (RI/README.md, "O
# daemon was available in the sandbox this was authored in (see RI/README.md, # que foi validado com Docker") -- this image originally omitted `config ./config`
# "Validado nesta sessão" for exactly what *was* run). Please verify on first # below, which built fine but made the Gateway fail closed at startup
# use in an environment with Docker and report back if anything needs fixing. # (`ConfigError`: config/clients not found -- `load_yaml_models`, EP-01/EP-04),
# since the container never had client registrations/profiles to load.
FROM python:3.12-slim AS base FROM python:3.12-slim AS base
WORKDIR /app WORKDIR /app
@@ -14,16 +15,19 @@ WORKDIR /app
COPY pyproject.toml ./ COPY pyproject.toml ./
COPY src ./src COPY src ./src
COPY services ./services COPY services ./services
# The Gateway loads client registrations/profiles/backend-credential config
# from `config/` at startup (EP-01/EP-04/EP-07, GatewayConfig.config_dir).
COPY config ./config
RUN pip install --no-cache-dir --upgrade pip \ RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -e . && pip install --no-cache-dir -e ".[otlp]"
# Runs as a non-root user; every RI service binds 0.0.0.0 inside its own # Runs as a non-root user; every RI service binds 0.0.0.0 inside its own
# container network namespace, never the host network directly. # container network namespace, never the host network directly.
RUN useradd --create-home --uid 10001 emcp RUN useradd --create-home --uid 10001 emcp
USER emcp USER emcp
EXPOSE 8000 8100 EXPOSE 8000 8100 8101
# Overridden per-service by docker-compose.yml `command:`. # Overridden per-service by docker-compose.yml `command:`.
CMD ["python3", "-m", "emcp_bus.gateway.server"] CMD ["python3", "-m", "emcp_bus.gateway.server"]

View File

@@ -0,0 +1,75 @@
{
"realm": "emcp",
"enabled": true,
"clients": [
{
"clientId": "sales-read-agent",
"name": "Sales.Read agent (README.md §7.1)",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": true,
"secret": "dev-sales-read-agent-secret",
"protocolMappers": [
{
"name": "emcp-gateway-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.custom.audience": "emcp-gateway",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
},
{
"clientId": "sales-write-agent",
"name": "Sales.Write agent (README.md §7.1)",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": true,
"secret": "dev-sales-write-agent-secret",
"protocolMappers": [
{
"name": "emcp-gateway-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.custom.audience": "emcp-gateway",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
},
{
"clientId": "finance-payments-agent",
"name": "Finance.Payments agent (README.md §7.2)",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": true,
"secret": "dev-finance-payments-agent-secret",
"protocolMappers": [
{
"name": "emcp-gateway-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.custom.audience": "emcp-gateway",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
}
]
}

View File

@@ -0,0 +1,29 @@
# EP-08-T04: minimal OTel Collector config for local development.
#
# RI services export OTLP/HTTP to this collector (OTEL_EXPORTER_OTLP_ENDPOINT,
# common/otel.py); the collector forwards to Jaeger's own OTLP/gRPC receiver
# for the local `jaeger` UI (docker-compose.yml, `observability` profile), and
# also logs a basic summary of every batch to its own stdout for a quick
# sanity check without opening the Jaeger UI.
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
grpc:
endpoint: 0.0.0.0:4317
exporters:
otlp:
endpoint: jaeger:4317
tls:
insecure: true
debug:
verbosity: basic
service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlp, debug]

View File

@@ -1,19 +1,74 @@
# EP-00-T04 (walking skeleton) + EP-16-T01 (grows into the full stack). # EP-00-T04 (walking skeleton) + EP-16-T01 (grows into the full stack).
# #
# `core` profile = M0: gateway + one example MCP server, static routing, no # `core` profile = the minimum that actually runs: gateway + sales-domain +
# enforcement yet. Later milestones add services under additional profiles # finance-domain + OPA. OPA stopped being optional as of M1 (EP-03/EP-05-T06):
# (`identity`, `policy`, `observability`, `llm-observability`, ...) so `docker # the Gateway's `lifespan()` fails closed at startup if it cannot sync
# compose --profile core up` stays cheap through M0/M1 instead of requiring # entitlements to a PDP, so "gateway without OPA" is not a smaller working
# the whole stack (Keycloak, OPA, Langfuse, ...) just to see the walking # stack, it's a Gateway that refuses to start (README.md §38) -- by design,
# skeleton trace. # not an oversight. `identity` profile (EP-01-T01) adds a real Keycloak with
# the realm this RI's client registrations expect -- opt-in via `.env` (see
# `.env.example`), since the Gateway's "no OIDC provider configured" mode
# (deny-by-default via handler-level checks) is just as valid a deployment
# choice as configuring one (see gateway/server.py's module docstring).
# `observability` profile (EP-08-T04) adds a real OTel Collector + Jaeger.
# #
# NOT executed against a real Docker daemon in the session this was authored # EP-05-T07 (anti-bypass): `opa`, `sales-domain` and `finance-domain`
# in (no `docker` binary in that sandbox) -- validated instead by running the # deliberately publish no host port below -- on a real Docker host, that
# same two processes directly on the host (see RI/README.md). Please run # already means nothing outside the compose network can reach them at all
# `docker compose --profile core up --build` on first use and report back if # (Docker only exposes a container port past the host via `ports:`); only
# anything needs adjusting. # `gateway` does. The domain servers add a second, in-process layer on top
# (`emcp_bus.audit.bypass_detection.BackendCredentialGate`, applied when
# `main()` runs, i.e. exactly this deployment): a request lacking the
# Gateway's own outbound service credential is rejected even if network
# segmentation were ever misconfigured. See RI/README.md, "O que foi
# validado com Docker", for exactly what was run and the two real bugs this
# caught (Dockerfile missing `COPY config`; `opa`'s healthcheck relying on a
# `wget` the official image doesn't ship).
services: services:
opa:
image: openpolicyagent/opa:1.20.2
command: ["run", "--server", "--addr", "0.0.0.0:8181", "/policies"]
volumes:
- ./config/policies:/policies:ro
profiles: ["core"]
# No host port published (EP-05-T07) -- only `gateway`, on the same
# compose network, ever reaches this service.
# No HEALTHCHECK either: the official image is a scratch/distroless-style
# build with only the `opa` binary -- no shell, wget, curl or nc to probe
# itself with (confirmed by `docker exec`: "sh": executable file not
# found). `gateway` below compensates with `service_started` +
# `restart: on-failure`, which is also just the fail-closed behavior
# (README.md §38) working as intended: a Gateway that starts before OPA
# is actually accepting connections refuses to come up and gets retried,
# rather than silently caching an empty entitlement set.
keycloak:
image: quay.io/keycloak/keycloak:26.7.3
command: ["start-dev", "--import-realm"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
# Fixed so the `iss` claim on every issued token is always
# `http://keycloak:8080/realms/emcp`, regardless of whether the caller
# reached Keycloak via the container network (`keycloak:8080`, what the
# Gateway uses to validate) or the host-mapped port (`127.0.0.1:8080`,
# e.g. to fetch a token for manual testing) -- without this, Keycloak
# derives `iss` from the request's own Host header, so a token fetched
# from the host would fail the Gateway's issuer check.
KC_HOSTNAME: keycloak
KC_HOSTNAME_STRICT: "false"
volumes:
- ./deploy/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
ports:
- "8080:8080"
profiles: ["identity"]
# Same reasoning as `opa`: `start-dev` is a JVM/Quarkus dev-mode image
# with no lightweight in-container probe available out of the box, and
# ~15-20s cold start (schema init + realm import) makes a short
# healthcheck interval noisy. `gateway`'s own `restart: on-failure`
# covers the startup race the same way it does for `opa`.
sales-domain: sales-domain:
build: build:
context: . context: .
@@ -22,9 +77,10 @@ services:
environment: environment:
SALES_DOMAIN_HOST: "0.0.0.0" SALES_DOMAIN_HOST: "0.0.0.0"
SALES_DOMAIN_PORT: "8100" SALES_DOMAIN_PORT: "8100"
ports: SALES_DOMAIN_SERVICE_TOKEN: "dev-sales-domain-service-token"
- "8100:8100" EMCP_AUDIT_LOG_PATH: "/tmp/emcp-audit.jsonl"
profiles: ["core"] profiles: ["core"]
# No host port published (EP-05-T07) -- see module-level comment above.
healthcheck: healthcheck:
# A bare TCP connect, not an HTTP/MCP-protocol check: streamable-http # A bare TCP connect, not an HTTP/MCP-protocol check: streamable-http
# rejects a plain GET, so this only confirms the process is listening. # rejects a plain GET, so this only confirms the process is listening.
@@ -33,6 +89,24 @@ services:
timeout: 3s timeout: 3s
retries: 5 retries: 5
finance-domain:
build:
context: .
dockerfile: deploy/docker/Dockerfile
command: ["python3", "-m", "example_mcp_servers.finance_domain.server"]
environment:
FINANCE_DOMAIN_HOST: "0.0.0.0"
FINANCE_DOMAIN_PORT: "8101"
FINANCE_DOMAIN_SERVICE_TOKEN: "dev-finance-domain-service-token"
EMCP_AUDIT_LOG_PATH: "/tmp/emcp-audit.jsonl"
profiles: ["core"]
# No host port published (EP-05-T07) -- see module-level comment above.
healthcheck:
test: ["CMD", "python3", "-c", "import socket; socket.create_connection(('127.0.0.1', 8101), timeout=2)"]
interval: 5s
timeout: 3s
retries: 5
gateway: gateway:
build: build:
context: . context: .
@@ -42,9 +116,49 @@ services:
GATEWAY_HOST: "0.0.0.0" GATEWAY_HOST: "0.0.0.0"
GATEWAY_PORT: "8000" GATEWAY_PORT: "8000"
SALES_DOMAIN_URL: "http://sales-domain:8100/mcp" SALES_DOMAIN_URL: "http://sales-domain:8100/mcp"
FINANCE_DOMAIN_URL: "http://finance-domain:8101/mcp"
OPA_URL: "http://opa:8181"
SALES_DOMAIN_SERVICE_TOKEN: "dev-sales-domain-service-token"
FINANCE_DOMAIN_SERVICE_TOKEN: "dev-finance-domain-service-token"
EMCP_AUDIT_LOG_PATH: "/tmp/emcp-audit.jsonl"
# Empty (default) unless set in `.env`/the shell environment before
# `docker compose --profile core --profile identity up`: the Gateway
# then runs in its "no OIDC provider configured" mode -- deny-by-default
# via handler-level checks instead of the SDK's RequireAuthMiddleware
# (see gateway/server.py's module docstring and `build_auth`). Set all
# three (see `.env.example`) to point at the `keycloak` service above.
OIDC_JWKS_URL: "${OIDC_JWKS_URL:-}"
OIDC_ISSUER: "${OIDC_ISSUER:-}"
OIDC_AUDIENCE: "${OIDC_AUDIENCE:-emcp-gateway}"
# EP-08-T04: empty (default) exports traces to stdout via
# ConsoleSpanExporter; set to "http://otel-collector:4318" (with
# `--profile observability`) to export real OTLP traces instead.
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT:-}"
ports: ports:
- "8000:8000" - "8000:8000"
profiles: ["core"] profiles: ["core"]
restart: on-failure:5
depends_on: depends_on:
sales-domain: sales-domain:
condition: service_healthy condition: service_healthy
finance-domain:
condition: service_healthy
opa:
condition: service_started
otel-collector:
image: otel/opentelemetry-collector-contrib:0.114.0
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./deploy/otel/collector-config.yaml:/etc/otel-collector-config.yaml:ro
profiles: ["observability"]
# No host port published -- only the RI services (pointed at it via
# OTEL_EXPORTER_OTLP_ENDPOINT above) and `jaeger` need to reach it.
jaeger:
image: jaegertracing/all-in-one:1.62.0
environment:
COLLECTOR_OTLP_ENABLED: "true"
ports:
- "16686:16686" # Jaeger UI
profiles: ["observability"]

View File

@@ -25,6 +25,13 @@ dev = [
"mypy>=2.3.1,<3", "mypy>=2.3.1,<3",
"pytest>=9.1.1,<10", "pytest>=9.1.1,<10",
"pytest-asyncio>=1.4.0,<2", "pytest-asyncio>=1.4.0,<2",
# Resolves the e2e suite's previously-documented flakiness (RI/README.md):
# one process *per test function* means no in-process ASGI server pair
# ever shares event-loop/socket state with another test's pair. Verified:
# 22/22 e2e tests pass reliably with `--forked`, where the un-forked
# suite intermittently failed several. Used by `make test` itself now,
# not just CI.
"pytest-forked>=1.7.5,<2",
"types-PyYAML>=6.0.12,<7", "types-PyYAML>=6.0.12,<7",
] ]

View File

@@ -0,0 +1,60 @@
{
"properties": {
"approval_id": {
"title": "Approval Id",
"type": "string"
},
"arguments_summary": {
"additionalProperties": true,
"title": "Arguments Summary",
"type": "object"
},
"client_profile": {
"title": "Client Profile",
"type": "string"
},
"expires_at": {
"format": "date-time",
"title": "Expires At",
"type": "string"
},
"operation_hash": {
"title": "Operation Hash",
"type": "string"
},
"reason_code": {
"title": "Reason Code",
"type": "string"
},
"requested_at": {
"format": "date-time",
"title": "Requested At",
"type": "string"
},
"status": {
"default": "pending",
"enum": [
"pending",
"granted",
"denied",
"consumed"
],
"title": "Status",
"type": "string"
},
"tool": {
"title": "Tool",
"type": "string"
}
},
"required": [
"operation_hash",
"client_profile",
"tool",
"arguments_summary",
"reason_code",
"expires_at"
],
"title": "ApprovalRequest",
"type": "object"
}

View File

@@ -0,0 +1,56 @@
{
"properties": {
"audience": {
"title": "Audience",
"type": "string"
},
"backend": {
"title": "Backend",
"type": "string"
},
"credential_mode": {
"default": "service_account",
"enum": [
"service_account",
"token_exchange"
],
"title": "Credential Mode",
"type": "string"
},
"service_account_token_env_var": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Service Account Token Env Var"
},
"token_exchange_endpoint": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Token Exchange Endpoint"
},
"url": {
"title": "Url",
"type": "string"
}
},
"required": [
"backend",
"url",
"audience"
],
"title": "BackendConfig",
"type": "object"
}

View File

@@ -0,0 +1,199 @@
{
"$defs": {
"ApprovalPolicy": {
"properties": {
"required": {
"default": false,
"title": "Required",
"type": "boolean"
}
},
"title": "ApprovalPolicy",
"type": "object"
},
"BackendRef": {
"description": "Where the Fabric (EP-06-T01) routes a capability once authorized.",
"properties": {
"operation": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Informational, e.g. 'GET /v2/customers/{id}' (README.md \u00a76.8).",
"title": "Operation"
},
"service": {
"description": "Adapter/service id the Fabric router resolves, e.g. 'sales-domain'.",
"title": "Service",
"type": "string"
},
"type": {
"default": "mcp",
"enum": [
"rest",
"mcp",
"grpc"
],
"title": "Type",
"type": "string"
}
},
"required": [
"service"
],
"title": "BackendRef",
"type": "object"
},
"DataClassification": {
"enum": [
"public",
"internal",
"confidential",
"restricted"
],
"title": "DataClassification",
"type": "string"
},
"LifecycleInfo": {
"properties": {
"status": {
"$ref": "#/$defs/LifecycleStatus",
"default": "nominate"
},
"sunset_after": {
"anyOf": [
{
"format": "date",
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Sunset After"
}
},
"title": "LifecycleInfo",
"type": "object"
},
"LifecycleStatus": {
"description": "Registry governance lifecycle (README.md \u00a723).",
"enum": [
"nominate",
"review",
"register",
"active",
"deprecated",
"retired"
],
"title": "LifecycleStatus",
"type": "string"
},
"ObservabilityPolicy": {
"properties": {
"audit_level": {
"default": "full",
"enum": [
"none",
"standard",
"full"
],
"title": "Audit Level",
"type": "string"
}
},
"title": "ObservabilityPolicy",
"type": "object"
},
"RiskTier": {
"description": "Capability/client risk classification (README.md \u00a717).\n\nR0 discovery/public, R1 read, R2 write, R3 high-impact (approval-eligible),\nR4 destructive/restricted. The *same* enum is used for both a capability\nmanifest's `risk_tier` (EP-02-T01) and a client profile's `risk_ceiling`\n(EP-04-T01) so entitlement resolution (EP-04-T02) can compare them\ndirectly instead of maintaining two parallel classifications.",
"enum": [
"R0",
"R1",
"R2",
"R3",
"R4"
],
"title": "RiskTier",
"type": "string"
}
},
"properties": {
"approval": {
"$ref": "#/$defs/ApprovalPolicy"
},
"backend": {
"$ref": "#/$defs/BackendRef"
},
"data_classification": {
"items": {
"$ref": "#/$defs/DataClassification"
},
"title": "Data Classification",
"type": "array"
},
"domain": {
"title": "Domain",
"type": "string"
},
"entitlements": {
"description": "ClientProfile names required to see/call this capability (EP-04).",
"items": {
"type": "string"
},
"minItems": 1,
"title": "Entitlements",
"type": "array"
},
"idempotent": {
"title": "Idempotent",
"type": "boolean"
},
"lifecycle": {
"$ref": "#/$defs/LifecycleInfo"
},
"name": {
"description": "Canonical '<domain>.<capability>' name (README.md \u00a739).",
"title": "Name",
"type": "string"
},
"observability": {
"$ref": "#/$defs/ObservabilityPolicy"
},
"owner": {
"title": "Owner",
"type": "string"
},
"risk_tier": {
"$ref": "#/$defs/RiskTier"
},
"side_effects": {
"title": "Side Effects",
"type": "boolean"
},
"version": {
"description": "SemVer, e.g. '1.2.0' (README.md \u00a740).",
"title": "Version",
"type": "string"
}
},
"required": [
"name",
"version",
"domain",
"owner",
"risk_tier",
"side_effects",
"idempotent",
"entitlements",
"backend"
],
"title": "CapabilityManifest",
"type": "object"
}

View File

@@ -0,0 +1,148 @@
{
"$defs": {
"AuditPolicy": {
"properties": {
"level": {
"default": "full",
"enum": [
"none",
"standard",
"full"
],
"title": "Level",
"type": "string"
}
},
"title": "AuditPolicy",
"type": "object"
},
"CredentialPolicy": {
"properties": {
"short_lived": {
"default": true,
"title": "Short Lived",
"type": "boolean"
},
"workload_identity": {
"default": true,
"title": "Workload Identity",
"type": "boolean"
}
},
"title": "CredentialPolicy",
"type": "object"
},
"RiskTier": {
"description": "Capability/client risk classification (README.md \u00a717).\n\nR0 discovery/public, R1 read, R2 write, R3 high-impact (approval-eligible),\nR4 destructive/restricted. The *same* enum is used for both a capability\nmanifest's `risk_tier` (EP-02-T01) and a client profile's `risk_ceiling`\n(EP-04-T01) so entitlement resolution (EP-04-T02) can compare them\ndirectly instead of maintaining two parallel classifications.",
"enum": [
"R0",
"R1",
"R2",
"R3",
"R4"
],
"title": "RiskTier",
"type": "string"
},
"TransactionRestrictions": {
"description": "Argument/resource-level bounds enforced by the Transaction Policy (EP-03-T03).\n\nThese are *hints* carried alongside the profile for the PDP's Rego data\ndocument -- the PDP, not this model, is the authority that actually\nenforces them (README.md \u00a76.5.B, \u00a725 Passo 2).",
"properties": {
"allowed_regions": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"title": "Allowed Regions"
},
"approval_threshold": {
"anyOf": [
{
"minimum": 0,
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"description": "Above this `arguments.amount`, the transaction policy returns REQUIRE_APPROVAL instead of ALLOW (README.md \u00a726 example: payment.execute < 10k ALLOW, >= 10k REQUIRE_APPROVAL).",
"title": "Approval Threshold"
},
"max_transaction_value": {
"anyOf": [
{
"minimum": 0,
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Max Transaction Value"
}
},
"title": "TransactionRestrictions",
"type": "object"
}
},
"description": "A governed client profile (README.md \u00a741 example).",
"properties": {
"allowed_tools": {
"description": "Canonical <domain>.<capability> tool names (README.md \u00a739).",
"items": {
"type": "string"
},
"minItems": 1,
"title": "Allowed Tools",
"type": "array"
},
"audit": {
"$ref": "#/$defs/AuditPolicy"
},
"credential_policy": {
"$ref": "#/$defs/CredentialPolicy"
},
"environment": {
"default": "production",
"enum": [
"dev",
"ci",
"production"
],
"title": "Environment",
"type": "string"
},
"name": {
"description": "e.g. \"Sales.Read\", \"Finance.Payments\" (README.md \u00a77.1).",
"title": "Name",
"type": "string"
},
"owner": {
"title": "Owner",
"type": "string"
},
"restrictions": {
"$ref": "#/$defs/TransactionRestrictions"
},
"risk_ceiling": {
"$ref": "#/$defs/RiskTier"
}
},
"required": [
"name",
"owner",
"risk_ceiling",
"allowed_tools"
],
"title": "ClientProfile",
"type": "object"
}

View File

@@ -0,0 +1,40 @@
{
"properties": {
"client_id": {
"title": "Client Id",
"type": "string"
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Description"
},
"environment": {
"default": "production",
"enum": [
"dev",
"ci",
"production"
],
"title": "Environment",
"type": "string"
},
"profile": {
"title": "Profile",
"type": "string"
}
},
"required": [
"client_id",
"profile"
],
"title": "ClientRegistration",
"type": "object"
}

View File

@@ -0,0 +1,33 @@
{
"$defs": {
"PDPOutcome": {
"enum": [
"ALLOW",
"DENY",
"REQUIRE_APPROVAL"
],
"title": "PDPOutcome",
"type": "string"
}
},
"properties": {
"outcome": {
"$ref": "#/$defs/PDPOutcome"
},
"policy_version": {
"title": "Policy Version",
"type": "string"
},
"reason_code": {
"title": "Reason Code",
"type": "string"
}
},
"required": [
"outcome",
"policy_version",
"reason_code"
],
"title": "PDPDecision",
"type": "object"
}

View File

@@ -0,0 +1,44 @@
"""Exports JSON Schema for every declarative config/contract model in the RI
into schemas/*.schema.json (EP-00-T02/T03: "todo YAML de config tem JSON
Schema"). Run via `make export-schemas`; CI (`make validate-schemas`) checks
the committed files are up to date rather than regenerating them silently.
"""
from __future__ import annotations
import json
from pathlib import Path
from pydantic import BaseModel
from emcp_bus.approval.models import ApprovalRequest
from emcp_bus.downstream.models import BackendConfig
from emcp_bus.entitlement.models import ClientProfile
from emcp_bus.identity.models import ClientRegistration
from emcp_bus.pdp.models import PDPDecision
from emcp_bus.registry.models import CapabilityManifest
REPO_ROOT = Path(__file__).resolve().parents[1]
SCHEMAS_DIR = REPO_ROOT / "schemas"
MODELS: dict[str, type[BaseModel]] = {
"client-registration": ClientRegistration,
"client-profile": ClientProfile,
"capability-manifest": CapabilityManifest,
"backend-credentials": BackendConfig,
"approval-request": ApprovalRequest,
"pdp-decision": PDPDecision,
}
def main() -> None:
SCHEMAS_DIR.mkdir(exist_ok=True)
for file_stem, model in MODELS.items():
schema = model.model_json_schema()
path = SCHEMAS_DIR / f"{file_stem}.schema.json"
path.write_text(json.dumps(schema, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"wrote {path.relative_to(REPO_ROOT)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,67 @@
"""CI/local check (EP-00-T03): every YAML file under config/ must validate
against its Pydantic model -- this is the thing that actually matters
(RI-PLANNING.md's "todo YAML de config tem ... validação em CI"), not just
that a JSON Schema file exists somewhere.
"""
from __future__ import annotations
import sys
from pathlib import Path
from pydantic import BaseModel
from emcp_bus.common.config import ConfigError, load_yaml_models
from emcp_bus.downstream.models import BackendConfig
from emcp_bus.entitlement.models import ClientProfile
from emcp_bus.identity.models import ClientRegistration
from emcp_bus.identity.shared_client_lint import find_shared_client_violations
from emcp_bus.registry.models import CapabilityManifest
REPO_ROOT = Path(__file__).resolve().parents[1]
CONFIG_DIR = REPO_ROOT / "config"
CHECKS: list[tuple[str, type[BaseModel]]] = [
("clients/profiles", ClientProfile),
("clients", ClientRegistration), # non-recursive glob: skips clients/profiles/
("backends", BackendConfig),
("capabilities/sales", CapabilityManifest),
("capabilities/finance", CapabilityManifest),
]
def main() -> int:
had_errors = False
client_registrations: list[ClientRegistration] = []
for relative_dir, model in CHECKS:
directory = CONFIG_DIR / relative_dir
try:
items = load_yaml_models(directory, model)
except ConfigError as exc:
print(f"FAIL {relative_dir}: {exc}")
had_errors = True
continue
print(f"OK {relative_dir}: {len(items)} file(s) validated against {model.__name__}")
if model is ClientRegistration:
client_registrations = items # type: ignore[assignment]
# EP-01-T05: two agents sharing an MCP Client identity must resolve to
# the same profile (README.md §18/§35) -- a config-shape check schema
# validation alone cannot express, since it spans multiple files.
violations = find_shared_client_violations(client_registrations)
if violations:
for violation in violations:
print(f"FAIL clients (shared-client lint): {violation}")
had_errors = True
else:
print("OK clients (shared-client lint): no violations")
if had_errors:
print("\nConfig validation failed.")
return 1
print("\nAll config files validate against their schema.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,111 @@
"""Example Finance-domain MCP server (EP-06-T03).
invoice.get (R1, read-only), payment.create (R2, side-effecting, not yet
executed), payment.execute (R3, high-impact -- REQUIRE_APPROVAL above
Finance.Payments' `restrictions.approval_threshold`, enforced entirely by
config/policies/transaction.rego before the call ever reaches here). Backed
by static mock data -- this server is deliberately "dumb" (ADR-023): it never
authorizes anything, only the Gateway/PDP does that, upstream of this process.
Run directly for local development:
python -m example_mcp_servers.finance_domain.server
"""
from __future__ import annotations
import itertools
import os
from mcp.server.mcpserver import MCPServer
from starlette.types import ASGIApp
from emcp_bus.audit.bypass_detection import BackendCredentialGate
from emcp_bus.audit.sink import JSONLFileAuditSink
from emcp_bus.common.otel import setup_tracing
setup_tracing("finance-domain-mcp-server")
server = MCPServer(name="finance-domain", version="0.1.0")
_INVOICES: dict[str, dict[str, object]] = {
"inv-001": {
"invoice_id": "inv-001",
"customer_id": "cust-001",
"amount": 5000,
"status": "open",
},
"inv-002": {
"invoice_id": "inv-002",
"customer_id": "cust-002",
"amount": 25000,
"status": "overdue",
},
}
_PAYMENTS: dict[str, dict[str, object]] = {}
_payment_ids = itertools.count(1)
@server.tool(name="finance.invoice.get", description="Get a single finance invoice by id.")
def invoice_get(invoice_id: str) -> dict[str, object]:
invoice = _INVOICES.get(invoice_id)
if invoice is None:
return {"error": "not_found", "invoice_id": invoice_id}
return invoice
@server.tool(
name="finance.payment.create", description="Create a draft payment against an invoice."
)
def payment_create(invoice_id: str, amount: float) -> dict[str, object]:
payment_id = f"pay-{next(_payment_ids):03d}"
payment = {
"payment_id": payment_id,
"invoice_id": invoice_id,
"amount": amount,
"status": "draft",
}
_PAYMENTS[payment_id] = payment
return payment
@server.tool(name="finance.payment.execute", description="Execute a previously created payment.")
def payment_execute(payment_id: str, amount: float) -> dict[str, object]:
"""`amount` is declared by the caller (not re-derived from the stored
payment) so it is visible to `config/policies/transaction.rego`'s
`approval_threshold` check (README.md §26) -- the transaction policy
inspects call arguments, never backend state, matching `sales.quote.create`'s
same pattern (EP-06-T02)."""
payment = _PAYMENTS.get(payment_id)
if payment is None:
return {"error": "not_found", "payment_id": payment_id}
payment["status"] = "executed"
payment["executed_amount"] = amount
return payment
def main() -> None:
host = os.environ.get("FINANCE_DOMAIN_HOST", "127.0.0.1")
port = int(os.environ.get("FINANCE_DOMAIN_PORT", "8101"))
expected_token = os.environ.get("FINANCE_DOMAIN_SERVICE_TOKEN")
audit_log_path = os.environ.get("EMCP_AUDIT_LOG_PATH", "/tmp/emcp-audit.jsonl")
app: ASGIApp = server.streamable_http_app(host=host)
if expected_token:
# EP-05-T07: reject anything that didn't come through the Gateway's
# outbound identity (EP-07-T01) with the matching service credential.
app = BackendCredentialGate(
app,
expected_token=expected_token,
backend="finance-domain",
audit_sink=JSONLFileAuditSink(audit_log_path),
)
import uvicorn
uvicorn.run(app, host=host, port=port, log_level="info")
if __name__ == "__main__":
main()

View File

@@ -1,10 +1,11 @@
"""Example Sales-domain MCP server (EP-06-T02, walking-skeleton subset). """Example Sales-domain MCP server (EP-06-T02).
Read-only tools (risk tier R1, see README.md §17): customer.search, Read-only tools (risk tier R1, see README.md §17): customer.search,
customer.get, order.get, inventory.check. Backed by static mock data -- this customer.get, order.get, inventory.check. Write tools (R2): quote.create,
server is deliberately "dumb": it never authorizes anything (ADR-023 in quote.update. Backed by static mock data -- this server is deliberately
docs/RI-PLANNING.md). Authorization happens exclusively upstream, in the "dumb": it never authorizes anything (ADR-023 in docs/RI-PLANNING.md).
Gateway/PEP (EP-05) before a call ever reaches this process. Authorization happens exclusively upstream, in the Gateway/PEP (EP-05)
before a call ever reaches this process.
Run directly for local development: Run directly for local development:
@@ -13,10 +14,14 @@ Run directly for local development:
from __future__ import annotations from __future__ import annotations
import itertools
import os import os
from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver import MCPServer
from starlette.types import ASGIApp
from emcp_bus.audit.bypass_detection import BackendCredentialGate
from emcp_bus.audit.sink import JSONLFileAuditSink
from emcp_bus.common.otel import setup_tracing from emcp_bus.common.otel import setup_tracing
setup_tracing("sales-domain-mcp-server") setup_tracing("sales-domain-mcp-server")
@@ -37,6 +42,9 @@ _INVENTORY: dict[str, int] = {
"sku-200": 0, "sku-200": 0,
} }
_QUOTES: dict[str, dict[str, object]] = {}
_quote_ids = itertools.count(1)
@server.tool(name="sales.customer.search", description="Search sales customers by name fragment.") @server.tool(name="sales.customer.search", description="Search sales customers by name fragment.")
def customer_search(query: str) -> dict[str, object]: def customer_search(query: str) -> dict[str, object]:
@@ -65,10 +73,50 @@ def inventory_check(sku: str) -> dict[str, object]:
return {"sku": sku, "available": _INVENTORY.get(sku, 0)} return {"sku": sku, "available": _INVENTORY.get(sku, 0)}
@server.tool(name="sales.quote.create", description="Create a sales quote for a customer.")
def quote_create(customer_id: str, amount: float, region: str = "BR-SP") -> dict[str, object]:
quote_id = f"quote-{next(_quote_ids):03d}"
quote = {
"quote_id": quote_id,
"customer_id": customer_id,
"amount": amount,
"region": region,
"status": "draft",
}
_QUOTES[quote_id] = quote
return quote
@server.tool(name="sales.quote.update", description="Update an existing sales quote.")
def quote_update(quote_id: str, amount: float | None = None) -> dict[str, object]:
quote = _QUOTES.get(quote_id)
if quote is None:
return {"error": "not_found", "quote_id": quote_id}
if amount is not None:
quote["amount"] = amount
return quote
def main() -> None: def main() -> None:
host = os.environ.get("SALES_DOMAIN_HOST", "127.0.0.1") host = os.environ.get("SALES_DOMAIN_HOST", "127.0.0.1")
port = int(os.environ.get("SALES_DOMAIN_PORT", "8100")) port = int(os.environ.get("SALES_DOMAIN_PORT", "8100"))
server.run(transport="streamable-http", host=host, port=port) expected_token = os.environ.get("SALES_DOMAIN_SERVICE_TOKEN")
audit_log_path = os.environ.get("EMCP_AUDIT_LOG_PATH", "/tmp/emcp-audit.jsonl")
app: ASGIApp = server.streamable_http_app(host=host)
if expected_token:
# EP-05-T07: reject anything that didn't come through the Gateway's
# outbound identity (EP-07-T01) with the matching service credential.
app = BackendCredentialGate(
app,
expected_token=expected_token,
backend="sales-domain",
audit_sink=JSONLFileAuditSink(audit_log_path),
)
import uvicorn
uvicorn.run(app, host=host, port=port, log_level="info")
if __name__ == "__main__": if __name__ == "__main__":

View File

View File

@@ -0,0 +1,52 @@
"""Approval request contract (EP-14-T01, README.md §26).
Out-of-band, bound to the *exact* operation it was requested for (not just
the tool name), with an expiry and single-use semantics -- so an approval
granted for one call can never be replayed against a different one (same
tool, different arguments) or reused twice (README.md §46, "approval
replay").
"""
from __future__ import annotations
import hashlib
import json
from datetime import UTC, datetime
from typing import Any, Literal
from uuid import uuid4
from pydantic import BaseModel, Field
def compute_operation_hash(*, client_profile: str, tool: str, arguments: dict[str, Any]) -> str:
"""Binds an approval to one exact (client_profile, tool, arguments) triple.
Canonical JSON (sorted keys) so the same logical call always hashes the
same way regardless of dict insertion order.
"""
canonical = json.dumps(
{"client_profile": client_profile, "tool": tool, "arguments": arguments},
sort_keys=True,
default=str,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
class ApprovalRequest(BaseModel):
approval_id: str = Field(default_factory=lambda: str(uuid4()))
operation_hash: str
client_profile: str
tool: str
arguments_summary: dict[str, Any]
"""A copy of the relevant arguments for an approver to review -- README.md
§26 ("com resumo dos argumentos relevantes"). Not redacted in this RI;
a production deployment would apply the same PII-minimization rules as
audit logging (README.md §27)."""
reason_code: str
requested_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
expires_at: datetime
status: Literal["pending", "granted", "denied", "consumed"] = "pending"
def is_expired(self, *, now: datetime | None = None) -> bool:
return (now or datetime.now(UTC)) >= self.expires_at

View File

@@ -0,0 +1,113 @@
"""Approval service (EP-14-T02, README.md §26).
Gates a `REQUIRE_APPROVAL` PDP decision (EP-03-T04): the Gateway calls
`check_and_consume` for every attempt of an operation that needs approval; a
human approver (CLI, API, or a future Orchestrator human-in-the-loop node,
EP-11-T05) calls `request`/`grant`/`deny` out-of-band. Approval is never
implicit -- a REQUIRE_APPROVAL attempt with no matching grant is denied, not
queued-and-allowed.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from emcp_bus.approval.models import ApprovalRequest, compute_operation_hash
from emcp_bus.audit.events import approval_granted
from emcp_bus.audit.sink import AuditSink
class ApprovalNotFoundError(Exception):
pass
class ApprovalService:
def __init__(self, audit_sink: AuditSink | None = None) -> None:
self._requests: dict[str, ApprovalRequest] = {}
self._audit_sink = audit_sink
def request(
self,
*,
client_profile: str,
tool: str,
arguments: dict[str, Any],
reason_code: str,
ttl_seconds: int = 300,
) -> ApprovalRequest:
"""Create (or return the existing pending one for) an approval request
for this exact operation -- repeated attempts of the same call while
one is pending do not spawn duplicate requests for an approver to
wade through."""
operation_hash = compute_operation_hash(
client_profile=client_profile, tool=tool, arguments=arguments
)
existing = self._pending_for_operation(operation_hash)
if existing is not None:
return existing
approval = ApprovalRequest(
operation_hash=operation_hash,
client_profile=client_profile,
tool=tool,
arguments_summary=arguments,
reason_code=reason_code,
expires_at=datetime.now(UTC) + timedelta(seconds=ttl_seconds),
)
self._requests[approval.approval_id] = approval
return approval
def grant(self, approval_id: str) -> None:
approval = self._get(approval_id)
approval.status = "granted"
if self._audit_sink is not None:
self._audit_sink.emit(
approval_granted(
approval_id=approval.approval_id,
client_profile=approval.client_profile,
tool=approval.tool,
)
)
def deny(self, approval_id: str) -> None:
approval = self._get(approval_id)
approval.status = "denied"
def check_and_consume(
self, *, client_profile: str, tool: str, arguments: dict[str, Any]
) -> bool:
"""True exactly once for a matching, granted, unexpired approval.
Single-use (README.md §46, "approval replay"): a granted approval
that passes this check is immediately marked `consumed` and can
never satisfy a second attempt, even of the identical operation.
"""
operation_hash = compute_operation_hash(
client_profile=client_profile, tool=tool, arguments=arguments
)
for approval in self._requests.values():
if (
approval.operation_hash == operation_hash
and approval.status == "granted"
and not approval.is_expired()
):
approval.status = "consumed"
return True
return False
def _pending_for_operation(self, operation_hash: str) -> ApprovalRequest | None:
for approval in self._requests.values():
if (
approval.operation_hash == operation_hash
and approval.status == "pending"
and not approval.is_expired()
):
return approval
return None
def _get(self, approval_id: str) -> ApprovalRequest:
approval = self._requests.get(approval_id)
if approval is None:
raise ApprovalNotFoundError(approval_id)
return approval

View File

View File

@@ -0,0 +1,80 @@
"""Anti-bypass check for domain ("dumb") MCP servers (EP-05-T07, README.md §21, ADR-007).
Network segmentation (docker-compose.yml: domain servers publish no host
port, reachable only on the compose-internal network the Gateway also sits
on) is the primary control. This module is the second, in-process layer:
every domain server call must carry the *outbound* credential only the
Gateway holds (`BackendCredentialProvider`, EP-07-T01) as a bearer token: a
request missing it, or carrying the wrong value, did not come from the
Gateway and is rejected here, before it ever reaches a tool handler.
This is deliberately NOT an authorization decision (ADR-023: domain servers
never decide entitlement/risk) -- it is a bypass/integrity check, equivalent
in spirit to requiring mTLS from a fixed set of peers. The only decision
made here is "did this request present the one credential the Gateway is
configured to send", never "is this client allowed to call this tool".
"""
from __future__ import annotations
import hmac
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from emcp_bus.audit.events import bypass_attempt_detected
from emcp_bus.audit.sink import AuditSink
class BackendCredentialGate:
"""ASGI middleware: reject any request not bearing `expected_token`.
Wrap a domain server's app with this, e.g.:
app = BackendCredentialGate(server.streamable_http_app(...),
expected_token=..., backend="sales-domain",
audit_sink=...)
"""
def __init__(
self,
app: ASGIApp,
*,
expected_token: str,
backend: str,
audit_sink: AuditSink | None = None,
) -> None:
self._app = app
self._expected_token = expected_token
self._backend = backend
self._audit_sink = audit_sink
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self._app(scope, receive, send)
return
request = Request(scope)
presented = _extract_bearer_token(request.headers.get("authorization"))
if presented is None or not hmac.compare_digest(presented, self._expected_token):
if self._audit_sink is not None:
reason = "missing_credential" if presented is None else "wrong_credential"
self._audit_sink.emit(bypass_attempt_detected(backend=self._backend, reason=reason))
response = JSONResponse(
{"error": "backend_credential_required"},
status_code=401,
)
await response(scope, receive, send)
return
await self._app(scope, receive, send)
def _extract_bearer_token(authorization_header: str | None) -> str | None:
if authorization_header is None:
return None
scheme, _, value = authorization_header.partition(" ")
if scheme.lower() != "bearer" or not value:
return None
return value

View File

@@ -0,0 +1,76 @@
"""End-to-end correlation (EP-08-T03, README.md §27/§28, ADR-017).
One `DecisionCorrelation` is created per `tools/call` attempt and threaded
through: the PDP decision, the audit events it produces, and the current
OTel span (as attributes) -- so a single `decision_id` recovers every event
of one authorization attempt, and `policy_decision_id` recovers every event
that reused the *same* policy evaluation (relevant for the approval flow,
where a REQUIRE_APPROVAL attempt and its later granted retry share
`policy_decision_id` but not `decision_id`, since they are two distinct
attempts against the same policy_version+client_profile+tool+arguments).
Deliberately not an ID minted by OPA: this RI's OPA deployment does not use
bundle/decision-log infrastructure (see `PDPClient`/`compute_policy_version`),
so `policy_decision_id` is a deterministic hash over exactly the inputs the
PDP was actually given, which is sufficient to recover "which evaluation was
this" without that infrastructure.
"""
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass
from typing import Any
from opentelemetry import trace
def new_decision_id() -> str:
"""Unique per attempt -- never reused, even for a retried identical call."""
return uuid.uuid4().hex
def compute_policy_decision_id(
*, policy_version: str, client_profile: str, tool: str, arguments: dict[str, Any]
) -> str:
digest = hashlib.sha256()
digest.update(policy_version.encode("utf-8"))
digest.update(client_profile.encode("utf-8"))
digest.update(tool.encode("utf-8"))
for key in sorted(arguments):
digest.update(f"{key}={arguments[key]!r}".encode())
return digest.hexdigest()[:16]
@dataclass(frozen=True)
class DecisionCorrelation:
decision_id: str
policy_decision_id: str
entitlement_version: str | None
policy_version: str | None
mcp_request_id: str | None
def record_on_current_span(self) -> None:
"""Expose every correlation field as an attribute on the current OTel
span (README.md §27: "expostos no trace e no evento de auditoria") --
the span itself is already correlated cross-process via the SDK's
`OpenTelemetryMiddleware` (see common/otel.py); this adds the
business-level ids the trace_id alone doesn't carry."""
span = trace.get_current_span()
span.set_attribute("emcp.decision_id", self.decision_id)
span.set_attribute("emcp.policy_decision_id", self.policy_decision_id)
if self.entitlement_version is not None:
span.set_attribute("emcp.entitlement_version", self.entitlement_version)
if self.policy_version is not None:
span.set_attribute("emcp.policy_version", self.policy_version)
if self.mcp_request_id is not None:
span.set_attribute("emcp.mcp_request_id", self.mcp_request_id)
def current_trace_id() -> str | None:
span = trace.get_current_span()
context = span.get_span_context()
if context is None or context.trace_id == 0:
return None
return format(context.trace_id, "032x")

View File

@@ -0,0 +1,154 @@
"""Audit event taxonomy (EP-08-T02, README.md §27).
One factory function per event type named in §27's taxonomy (plus the
bypass-detection event from EP-05-T07). Each hard-codes its `EventCategory`
so callers never have to decide IC/NOC/GRL per call site -- that
classification is a property of the event type itself, not of the moment
it fires.
"""
from __future__ import annotations
from typing import Any
from emcp_bus.audit.correlation import DecisionCorrelation, current_trace_id
from emcp_bus.audit.models import EventCategory, EventEnvelope
def _base(
*,
event_type: str,
category: EventCategory,
client_id: str | None = None,
profile_name: str | None = None,
tool: str | None = None,
reason_code: str | None = None,
correlation: DecisionCorrelation | None = None,
payload: dict[str, Any] | None = None,
) -> EventEnvelope:
return EventEnvelope(
event_type=event_type,
category=category,
client_id=client_id,
profile_name=profile_name,
tool=tool,
reason_code=reason_code,
decision_id=correlation.decision_id if correlation else None,
policy_decision_id=correlation.policy_decision_id if correlation else None,
entitlement_version=correlation.entitlement_version if correlation else None,
policy_version=correlation.policy_version if correlation else None,
mcp_request_id=correlation.mcp_request_id if correlation else None,
trace_id=current_trace_id(),
payload=payload or {},
)
def client_authenticated(*, client_id: str, mcp_request_id: str | None) -> EventEnvelope:
return _base(
event_type="client_authenticated",
category=EventCategory.IC,
client_id=client_id,
payload={"mcp_request_id": mcp_request_id},
)
def tools_list_filtered(
*, client_id: str, profile_name: str, entitlement_version: str, allowed: int, total: int
) -> EventEnvelope:
return _base(
event_type="tools_list_filtered",
category=EventCategory.GRL,
client_id=client_id,
profile_name=profile_name,
payload={"entitlement_version": entitlement_version, "allowed": allowed, "total": total},
)
def tool_call_allowed(
*, client_id: str, profile_name: str, tool: str, correlation: DecisionCorrelation
) -> EventEnvelope:
return _base(
event_type="tool_call_allowed",
category=EventCategory.IC,
client_id=client_id,
profile_name=profile_name,
tool=tool,
correlation=correlation,
)
def tool_call_denied(
*,
client_id: str | None,
profile_name: str | None,
tool: str | None,
reason_code: str,
correlation: DecisionCorrelation | None = None,
) -> EventEnvelope:
return _base(
event_type="tool_call_denied",
category=EventCategory.GRL,
client_id=client_id,
profile_name=profile_name,
tool=tool,
reason_code=reason_code,
correlation=correlation,
)
def tool_call_require_approval(
*,
client_id: str,
profile_name: str,
tool: str,
approval_id: str,
correlation: DecisionCorrelation,
) -> EventEnvelope:
return _base(
event_type="tool_call_require_approval",
category=EventCategory.GRL,
client_id=client_id,
profile_name=profile_name,
tool=tool,
correlation=correlation,
payload={"approval_id": approval_id},
)
def approval_granted(*, approval_id: str, client_profile: str, tool: str) -> EventEnvelope:
return _base(
event_type="approval_granted",
category=EventCategory.GRL,
profile_name=client_profile,
tool=tool,
payload={"approval_id": approval_id},
)
def pdp_unavailable(*, client_id: str, tool: str) -> EventEnvelope:
return _base(
event_type="pdp_unavailable",
category=EventCategory.NOC,
client_id=client_id,
tool=tool,
)
def backend_credential_unavailable(*, client_id: str, tool: str, backend: str) -> EventEnvelope:
return _base(
event_type="backend_credential_unavailable",
category=EventCategory.NOC,
client_id=client_id,
tool=tool,
payload={"backend": backend},
)
def bypass_attempt_detected(*, backend: str, reason: str) -> EventEnvelope:
"""EP-05-T07: a request reached a domain server without the Gateway's
outbound credential -- i.e. it did not traverse the PEP."""
return _base(
event_type="bypass_attempt_detected",
category=EventCategory.GRL,
payload={"backend": backend, "reason": reason},
)

View File

@@ -0,0 +1,89 @@
"""Audit event schema (EP-08-T02, README.md §27).
Adopts the IC/NOC/GRL taxonomy from the Agent Platform OCI reference
(ADR-022, docs/research/hoshikawa-agent-platform-oci.md) as the
`EventEnvelope.category` classification, rather than inventing a parallel
one: IC (Interação de Cliente/negócio-jornada), NOC (operacional/erro), GRL
(guardrail/governança). `decision_id`/`policy_decision_id`/`entitlement_version`
(ADR-017, EP-08-T03) travel as first-class correlation fields on every event,
not as opaque payload keys, so a consumer can join events across the chain
without parsing `payload`.
"""
from __future__ import annotations
import hashlib
import uuid
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, Field
_SENSITIVE_KEY_MARKERS = ("token", "secret", "password", "authorization", "credential")
class EventCategory(StrEnum):
"""README.md §27 event classes, mapped onto the Hoshikawa IC/NOC/GRL vocabulary."""
IC = "ic"
"""Interação de Cliente / negócio-jornada: the call progressed normally
(e.g. `client_authenticated`, `tool_call_allowed`)."""
NOC = "noc"
"""Operacional/erro: an availability/infra failure, not a policy decision
(e.g. `pdp_unavailable`, `backend_credential_unavailable`)."""
GRL = "grl"
"""Guardrail/governança: an entitlement/policy boundary was enforced or
tested (e.g. `tool_call_denied`, `tools_list_filtered`, `bypass_attempt_detected`)."""
def _sanitize(value: Any) -> Any:
"""Never let a token/secret value reach a sink (§27, "Não registrar").
Recurses into mappings/sequences; any mapping key whose lowercased name
contains a marker in `_SENSITIVE_KEY_MARKERS` has its value replaced by a
`sha256:<hex>` reference instead of being dropped -- a reader can still
tell two events referenced the *same* secret without ever seeing it.
"""
if isinstance(value, dict):
sanitized: dict[str, Any] = {}
for key, inner in value.items():
if isinstance(key, str) and any(
marker in key.lower() for marker in _SENSITIVE_KEY_MARKERS
):
digest = hashlib.sha256(str(inner).encode("utf-8")).hexdigest()
sanitized[key] = f"sha256:{digest}"
else:
sanitized[key] = _sanitize(inner)
return sanitized
if isinstance(value, list):
return [_sanitize(item) for item in value]
return value
class EventEnvelope(BaseModel):
event_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
event_type: str
category: EventCategory
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
client_id: str | None = None
profile_name: str | None = None
tool: str | None = None
reason_code: str | None = None
decision_id: str | None = None
policy_decision_id: str | None = None
entitlement_version: str | None = None
policy_version: str | None = None
mcp_request_id: str | None = None
trace_id: str | None = None
payload: dict[str, Any] = Field(default_factory=dict)
def model_post_init(self, __context: Any) -> None:
# Sanitize post-construction so every construction path (including
# `model_validate` deserialization from a sink) gets the same guarantee.
self.payload = _sanitize(self.payload)

View File

@@ -0,0 +1,38 @@
"""Audit event sinks (EP-08-T02).
Append-only by contract: nothing in this module ever updates or deletes a
previously emitted event. `JSONLFileAuditSink` is the dev/deploy default
(one JSON object per line); `InMemoryAuditSink` exists purely for tests that
need to assert on emitted events without touching a filesystem. A Postgres
sink is a documented future extension (same `AuditSink` protocol), not
implemented in this RI.
"""
from __future__ import annotations
from pathlib import Path
from typing import Protocol
from emcp_bus.audit.models import EventEnvelope
class AuditSink(Protocol):
def emit(self, event: EventEnvelope) -> None: ...
class InMemoryAuditSink:
def __init__(self) -> None:
self.events: list[EventEnvelope] = []
def emit(self, event: EventEnvelope) -> None:
self.events.append(event)
class JSONLFileAuditSink:
def __init__(self, path: str | Path) -> None:
self._path = Path(path)
self._path.parent.mkdir(parents=True, exist_ok=True)
def emit(self, event: EventEnvelope) -> None:
with self._path.open("a", encoding="utf-8") as f:
f.write(event.model_dump_json() + "\n")

View File

View File

@@ -0,0 +1,75 @@
"""Outbound (backend) identity, distinct from inbound (MCP Client) identity
(EP-07-T01, README.md §20, ADR-006).
inbound token (MCP Client -> Gateway) != outbound token (Fabric -> Backend)
The Gateway authenticates the *caller*; this module answers a completely
separate question -- which credential the Fabric presents *to the backend*
-- and never answers it by handing the inbound token through unmodified
(README.md §35.6, "Token passthrough indiscriminado"). `service_account`
mode resolves a static, backend-specific credential from an env var at call
time; `token_exchange` (RFC 8693) is a documented, unimplemented extension
(see `BackendConfig`).
"""
from __future__ import annotations
import os
from pydantic import BaseModel
from emcp_bus.downstream.models import BackendConfig
class UnknownBackendError(Exception):
pass
class BackendCredentialUnavailableError(Exception):
"""The configured env var for a backend's service-account token is unset.
Distinct from `UnknownBackendError`: the backend *is* configured, but
its credential is missing at runtime -- callers (the Gateway) must
treat this as DENY, not as "fall back to no credential" (README.md §38).
"""
class BackendCredential(BaseModel):
backend: str
url: str
token: str
audience: str
class BackendCredentialProvider:
def __init__(self, backend_configs: list[BackendConfig]) -> None:
self._configs = {config.backend: config for config in backend_configs}
def credential_for(self, backend: str) -> BackendCredential:
config = self._configs.get(backend)
if config is None:
raise UnknownBackendError(backend)
if config.credential_mode == "token_exchange":
raise NotImplementedError(
f"credential_mode='token_exchange' for backend {backend!r} is a documented "
"extension point (RFC 8693 via an OIDC IdP), not implemented in this RI -- "
"see BackendConfig docstring."
)
assert config.service_account_token_env_var is not None # enforced by BackendConfig
token = os.environ.get(config.service_account_token_env_var)
if not token:
raise BackendCredentialUnavailableError(
f"Environment variable {config.service_account_token_env_var!r} for backend "
f"{backend!r} is not set"
)
return BackendCredential(
backend=backend, url=config.url, token=token, audience=config.audience
)
def url_for(self, backend: str) -> str:
config = self._configs.get(backend)
if config is None:
raise UnknownBackendError(backend)
return config.url

View File

@@ -0,0 +1,69 @@
"""Backend credential config schema (EP-07-T02, README.md §20).
One entry per backend the Fabric calls, declaring *how* the Gateway obtains
an outbound credential for it -- never the credential's value. The RI
implements `service_account` (a static credential resolved from an env var
at call time); `token_exchange` (RFC 8693, e.g. via Keycloak) is modeled as
a distinct, documented mode but not implemented in this milestone -- see
`emcp_bus.downstream.identity.BackendCredentialProvider`.
"""
from __future__ import annotations
import re
from typing import Literal, Self
from pydantic import BaseModel, field_validator, model_validator
_ENV_VAR_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
class BackendConfig(BaseModel):
backend: str
"""Adapter/service id, matching `CapabilityManifest.backend.service` (EP-02-T01)."""
url: str
"""Where the Fabric router (EP-06-T01) connects to reach this backend --
an MCP streamable-http endpoint for `backend.type == "mcp"`, or a REST
base URL for `"rest"` (EP-06-T04). Not a secret, so unlike the credential
fields below it may be a literal value, though it commonly uses
`${VAR}` too since it differs between `make dev` (127.0.0.1) and Docker
(the compose service DNS name)."""
audience: str
"""The outbound token's audience -- deliberately never equal to the Gateway's
own inbound `OIDC_AUDIENCE` (README.md §20: inbound and outbound authorization
are distinct relations)."""
credential_mode: Literal["service_account", "token_exchange"] = "service_account"
service_account_token_env_var: str | None = None
"""Name (not value) of the environment variable holding the backend's
static credential. Required when `credential_mode == "service_account"`."""
token_exchange_endpoint: str | None = None
"""RFC 8693 token endpoint URL. Documented extension point, not yet
implemented -- see module docstring."""
@field_validator("service_account_token_env_var")
@classmethod
def _looks_like_an_env_var_name_not_a_secret(cls, value: str | None) -> str | None:
if value is not None and not _ENV_VAR_NAME_PATTERN.match(value):
raise ValueError(
f"{value!r} does not look like an environment variable name "
"(expected e.g. 'SALES_DOMAIN_SERVICE_TOKEN') -- this field must "
"never hold a literal secret value (README.md §35.5)"
)
return value
@model_validator(mode="after")
def _required_field_matches_credential_mode(self) -> Self:
if self.credential_mode == "service_account" and not self.service_account_token_env_var:
raise ValueError(
"service_account_token_env_var is required for credential_mode='service_account'"
)
if self.credential_mode == "token_exchange" and not self.token_exchange_endpoint:
raise ValueError(
"token_exchange_endpoint is required when credential_mode == 'token_exchange'"
)
return self

View File

View File

@@ -0,0 +1,90 @@
"""Generic REST adapter (EP-06-T04, README.md §6.7).
Translates one `tools/call` invocation into an HTTP request against a REST
backend described entirely by config (`RestBackendConfig`) -- no new Python
code is needed to expose a new REST backend, only a YAML declaring, per
capability, the HTTP method and a path template (README.md §6.8's
`operation: "GET /v2/customers/{id}"` example). Path placeholders are
substituted from the call's arguments by name; whatever arguments remain
become the JSON body (write methods) or query string (GET/DELETE).
This RI's two example domains (sales, finance; EP-06-T02/T03) are both
MCP-native, so no seed `CapabilityManifest` declares `backend.type: "rest"`
yet, and the Fabric router (EP-06-T01) does not dispatch to this adapter on
any live path today -- it is implemented and unit-tested standalone (real
httpx request/response handling, against an `httpx.MockTransport` in-process
endpoint rather than a real network service, since no real REST example
backend is in scope). Wiring a third, REST-based example backend through
`CapabilityRouter`/the Gateway is possible without any contract change here
(dispatch on `BackendRoute.backend_type == "rest"`), left as unscoped,
non-blocking future work.
"""
from __future__ import annotations
from typing import Any, Literal
import httpx
from pydantic import BaseModel
class RestOperation(BaseModel):
method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
path: str
"""Path template, e.g. "/v2/customers/{customer_id}"."""
class RestBackendConfig(BaseModel):
backend: str
base_url: str
operations: dict[str, RestOperation]
"""Capability name -> operation, e.g. {"sales.customer.get": RestOperation(...)}."""
class UnknownOperationError(Exception):
pass
class RestAdapter:
"""One instance per REST backend; `call` runs one capability invocation."""
def __init__(
self, config: RestBackendConfig, *, client: httpx.AsyncClient | None = None
) -> None:
self._config = config
self._client = client or httpx.AsyncClient(base_url=config.base_url)
async def aclose(self) -> None:
await self._client.aclose()
async def call(
self, capability: str, arguments: dict[str, Any], *, bearer_token: str | None = None
) -> dict[str, Any]:
operation = self._config.operations.get(capability)
if operation is None:
raise UnknownOperationError(
f"REST backend {self._config.backend!r} has no operation configured "
f"for capability {capability!r}"
)
remaining = dict(arguments)
path = operation.path
for key in list(remaining):
placeholder = "{" + key + "}"
if placeholder in path:
path = path.replace(placeholder, str(remaining.pop(key)))
headers = {"Authorization": f"Bearer {bearer_token}"} if bearer_token else {}
if operation.method in ("GET", "DELETE"):
response = await self._client.request(
operation.method, path, params=remaining or None, headers=headers
)
else:
response = await self._client.request(
operation.method, path, json=remaining, headers=headers
)
response.raise_for_status()
if not response.content:
return {}
result: dict[str, Any] = response.json()
return result

View File

@@ -0,0 +1,88 @@
"""Enterprise MCP Fabric core: capability router (EP-06-T01, README.md §6.7).
Resolves a `<domain>.<capability>` tool name to the backend that actually
serves it, using the Global Capability Registry (EP-02-T03) as the single
source of truth -- not a Gateway-local static mapping (the pre-M3 state this
replaces: `GatewayConfig.downstream_url`/`downstream_backend` hard-coded to
one backend, see git history of gateway/server.py). Only `ACTIVE` manifests
are routable (README.md §6.6): anything still in nominate/review/register,
or already deprecated/retired, is a control-plane-only concern the Fabric
must never dispatch a live call to.
The Registry says *which* backend id serves a capability
(`CapabilityManifest.backend.service`); `BackendCredentialProvider` (EP-07)
resolves that id to a connection URL and an outbound credential -- the
router itself never touches a socket.
"""
from __future__ import annotations
from dataclasses import dataclass
from emcp_bus.downstream.identity import BackendCredentialProvider, UnknownBackendError
from emcp_bus.registry.models import CapabilityManifest
from emcp_bus.registry.store import RegistryStore
class UnroutableCapabilityError(Exception):
"""`tool` has no ACTIVE capability manifest, or its backend is unresolvable.
Callers (the Gateway) must treat this identically to DENY (README.md
§38) -- never as "fall back to some default backend".
"""
@dataclass(frozen=True)
class BackendRoute:
tool: str
backend_id: str
backend_type: str
"""`CapabilityManifest.backend.type`: "mcp" | "rest" | "grpc" (EP-06-T04
dispatches "rest" through `fabric.adapters.rest_adapter`; "mcp" is
handled directly by the Gateway via a downstream MCP session)."""
url: str
credential_token: str
credential_audience: str
class CapabilityRouter:
def __init__(
self, registry: RegistryStore, backend_credentials: BackendCredentialProvider
) -> None:
self._registry = registry
self._backend_credentials = backend_credentials
def route(self, tool: str) -> BackendRoute:
manifest = self._active_manifest(tool)
backend_ref = manifest.backend
try:
credential = self._backend_credentials.credential_for(backend_ref.service)
except UnknownBackendError as exc:
raise UnroutableCapabilityError(
f"Capability {tool!r} references unknown backend {backend_ref.service!r}"
) from exc
return BackendRoute(
tool=tool,
backend_id=backend_ref.service,
backend_type=backend_ref.type,
url=credential.url,
credential_token=credential.token,
credential_audience=credential.audience,
)
def active_capabilities_for(self, tool_names: frozenset[str]) -> list[CapabilityManifest]:
"""Every ACTIVE manifest whose name is in `tool_names` -- lets
`on_list_tools` know which distinct backends it must actually query
for a given client's entitlement, instead of always querying every
registered backend (README.md §6.7)."""
return [m for m in self._registry.list_active() if m.name in tool_names]
def distinct_backends_for(self, tool_names: frozenset[str]) -> set[str]:
return {m.backend.service for m in self.active_capabilities_for(tool_names)}
def _active_manifest(self, tool: str) -> CapabilityManifest:
for manifest in self._registry.list_active():
if manifest.name == tool:
return manifest
raise UnroutableCapabilityError(f"No ACTIVE capability named {tool!r}")

View File

@@ -0,0 +1,66 @@
"""Private `tools/list` cache hint (EP-05-T03, README.md §13, Threat: Cache
leakage §28).
SEP-2549 (`ttlMs`/`cacheScope`) is a field pair the `mcp` SDK itself defines
(`mcp.server.caching.CacheHint`, `types.ListToolsResult.ttl_ms`/`.cache_scope`)
-- there is deliberately no hand-rolled HTTP cache here, only this thin
wrapper around the SDK's own primitive.
**Important, verified-empirically limitation of this RI's deployment
shape** (not a bug in this module): SEP-2549 belongs to protocol revision
2026-07-28, which the SDK implements as an entirely different wire mode --
a stateless, session-less per-request envelope (`mcp/server/_streamable_http_modern.py`:
"no `initialize` handshake, no `Mcp-Session-Id`") reached via `server/discover`,
never via the classic `initialize` handshake. `mcp_types.version.HANDSHAKE_PROTOCOL_VERSIONS`
(everything reachable via `ClientSession.initialize()`) and
`MODERN_PROTOCOL_VERSIONS` (`("2026-07-28",)`, the stateless mode) are
disjoint sets. This RI's entire transport -- the Gateway's inbound side *and*
every downstream connection it opens (`_with_downstream_session`) -- uses the
classic session handshake exclusively, so a connection here can never
negotiate 2026-07-28, and the SDK's own per-version wire sieve
(`ServerRunner._serialize`) deliberately strips `ttlMs`/`cacheScope` for
every version outside `MODERN_PROTOCOL_VERSIONS` ("2026-era vocabulary
cannot be delivered on a legacy wire"). Concretely: `tools_list_cache_hint()`
below is computed correctly and set on every `ListToolsResult` `on_list_tools`
returns, but on today's wire the client only ever sees the field's own
default (`ttl_ms=0`, `cache_scope="private"`) -- which happens to be the
maximally conservative fallback (`ttl_ms=0` means "never treat this as
fresh," i.e. no caching benefit, but also no risk), not an unsafe one. This
code is kept anyway: it costs nothing, is exactly correct if this Gateway
ever adds the stateless per-request transport mode, and documents the
intended behavior precisely. See
`tests/e2e/test_governed_gateway.py::test_tools_list_cache_scope_is_always_private`
for what is actually verifiable given this constraint.
The Gateway's own `on_list_tools` already recomputes `MaximumEntitlement`
fresh on every call (no server-side caching of the filtered catalog across
clients), so cross-client leakage (Teste 5, §45) cannot happen regardless of
what any hint does or doesn't tell a *client* to do locally, and regardless
of whether `ttl_ms` reaches the wire: this module's job is telling a
well-behaved client on a future/modern transport the two things it would
need to cache safely -- how long, and, per SEP-2549's contract, "private"
scope, so a cached tools/list result is scoped to a client's own
authorization context and never shared with a different one.
Revocation (EP-04-T03) is honored independently of `ttl_ms`: a revoked
client's *next* `tools/list`/`tools/call` is re-evaluated server-side
regardless of whether its own local cache still thinks a prior response is
fresh -- this hint is an optimization for a well-behaved client, never the
Gateway's own source of truth.
"""
from __future__ import annotations
import os
from mcp.server.caching import CacheHint
_DEFAULT_TTL_MS = 5_000
def tools_list_cache_hint() -> CacheHint:
ttl_ms = int(os.environ.get("EMCP_TOOLS_LIST_CACHE_TTL_MS", str(_DEFAULT_TTL_MS)))
# `scope` is never "public": a discovery result depends on the caller's
# own MaximumEntitlement, so it must never be treated as reusable across
# a different authorization context.
return CacheHint(ttl_ms=ttl_ms, scope="private")

View File

@@ -0,0 +1,119 @@
"""Header/body consistency check (EP-05-T08, README.md §22, SEP-2243).
The 2026-07-28 MCP revision has clients mirror the JSON-RPC `method` (and,
for `tools/call`/`prompts/get`/`resources/read`, the named resource) into
the `Mcp-Method`/`Mcp-Name` HTTP headers -- `mcp`'s own `ClientSession`
already sends both on every request (verified against the installed SDK).
The SDK's *modern per-request-envelope* transport path validates this
consistency internally, but only for that one wire mode (a fresh
`_meta.protocolVersion` on every request, no `initialize` handshake) --
this RI's Gateway (like most current clients, including its own test
suite) uses the classic session handshake instead, where nothing upstream
of application code checks it. This module is that check: if both a header
and the corresponding body field are present, they must agree, or the
request is rejected before it ever reaches authentication/authorization --
producing one canonical (method, name) pair a downstream handler can trust,
rather than silently picking one of two disagreeing sources (README.md
§22, "produzir representação canônica única").
Headers absent entirely (a client that doesn't implement SEP-2243) are not
an error -- this is a consistency check, not a mandate that every client
adopt the new headers.
"""
from __future__ import annotations
import json
from typing import Any
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_NAME_HEADER, NAME_BEARING_METHODS
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
def find_header_body_mismatch(
body: bytes, *, method_header: str | None, name_header: str | None
) -> str | None:
"""Reason string if `body` disagrees with the given header values, else `None`.
A body that isn't valid JSON, or isn't a JSON-RPC request shape, is left
for the app's own JSON-RPC parsing to reject -- this function only rules
on cases where it can positively compare a body value against a header.
"""
if method_header is None and name_header is None:
return None
try:
parsed: Any = json.loads(body) if body else None
except json.JSONDecodeError:
return None
if not isinstance(parsed, dict):
return None
body_method = parsed.get("method")
if method_header is not None and isinstance(body_method, str) and method_header != body_method:
return (
f"{MCP_METHOD_HEADER} header ({method_header!r}) does not match "
f"body.method ({body_method!r})"
)
if name_header is not None and isinstance(body_method, str):
name_key = NAME_BEARING_METHODS.get(body_method)
if name_key is not None:
params = parsed.get("params")
body_name = params.get(name_key) if isinstance(params, dict) else None
if isinstance(body_name, str) and name_header != body_name:
return (
f"{MCP_NAME_HEADER} header ({name_header!r}) does not match "
f"body.params.{name_key} ({body_name!r})"
)
return None
class CanonicalRequestGate:
"""ASGI middleware wrapping the Gateway's MCP app: rejects a POST whose
`Mcp-Method`/`Mcp-Name` headers disagree with its JSON-RPC body, before
the request ever reaches authentication or `on_list_tools`/`on_call_tool`."""
def __init__(self, app: ASGIApp) -> None:
self._app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or scope.get("method") != "POST":
await self._app(scope, receive, send)
return
headers = {
key.decode("latin-1").lower(): value.decode("latin-1")
for key, value in scope["headers"]
}
method_header = headers.get(MCP_METHOD_HEADER)
name_header = headers.get(MCP_NAME_HEADER)
if method_header is None and name_header is None:
await self._app(scope, receive, send)
return
buffered: list[Message] = []
body = b""
more_body = True
while more_body:
message = await receive()
buffered.append(message)
body += message.get("body", b"")
more_body = message.get("more_body", False)
mismatch = find_header_body_mismatch(
body, method_header=method_header, name_header=name_header
)
if mismatch is not None:
response = JSONResponse(
{"error": "header_body_mismatch", "detail": mismatch}, status_code=400
)
await response(scope, receive, send)
return
async def replay_receive() -> Message:
if buffered:
return buffered.pop(0)
return {"type": "http.disconnect"}
await self._app(scope, replay_receive, send)

View File

@@ -15,6 +15,13 @@ not configured for a given deployment, not a bug to work around.
Fail-closed (README.md §38, EP-05-T06): a PDP that cannot be reached is a Fail-closed (README.md §38, EP-05-T06): a PDP that cannot be reached is a
DENY, not an ALLOW -- see the `PDPUnavailableError` branch in `on_call_tool`. DENY, not an ALLOW -- see the `PDPUnavailableError` branch in `on_call_tool`.
Routing is resolved per capability through the Enterprise MCP Fabric router
(EP-06-T01, `emcp_bus.fabric.router.CapabilityRouter`) against the Global
Capability Registry (EP-02-T03), seeded at startup from `config/capabilities/`
-- not a hard-coded single downstream. A client's `tools/list` may therefore
span more than one backend (e.g. Sales + Finance); `tools/call` always
re-resolves the one backend that specific tool routes to.
Configuration is read from the environment lazily, inside `lifespan()`/ Configuration is read from the environment lazily, inside `lifespan()`/
`build_auth()`, rather than into module-level constants at import time: `build_auth()`, rather than into module-level constants at import time:
every RI service is one process per deployment in production, so this is every RI service is one process per deployment in production, so this is
@@ -23,12 +30,15 @@ lifespan runs within one Python process, which the test suite relies on to
exercise multiple configurations without re-importing this module). exercise multiple configurations without re-importing this module).
Tracing is automatic (see `common/otel.py`): the SDK's `OpenTelemetryMiddleware` Tracing is automatic (see `common/otel.py`): the SDK's `OpenTelemetryMiddleware`
correlates every hop without any manual span code here. correlates every hop without any manual span code here; `emcp_bus.audit.correlation`
adds the business-level decision/entitlement/policy ids as span attributes on
top of that (EP-08-T03).
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import tempfile
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
@@ -36,23 +46,46 @@ from pathlib import Path
import mcp_types as types import mcp_types as types
from mcp.client.session import ClientSession from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import ( # type: ignore[attr-defined]
create_mcp_http_client,
streamable_http_client,
)
from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import TokenVerifier from mcp.server.auth.provider import TokenVerifier
from mcp.server.auth.settings import AuthSettings from mcp.server.auth.settings import AuthSettings
from mcp.server.context import ServerRequestContext from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server from mcp.server.lowlevel import Server
from pydantic import AnyHttpUrl from pydantic import AnyHttpUrl
from starlette.types import ASGIApp
from emcp_bus.approval.service import ApprovalService
from emcp_bus.audit import events
from emcp_bus.audit.correlation import (
DecisionCorrelation,
compute_policy_decision_id,
new_decision_id,
)
from emcp_bus.audit.sink import AuditSink, JSONLFileAuditSink
from emcp_bus.common.config import load_yaml_models from emcp_bus.common.config import load_yaml_models
from emcp_bus.common.otel import setup_tracing from emcp_bus.common.otel import setup_tracing
from emcp_bus.downstream.identity import (
BackendCredentialProvider,
BackendCredentialUnavailableError,
UnknownBackendError,
)
from emcp_bus.downstream.models import BackendConfig
from emcp_bus.entitlement.manager import EntitlementManager, UnknownClientError from emcp_bus.entitlement.manager import EntitlementManager, UnknownClientError
from emcp_bus.entitlement.models import ClientProfile from emcp_bus.entitlement.models import ClientProfile
from emcp_bus.fabric.router import CapabilityRouter, UnroutableCapabilityError
from emcp_bus.gateway.cache import tools_list_cache_hint
from emcp_bus.gateway.canonical_request import CanonicalRequestGate
from emcp_bus.identity.authn import TokenValidator from emcp_bus.identity.authn import TokenValidator
from emcp_bus.identity.models import ClientRegistration from emcp_bus.identity.models import ClientRegistration
from emcp_bus.identity.token_verifier import MCPTokenVerifier from emcp_bus.identity.token_verifier import MCPTokenVerifier
from emcp_bus.pdp.client import PDPClient, PDPUnavailableError, compute_policy_version from emcp_bus.pdp.client import PDPClient, PDPUnavailableError, compute_policy_version
from emcp_bus.pdp.models import PDPOutcome from emcp_bus.pdp.models import PDPOutcome
from emcp_bus.registry.seed import seed_registry
from emcp_bus.registry.store import RegistryStore
setup_tracing("emcp-gateway") setup_tracing("emcp-gateway")
@@ -61,22 +94,22 @@ _REPO_ROOT = Path(__file__).resolve().parents[3]
@dataclass(frozen=True) @dataclass(frozen=True)
class GatewayConfig: class GatewayConfig:
downstream_url: str
opa_url: str opa_url: str
config_dir: Path config_dir: Path
oidc_jwks_url: str oidc_jwks_url: str
oidc_issuer: str oidc_issuer: str
oidc_audience: str oidc_audience: str
audit_log_path: str
@classmethod @classmethod
def from_env(cls) -> GatewayConfig: def from_env(cls) -> GatewayConfig:
return cls( return cls(
downstream_url=os.environ.get("SALES_DOMAIN_URL", "http://127.0.0.1:8100/mcp"),
opa_url=os.environ.get("OPA_URL", "http://127.0.0.1:8181"), opa_url=os.environ.get("OPA_URL", "http://127.0.0.1:8181"),
config_dir=Path(os.environ.get("EMCP_CONFIG_DIR", str(_REPO_ROOT / "config"))), config_dir=Path(os.environ.get("EMCP_CONFIG_DIR", str(_REPO_ROOT / "config"))),
oidc_jwks_url=os.environ.get("OIDC_JWKS_URL", ""), oidc_jwks_url=os.environ.get("OIDC_JWKS_URL", ""),
oidc_issuer=os.environ.get("OIDC_ISSUER", ""), oidc_issuer=os.environ.get("OIDC_ISSUER", ""),
oidc_audience=os.environ.get("OIDC_AUDIENCE", "emcp-gateway"), oidc_audience=os.environ.get("OIDC_AUDIENCE", "emcp-gateway"),
audit_log_path=os.environ.get("EMCP_AUDIT_LOG_PATH", "/tmp/emcp-audit.jsonl"),
) )
@property @property
@@ -91,16 +124,47 @@ class GatewayConfig:
def policies_dir(self) -> Path: def policies_dir(self) -> Path:
return self.config_dir / "policies" return self.config_dir / "policies"
@property
def backends_dir(self) -> Path:
return self.config_dir / "backends"
@property
def capabilities_dir(self) -> Path:
return self.config_dir / "capabilities"
@dataclass @dataclass
class GatewayState: class GatewayState:
entitlement_manager: EntitlementManager entitlement_manager: EntitlementManager
pdp: PDPClient pdp: PDPClient
downstream_url: str router: CapabilityRouter
backend_credentials: BackendCredentialProvider
approval_service: ApprovalService
audit_sink: AuditSink
_current_state: GatewayState | None = None
"""The live `GatewayState` of whichever app instance is currently running.
Every other per-request need is served by `ctx.lifespan_context`
(`on_list_tools`/`on_call_tool`); this module-level mirror exists solely so
an out-of-band approver -- a CLI, an HTTP endpoint, a future Orchestrator
node (EP-11-T05) -- can reach the *same* `ApprovalService` instance the
Gateway's own `on_call_tool` consults, since none of those call sites go
through an MCP request context at all. `get_current_state()` is the
supported way to read it.
"""
def get_current_state() -> GatewayState:
if _current_state is None:
raise RuntimeError("Gateway is not running (lifespan has not started)")
return _current_state
@asynccontextmanager @asynccontextmanager
async def lifespan(server: Server[GatewayState]) -> AsyncIterator[GatewayState]: async def lifespan(server: Server[GatewayState]) -> AsyncIterator[GatewayState]:
global _current_state
config = GatewayConfig.from_env() config = GatewayConfig.from_env()
pdp = PDPClient( pdp = PDPClient(
base_url=config.opa_url, policy_version=compute_policy_version(config.policies_dir) base_url=config.opa_url, policy_version=compute_policy_version(config.policies_dir)
@@ -111,26 +175,62 @@ async def lifespan(server: Server[GatewayState]) -> AsyncIterator[GatewayState]:
# Deliberately not try/excepted: a PDP the Gateway cannot sync entitlements # Deliberately not try/excepted: a PDP the Gateway cannot sync entitlements
# to at startup must not come up looking healthy (README.md §38). # to at startup must not come up looking healthy (README.md §38).
await manager.sync_profiles_to_pdp() await manager.sync_profiles_to_pdp()
try:
yield GatewayState( backend_credentials = BackendCredentialProvider(
entitlement_manager=manager, pdp=pdp, downstream_url=config.downstream_url load_yaml_models(config.backends_dir, BackendConfig)
) )
# Fresh Registry per lifespan run (mirrors `manager`/`backend_credentials`
# above): a temp file rather than `config_dir`-relative, so repeated
# lifespan runs within one test process (see module docstring) never see
# another run's stale state. Also not try/excepted: a Registry the
# Gateway cannot seed at startup must not come up looking healthy either.
registry_fd, registry_db_path_str = tempfile.mkstemp(prefix="emcp-registry-", suffix=".db")
os.close(registry_fd)
registry_db_path = Path(registry_db_path_str)
registry = RegistryStore(registry_db_path)
seed_registry(registry, config.capabilities_dir)
router = CapabilityRouter(registry, backend_credentials)
audit_sink = JSONLFileAuditSink(config.audit_log_path)
state = GatewayState(
entitlement_manager=manager,
pdp=pdp,
router=router,
backend_credentials=backend_credentials,
approval_service=ApprovalService(audit_sink=audit_sink),
audit_sink=audit_sink,
)
_current_state = state
try:
yield state
finally: finally:
_current_state = None
await pdp.aclose() await pdp.aclose()
registry_db_path.unlink(missing_ok=True)
async def _with_downstream_session[ResultT]( async def _with_downstream_session[ResultT](
downstream_url: str, downstream_url: str,
backend_token: str,
coro_factory: Callable[[ClientSession], Awaitable[ResultT]], coro_factory: Callable[[ClientSession], Awaitable[ResultT]],
) -> ResultT: ) -> ResultT:
"""Open a fresh downstream connection for one request and run `coro_factory(session)`. """Open a fresh downstream connection for one request and run `coro_factory(session)`.
A connection per call is simpler and safer than a shared long-lived `backend_token` is the *outbound* credential (EP-07-T01) -- never the
session under concurrent requests; the Enterprise MCP Fabric (EP-06) is inbound MCP Client's own bearer token (README.md §20, ADR-006: no blind
where connection pooling/reuse per backend would be introduced later. token passthrough). A connection per call is simpler and safer than a
shared long-lived session under concurrent requests; connection
pooling/reuse per backend is a natural extension of the Fabric router
(EP-06-T01), not implemented in this milestone.
""" """
async with ( async with (
streamable_http_client(downstream_url) as (read_stream, write_stream), create_mcp_http_client(headers={"Authorization": f"Bearer {backend_token}"}) as http_client,
streamable_http_client(downstream_url, http_client=http_client) as (
read_stream,
write_stream,
),
ClientSession(read_stream, write_stream) as session, ClientSession(read_stream, write_stream) as session,
): ):
await session.initialize() await session.initialize()
@@ -148,6 +248,10 @@ def _denied(reason_code: str) -> types.CallToolResult:
) )
def _request_id_str(ctx: ServerRequestContext[GatewayState]) -> str | None:
return str(ctx.request_id) if ctx.request_id is not None else None
async def on_list_tools( async def on_list_tools(
ctx: ServerRequestContext[GatewayState], ctx: ServerRequestContext[GatewayState],
params: types.PaginatedRequestParams | None, params: types.PaginatedRequestParams | None,
@@ -162,13 +266,42 @@ async def on_list_tools(
except UnknownClientError: except UnknownClientError:
return types.ListToolsResult(tools=[]) return types.ListToolsResult(tools=[])
downstream_result = await _with_downstream_session( state.audit_sink.emit(
state.downstream_url, lambda session: session.list_tools(params=params) events.client_authenticated(
client_id=access_token.client_id, mcp_request_id=_request_id_str(ctx)
) )
filtered_tools = [ )
tool for tool in downstream_result.tools if tool.name in resolved.allowed_tools
] # EP-06-T01: query only the backends that actually serve a tool this
return downstream_result.model_copy(update={"tools": filtered_tools}) # client is entitled to, never every registered backend -- e.g. a
# Sales.Read client never opens a connection to the Finance backend.
all_tools: list[types.Tool] = []
for backend_id in sorted(state.router.distinct_backends_for(resolved.allowed_tools)):
try:
credential = state.backend_credentials.credential_for(backend_id)
except (UnknownBackendError, BackendCredentialUnavailableError):
continue
downstream_result = await _with_downstream_session(
credential.url, credential.token, lambda session: session.list_tools(params=params)
)
all_tools.extend(t for t in downstream_result.tools if t.name in resolved.allowed_tools)
state.audit_sink.emit(
events.tools_list_filtered(
client_id=access_token.client_id,
profile_name=resolved.profile_name,
entitlement_version=resolved.entitlement_version,
allowed=len(all_tools),
total=len(resolved.allowed_tools),
)
)
# EP-05-T03: see cache.py's module docstring -- `ttl_ms` will not
# actually reach a client on this RI's classic-session transport (a
# verified SDK/wire-mode constraint, not a bug here), but `cache_scope`
# is always "private" regardless, and this is exactly correct if this
# Gateway ever adds the stateless per-request (2026-07-28) transport.
hint = tools_list_cache_hint()
return types.ListToolsResult(tools=all_tools, ttl_ms=hint.ttl_ms, cache_scope=hint.scope)
async def on_call_tool( async def on_call_tool(
@@ -185,26 +318,110 @@ async def on_call_tool(
except UnknownClientError: except UnknownClientError:
return _denied("UNKNOWN_CLIENT") return _denied("UNKNOWN_CLIENT")
try: arguments = dict(params.arguments or {})
decision = await state.pdp.evaluate( correlation = DecisionCorrelation(
decision_id=new_decision_id(),
policy_decision_id=compute_policy_decision_id(
policy_version=state.pdp.policy_version,
client_profile=resolved.profile_name, client_profile=resolved.profile_name,
tool=params.name, tool=params.name,
arguments=dict(params.arguments or {}), arguments=arguments,
),
entitlement_version=resolved.entitlement_version,
policy_version=state.pdp.policy_version,
mcp_request_id=_request_id_str(ctx),
)
correlation.record_on_current_span()
try:
decision = await state.pdp.evaluate(
client_profile=resolved.profile_name, tool=params.name, arguments=arguments
) )
except PDPUnavailableError: except PDPUnavailableError:
state.audit_sink.emit(
events.pdp_unavailable(client_id=access_token.client_id, tool=params.name)
)
return _denied("PDP_UNAVAILABLE") return _denied("PDP_UNAVAILABLE")
if decision.outcome is PDPOutcome.DENY: if decision.outcome is PDPOutcome.DENY:
state.audit_sink.emit(
events.tool_call_denied(
client_id=access_token.client_id,
profile_name=resolved.profile_name,
tool=params.name,
reason_code=decision.reason_code,
correlation=correlation,
)
)
return _denied(decision.reason_code) return _denied(decision.reason_code)
if decision.outcome is PDPOutcome.REQUIRE_APPROVAL: if decision.outcome is PDPOutcome.REQUIRE_APPROVAL:
# EP-14 (human approval workflow) does not exist yet in this milestone try:
# -- fail closed rather than silently upgrading to ALLOW. approved = state.approval_service.check_and_consume(
return _denied(f"REQUIRE_APPROVAL:{decision.reason_code}") client_profile=resolved.profile_name, tool=params.name, arguments=arguments
)
except Exception: # noqa: BLE001 -- EP-14-T03: any approval-service failure is DENY, never ALLOW
approved = False
if not approved:
# Out-of-band from here: an approver calls ApprovalService.grant()
# with this approval_id (CLI/API today; EP-11-T05's Orchestrator
# interrupt node later) before the caller retries the same call.
request = state.approval_service.request(
client_profile=resolved.profile_name,
tool=params.name,
arguments=arguments,
reason_code=decision.reason_code,
)
state.audit_sink.emit(
events.tool_call_require_approval(
client_id=access_token.client_id,
profile_name=resolved.profile_name,
tool=params.name,
approval_id=request.approval_id,
correlation=correlation,
)
)
return _denied(f"REQUIRE_APPROVAL:{decision.reason_code}:{request.approval_id}")
try:
route = state.router.route(params.name)
except UnroutableCapabilityError:
state.audit_sink.emit(
events.tool_call_denied(
client_id=access_token.client_id,
profile_name=resolved.profile_name,
tool=params.name,
reason_code="UNROUTABLE_CAPABILITY",
correlation=correlation,
)
)
return _denied("UNROUTABLE_CAPABILITY")
if route.backend_type != "mcp":
# EP-06-T04: a "rest"/"grpc" backend.type has no seed capability yet
# (see fabric/adapters/rest_adapter.py's module docstring) -- fail
# closed rather than guess at a dispatch path with no test coverage.
state.audit_sink.emit(
events.backend_credential_unavailable(
client_id=access_token.client_id, tool=params.name, backend=route.backend_id
)
)
return _denied(f"UNSUPPORTED_BACKEND_TYPE:{route.backend_type}")
result = await _with_downstream_session( result = await _with_downstream_session(
state.downstream_url, lambda session: session.call_tool(params.name, params.arguments or {}) route.url,
route.credential_token,
lambda session: session.call_tool(params.name, params.arguments or {}),
) )
if isinstance(result, types.CallToolResult): if isinstance(result, types.CallToolResult):
state.audit_sink.emit(
events.tool_call_allowed(
client_id=access_token.client_id,
profile_name=resolved.profile_name,
tool=params.name,
correlation=correlation,
)
)
return result return result
raise TypeError(f"Unexpected downstream result type for tools/call: {type(result)!r}") raise TypeError(f"Unexpected downstream result type for tools/call: {type(result)!r}")
@@ -246,13 +463,27 @@ def build_auth(
return auth_settings, token_verifier return auth_settings, token_verifier
def build_app(
auth_settings: AuthSettings | None,
token_verifier: TokenVerifier | None,
*,
host: str = "127.0.0.1",
) -> ASGIApp:
"""The Gateway's real ASGI app: the MCP Server wrapped with EP-05-T08's
header/body consistency gate. Used by `main()` and by the test suite, so
tests exercise the exact same wiring a deployment runs, not a stripped
down variant of it."""
app = server.streamable_http_app(host=host, auth=auth_settings, token_verifier=token_verifier)
return CanonicalRequestGate(app)
def main() -> None: def main() -> None:
import uvicorn import uvicorn
host = os.environ.get("GATEWAY_HOST", "127.0.0.1") host = os.environ.get("GATEWAY_HOST", "127.0.0.1")
port = int(os.environ.get("GATEWAY_PORT", "8000")) port = int(os.environ.get("GATEWAY_PORT", "8000"))
auth_settings, token_verifier = build_auth() auth_settings, token_verifier = build_auth()
app = server.streamable_http_app(host=host, auth=auth_settings, token_verifier=token_verifier) app = build_app(auth_settings, token_verifier, host=host)
uvicorn.run(app, host=host, port=port, log_level="info") uvicorn.run(app, host=host, port=port, log_level="info")

View File

@@ -0,0 +1,45 @@
"""Shared-client lint (EP-01-T05, README.md §18/§35).
The anti-pattern this closes: two distinct agents registered against the
*same* underlying MCP Client (`client_id` -- the same Identity Provider
client/secret) but pointing at *different* client profiles. Sharing one
`client_id` across agents is only ever safe when every agent that shares it
would legitimately carry the same MaximumEntitlement ceiling (README.md
§18); if their registrations disagree on `profile`, one of two agents is
either over- or under-entitled relative to what its own registration
declares, and which one wins is just whichever YAML `load_yaml_models`
happens to process last (`EntitlementManager.load_registrations` keys
registrations by `client_id` in a plain dict) -- a silent, unaudited
authorization change, not a deliberate sharing decision.
"""
from __future__ import annotations
from collections import defaultdict
from emcp_bus.identity.models import ClientRegistration
class SharedClientLintError(Exception):
def __init__(self, violations: list[str]) -> None:
self.violations = violations
super().__init__("; ".join(violations))
def find_shared_client_violations(registrations: list[ClientRegistration]) -> list[str]:
profiles_by_client: dict[str, set[str]] = defaultdict(set)
for registration in registrations:
profiles_by_client[registration.client_id].add(registration.profile)
return [
f"client_id {client_id!r} is registered against more than one profile: {sorted(profiles)} "
"(README.md §18/§35: agents sharing an MCP Client must resolve to the same profile)"
for client_id, profiles in sorted(profiles_by_client.items())
if len(profiles) > 1
]
def assert_no_shared_client_violations(registrations: list[ClientRegistration]) -> None:
violations = find_shared_client_violations(registrations)
if violations:
raise SharedClientLintError(violations)

View File

@@ -0,0 +1,44 @@
"""Registry lifecycle state machine (EP-02-T04, README.md §23).
Nominate -> Review -> Register -> Publish -> Observe
Observe -> Change -> Publish
Observe -> Deprecate -> Retire
`ACTIVE` collapses README.md's Publish+Observe into one steady state (a
manifest that is live and being observed simultaneously, from this RI's
point of view -- there is no separate "being observed" flag to track). The
one invariant every caller of `transition()` gets for free is the one this
task's acceptance criterion names directly: nothing reaches `ACTIVE` without
having passed through `REVIEW` first.
"""
from __future__ import annotations
from emcp_bus.registry.models import LifecycleStatus
_ALLOWED_TRANSITIONS: dict[LifecycleStatus, frozenset[LifecycleStatus]] = {
LifecycleStatus.NOMINATE: frozenset({LifecycleStatus.REVIEW}),
# A rejected review goes back to NOMINATE (revise and resubmit) rather
# than staying stuck in REVIEW or disappearing from the registry.
LifecycleStatus.REVIEW: frozenset({LifecycleStatus.REGISTER, LifecycleStatus.NOMINATE}),
LifecycleStatus.REGISTER: frozenset({LifecycleStatus.ACTIVE}),
# "Change" (README.md §23) re-publishes an already-active capability;
# modeled as ACTIVE -> ACTIVE rather than a distinct state.
LifecycleStatus.ACTIVE: frozenset({LifecycleStatus.ACTIVE, LifecycleStatus.DEPRECATED}),
LifecycleStatus.DEPRECATED: frozenset({LifecycleStatus.RETIRED}),
LifecycleStatus.RETIRED: frozenset(),
}
class InvalidLifecycleTransitionError(Exception):
pass
def validate_transition(current: LifecycleStatus, target: LifecycleStatus) -> None:
"""Raises `InvalidLifecycleTransitionError` unless `current -> target` is
an allowed edge of the state machine above."""
if target not in _ALLOWED_TRANSITIONS.get(current, frozenset()):
raise InvalidLifecycleTransitionError(
f"{current.value} -> {target.value} is not an allowed registry "
f"lifecycle transition (README.md §23)"
)

View File

@@ -0,0 +1,83 @@
"""Capability publishing pipeline (EP-02-T02, README.md §6.8).
Manifest -> [schema | risk | policy | backend gates] -> Gate -> Registry
Fails closed: any gate rejects the *whole* manifest, nothing partially
enters the Registry -- there is no ad hoc manual registration path (RF-11).
Schema/naming (name format, required fields) is already enforced by
`CapabilityManifest` itself at construction (EP-02-T01); the gates here are
the business rules a merely well-typed manifest can still violate.
A network health check of `backend.service` is a natural extension (README.md
§6.8: "health checks") intentionally left out of this milestone -- the RI has
no registry of backend health endpoints yet, and gating publication on one
would just be a `TODO` masquerading as a check.
"""
from __future__ import annotations
from emcp_bus.common.models import RiskTier
from emcp_bus.registry.lifecycle import validate_transition
from emcp_bus.registry.models import CapabilityManifest, LifecycleStatus
from emcp_bus.registry.store import RegistryStore
_HIGH_IMPACT_TIERS = frozenset({RiskTier.R3, RiskTier.R4})
class PublishingRejectedError(Exception):
def __init__(self, reasons: list[str]) -> None:
self.reasons = reasons
super().__init__("; ".join(reasons))
class PublishingPipeline:
def __init__(self, store: RegistryStore) -> None:
self._store = store
def submit(self, manifest: CapabilityManifest) -> CapabilityManifest:
"""Runs `manifest` through the gates, then NOMINATE -> REVIEW -> REGISTER.
Stops short of `ACTIVE` -- a registered capability is not yet
resolvable by the Fabric until a separate `publish()` call, mirroring
README.md §23's Register and Publish being distinct stages.
"""
reasons = self._run_gates(manifest)
if reasons:
raise PublishingRejectedError(reasons)
nominated = manifest.model_copy(
update={
"lifecycle": manifest.lifecycle.model_copy(
update={"status": LifecycleStatus.NOMINATE}
)
}
)
self._store.upsert(nominated)
validate_transition(LifecycleStatus.NOMINATE, LifecycleStatus.REVIEW)
self._store.set_lifecycle_status(manifest.name, LifecycleStatus.REVIEW)
validate_transition(LifecycleStatus.REVIEW, LifecycleStatus.REGISTER)
self._store.set_lifecycle_status(manifest.name, LifecycleStatus.REGISTER)
return self._store.get(manifest.name)
def publish(self, name: str) -> CapabilityManifest:
"""REGISTER -> ACTIVE: the capability becomes resolvable (README.md §23 "Publish")."""
manifest = self._store.get(name)
validate_transition(manifest.lifecycle.status, LifecycleStatus.ACTIVE)
self._store.set_lifecycle_status(name, LifecycleStatus.ACTIVE)
return self._store.get(name)
def _run_gates(self, manifest: CapabilityManifest) -> list[str]:
reasons: list[str] = []
if not manifest.owner.strip():
reasons.append("owner is required")
if not manifest.backend.service.strip():
reasons.append("backend.service is required")
if manifest.risk_tier in _HIGH_IMPACT_TIERS and not manifest.approval.required:
reasons.append(
f"risk tier {manifest.risk_tier.value} requires approval.required=true "
"(README.md §17)"
)
return reasons

View File

@@ -0,0 +1,38 @@
"""Loads the RI's seed capability manifests (`config/capabilities/<domain>/*.yaml`)
through the real publishing pipeline (EP-02-T05) into a `RegistryStore`.
Shared by the Gateway's own startup (EP-06-T01: the Fabric router needs a
populated Registry to resolve anything) and by tests, so both exercise the
exact same load-and-publish path -- a schema mistake or a missing
`approval.required` on an R3 capability fails in both places identically.
"""
from __future__ import annotations
from pathlib import Path
from emcp_bus.common.config import load_yaml_models
from emcp_bus.registry.models import CapabilityManifest
from emcp_bus.registry.pipeline import PublishingPipeline
from emcp_bus.registry.store import RegistryStore
def load_all_capability_manifests(capabilities_dir: Path) -> list[CapabilityManifest]:
manifests: list[CapabilityManifest] = []
for domain_dir in sorted(capabilities_dir.iterdir()):
if domain_dir.is_dir():
manifests.extend(load_yaml_models(domain_dir, CapabilityManifest))
return manifests
def publish_all(store: RegistryStore, manifests: list[CapabilityManifest]) -> None:
pipeline = PublishingPipeline(store)
for manifest in manifests:
pipeline.submit(manifest)
pipeline.publish(manifest.name)
def seed_registry(store: RegistryStore, capabilities_dir: Path) -> list[CapabilityManifest]:
manifests = load_all_capability_manifests(capabilities_dir)
publish_all(store, manifests)
return manifests

View File

@@ -0,0 +1,107 @@
"""Global Capability Registry storage (EP-02-T03, README.md §6.6, ADR-010).
A control-plane asset, deliberately separate from the per-client catalog
view the Gateway serves (README.md §6.6: "O Global Registry é um
control-plane asset. Ele não deve ser confundido com a visão de catálogo
entregue a cada client."). Consumers (Gateway, Fabric, Offering Filter) only
ever read through `list_active`/`get` -- nothing outside the publishing
pipeline (EP-02-T02) writes here, and the pipeline never writes directly:
it only transitions status through `lifecycle.py`.
SQLite, used synchronously: this store is not on the Gateway's request
hot path in this milestone (routing is still static, EP-06 wires it in
later), and SQLite operations here are single-digit milliseconds -- an
`asyncio.to_thread` wrapper is a straightforward addition if/when a caller
needs this off the event loop.
"""
from __future__ import annotations
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from emcp_bus.registry.models import CapabilityManifest, LifecycleStatus
class CapabilityNotFoundError(Exception):
pass
class RegistryStore:
def __init__(self, db_path: str | Path) -> None:
self._db_path = str(db_path)
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS capabilities (
name TEXT PRIMARY KEY,
domain TEXT NOT NULL,
manifest_json TEXT NOT NULL,
lifecycle_status TEXT NOT NULL
)
"""
)
@contextmanager
def _connect(self) -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(self._db_path)
try:
yield conn
conn.commit()
finally:
conn.close()
def upsert(self, manifest: CapabilityManifest) -> None:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO capabilities (name, domain, manifest_json, lifecycle_status)
VALUES (?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
domain = excluded.domain,
manifest_json = excluded.manifest_json,
lifecycle_status = excluded.lifecycle_status
""",
(
manifest.name,
manifest.domain,
manifest.model_dump_json(),
manifest.lifecycle.status.value,
),
)
def get(self, name: str) -> CapabilityManifest:
with self._connect() as conn:
row = conn.execute(
"SELECT manifest_json FROM capabilities WHERE name = ?", (name,)
).fetchone()
if row is None:
raise CapabilityNotFoundError(name)
return CapabilityManifest.model_validate_json(row[0])
def list_by_domain(self, domain: str) -> list[CapabilityManifest]:
with self._connect() as conn:
rows = conn.execute(
"SELECT manifest_json FROM capabilities WHERE domain = ? ORDER BY name", (domain,)
).fetchall()
return [CapabilityManifest.model_validate_json(row[0]) for row in rows]
def list_active(self) -> list[CapabilityManifest]:
"""What Gateway/Fabric/Offering Filter are allowed to read (README.md §6.6) --
anything still in nominate/review/register, or already deprecated/retired,
is a control-plane-only concern."""
with self._connect() as conn:
rows = conn.execute(
"SELECT manifest_json FROM capabilities WHERE lifecycle_status = ? ORDER BY name",
(LifecycleStatus.ACTIVE.value,),
).fetchall()
return [CapabilityManifest.model_validate_json(row[0]) for row in rows]
def set_lifecycle_status(self, name: str, status: LifecycleStatus) -> None:
manifest = self.get(name)
updated = manifest.model_copy(
update={"lifecycle": manifest.lifecycle.model_copy(update={"status": status})}
)
self.upsert(updated)

View File

@@ -37,8 +37,28 @@ ISSUER = "https://keycloak.example/realms/emcp"
AUDIENCE = "emcp-gateway" AUDIENCE = "emcp-gateway"
KID = "test-key-1" KID = "test-key-1"
GATEWAY_PORT = 18000 _current_gateway_port: int | None = None
SALES_PORT = 18100 """Set by `governed_stack` (or a test managing its own stack, via
`set_current_gateway_port`) for the duration of one test. A *fresh* port
per test -- rather than a fixed constant -- avoids TIME_WAIT/bind
contention between one test's `RunningServer.stop()` and the next test's
bind on the same port, which showed up as intermittent "SSE stream ended
without a response" failures once enough e2e tests accumulated to make
that race likely within a single pytest run."""
def set_current_gateway_port(port: int | None) -> None:
global _current_gateway_port
_current_gateway_port = port
def _gateway_url() -> str:
if _current_gateway_port is None:
raise RuntimeError(
"No gateway is currently running for this test (see governed_stack fixture "
"or set_current_gateway_port)"
)
return f"http://127.0.0.1:{_current_gateway_port}/mcp"
def free_port() -> int: def free_port() -> int:
@@ -180,19 +200,34 @@ async def governed_stack(
) -> AsyncIterator[None]: ) -> AsyncIterator[None]:
"""Real OPA + real JWKS-backed OIDC auth, wired into fresh Gateway/Sales """Real OPA + real JWKS-backed OIDC auth, wired into fresh Gateway/Sales
instances for one test (EP-01/EP-03/EP-04/EP-05 all real, only Keycloak instances for one test (EP-01/EP-03/EP-04/EP-05 all real, only Keycloak
itself is substituted by the JWKS server -- see RI/README.md).""" itself is substituted by the JWKS server -- see RI/README.md).
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{SALES_PORT}/mcp")
Deliberately Sales-only, not Sales+Finance: most tests using this fixture
never reach a live backend call for a Finance tool (denied earlier, at
entitlement or REQUIRE_APPROVAL), and this file's own flakiness note
documents that more in-process ASGI server pairs per test measurably
increases the known e2e flakiness rate. `governed_stack_with_finance`
below is the (currently two) tests that actually need a live Finance
backend -- keeping the blast radius of that cost limited to just those.
"""
gateway_port = free_port()
sales_port = free_port()
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
monkeypatch.setenv("OPA_URL", opa_url) monkeypatch.setenv("OPA_URL", opa_url)
monkeypatch.setenv("OIDC_JWKS_URL", jwks_url) monkeypatch.setenv("OIDC_JWKS_URL", jwks_url)
monkeypatch.setenv("OIDC_ISSUER", ISSUER) monkeypatch.setenv("OIDC_ISSUER", ISSUER)
monkeypatch.setenv("OIDC_AUDIENCE", AUDIENCE) monkeypatch.setenv("OIDC_AUDIENCE", AUDIENCE)
# EP-07: the Gateway's outbound credential to the sales-domain backend --
# deliberately a different value/shape than any inbound client token.
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
import example_mcp_servers.sales_domain.server as sales_module import example_mcp_servers.sales_domain.server as sales_module
sales_config = uvicorn.Config( sales_config = uvicorn.Config(
sales_module.server.streamable_http_app(host="127.0.0.1"), sales_module.server.streamable_http_app(host="127.0.0.1"),
host="127.0.0.1", host="127.0.0.1",
port=SALES_PORT, port=sales_port,
log_level="warning", log_level="warning",
) )
sales_server = RunningServer(sales_config) sales_server = RunningServer(sales_config)
@@ -202,23 +237,91 @@ async def governed_stack(
auth_settings, token_verifier = gateway_module.build_auth() auth_settings, token_verifier = gateway_module.build_auth()
gateway_config = uvicorn.Config( gateway_config = uvicorn.Config(
gateway_module.server.streamable_http_app( gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
host="127.0.0.1", auth=auth_settings, token_verifier=token_verifier
),
host="127.0.0.1", host="127.0.0.1",
port=GATEWAY_PORT, port=gateway_port,
log_level="warning", log_level="warning",
) )
gateway_server = RunningServer(gateway_config) gateway_server = RunningServer(gateway_config)
await gateway_server.start() await gateway_server.start()
set_current_gateway_port(gateway_port)
try: try:
yield yield
finally: finally:
set_current_gateway_port(None)
await gateway_server.stop() await gateway_server.stop()
await sales_server.stop() await sales_server.stop()
@pytest.fixture
async def governed_stack_with_finance(
monkeypatch: pytest.MonkeyPatch, opa_url: str, jwks_url: str
) -> AsyncIterator[None]:
"""Same as `governed_stack`, plus a real Finance domain server (EP-06-T03) --
for the handful of tests that need a live Finance backend to actually
reach (e.g. proving the Fabric router dispatches Finance capabilities
there, or a full approval-then-execute cycle against it)."""
gateway_port = free_port()
sales_port = free_port()
finance_port = free_port()
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
monkeypatch.setenv("FINANCE_DOMAIN_URL", f"http://127.0.0.1:{finance_port}/mcp")
monkeypatch.setenv("OPA_URL", opa_url)
monkeypatch.setenv("OIDC_JWKS_URL", jwks_url)
monkeypatch.setenv("OIDC_ISSUER", ISSUER)
monkeypatch.setenv("OIDC_AUDIENCE", AUDIENCE)
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
monkeypatch.setenv("FINANCE_DOMAIN_SERVICE_TOKEN", "test-finance-domain-backend-credential")
import example_mcp_servers.sales_domain.server as sales_module
sales_server = RunningServer(
uvicorn.Config(
sales_module.server.streamable_http_app(host="127.0.0.1"),
host="127.0.0.1",
port=sales_port,
log_level="warning",
)
)
await sales_server.start()
import example_mcp_servers.finance_domain.server as finance_module
finance_server = RunningServer(
uvicorn.Config(
finance_module.server.streamable_http_app(host="127.0.0.1"),
host="127.0.0.1",
port=finance_port,
log_level="warning",
)
)
await finance_server.start()
import emcp_bus.gateway.server as gateway_module
auth_settings, token_verifier = gateway_module.build_auth()
gateway_server = RunningServer(
uvicorn.Config(
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
host="127.0.0.1",
port=gateway_port,
log_level="warning",
)
)
await gateway_server.start()
set_current_gateway_port(gateway_port)
try:
yield
finally:
set_current_gateway_port(None)
await gateway_server.stop()
await finance_server.stop()
await sales_server.stop()
async def run_against_gateway[ResultT]( async def run_against_gateway[ResultT](
operation: Callable[[ClientSession], Awaitable[ResultT]], operation: Callable[[ClientSession], Awaitable[ResultT]],
*, *,
@@ -232,7 +335,7 @@ async def run_against_gateway[ResultT](
Gateway defect: production deployments run one process per service. Gateway defect: production deployments run one process per service.
""" """
headers = {"Authorization": f"Bearer {token}"} if token else None headers = {"Authorization": f"Bearer {token}"} if token else None
url = f"http://127.0.0.1:{GATEWAY_PORT}/mcp" url = _gateway_url()
last_error: MCPError | None = None last_error: MCPError | None = None
for attempt in range(3): for attempt in range(3):
if attempt: if attempt:

View File

@@ -136,13 +136,13 @@ async def test_transaction_policy_denies_over_limit_even_though_entitled(
assert "TRANSACTION_POLICY_DENIED" in _text(result) assert "TRANSACTION_POLICY_DENIED" in _text(result)
async def test_require_approval_is_denied_pending_ep14( async def test_require_approval_is_denied_without_a_grant(
governed_stack: None, sign_token: Callable[..., str] governed_stack: None, sign_token: Callable[..., str]
) -> None: ) -> None:
"""README.md §26: Finance.Payments allows payment.execute below the approval """README.md §26: Finance.Payments allows payment.execute above the approval
threshold (README.md §7.2/§17 profile) but must never silently upgrade a threshold (README.md §7.2/§17 profile) but must never silently upgrade a
REQUIRE_APPROVAL to ALLOW -- and EP-14 (approval workflow) does not exist REQUIRE_APPROVAL to ALLOW -- the first attempt of any such call is always
yet in this milestone, so the only safe outcome is DENY.""" denied, since no approval has been granted for it yet (EP-14-T02)."""
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID) token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
result = await run_against_gateway( result = await run_against_gateway(
@@ -154,6 +154,152 @@ async def test_require_approval_is_denied_pending_ep14(
assert "REQUIRE_APPROVAL" in _text(result) assert "REQUIRE_APPROVAL" in _text(result)
async def test_full_approval_cycle_grant_then_execute_then_replay_denied(
governed_stack: None, sign_token: Callable[..., str]
) -> None:
"""EP-14 end to end: deny (no grant) -> out-of-band grant -> the *same*
call now reaches the real backend -> a second attempt of the identical
call is denied again (README.md §46, single-use "approval replay").
Uses Sales.Write + sales.quote.create (approval_threshold=100000,
config/clients/profiles/sales-write.yaml); see
`test_finance_full_approval_cycle_routes_through_fabric_to_finance_domain`
below for the same cycle proven against the Finance domain (EP-06-T03).
"""
import emcp_bus.gateway.server as gateway_module
token = sign_token(SALES_WRITE_CLIENT_ID)
arguments = {"customer_id": "cust-001", "amount": 150_000, "region": "BR-SP"}
first_attempt = await run_against_gateway(
lambda session: session.call_tool("sales.quote.create", arguments), token=token
)
assert first_attempt.is_error is True
assert "REQUIRE_APPROVAL" in _text(first_attempt)
approval_id = _text(first_attempt).rsplit(":", 1)[-1]
# Out-of-band: an approver grants the specific request the Gateway itself
# created (see `on_call_tool`'s REQUIRE_APPROVAL branch) -- not a new one
# constructed by the test, proving the Gateway and the approver are
# looking at the same `ApprovalService` instance (`get_current_state()`).
gateway_module.get_current_state().approval_service.grant(approval_id)
granted_attempt = await run_against_gateway(
lambda session: session.call_tool("sales.quote.create", arguments), token=token
)
assert granted_attempt.is_error is not True
assert granted_attempt.structured_content is not None
assert granted_attempt.structured_content["status"] == "draft"
replay_attempt = await run_against_gateway(
lambda session: session.call_tool("sales.quote.create", arguments), token=token
)
assert replay_attempt.is_error is True
assert "REQUIRE_APPROVAL" in _text(replay_attempt)
async def test_sales_client_tools_list_never_includes_finance_tools(
governed_stack: None, sign_token: Callable[..., str]
) -> None:
"""EP-06-T01: the Fabric router queries only the backends a client's
entitlement actually touches -- a Sales.Read client's tools/list never
even connects to the Finance backend."""
token = sign_token(SALES_READ_CLIENT_ID)
result = await run_against_gateway(lambda session: session.list_tools(), token=token)
tool_names = {tool.name for tool in result.tools}
assert tool_names == {
"sales.customer.search",
"sales.customer.get",
"sales.order.get",
"sales.inventory.check",
}
async def test_finance_client_tools_list_never_includes_sales_tools(
governed_stack_with_finance: None, sign_token: Callable[..., str]
) -> None:
"""EP-06-T01, the mirror image: a Finance.Payments client's tools/list
routes to the Finance backend only, never Sales."""
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
result = await run_against_gateway(lambda session: session.list_tools(), token=token)
tool_names = {tool.name for tool in result.tools}
assert tool_names == {
"finance.invoice.get",
"finance.payment.create",
"finance.payment.execute",
}
# EP-05-T03's SEP-2549 `ttl_ms`/`cache_scope` fields are per-version wire
# vocabulary (`mcp_types._v2026_07_28`) that the SDK's own serialization
# sieve strips for every earlier protocol revision (verified: neither field
# exists in any pre-2026-07-28 per-version result model at all). This RI's
# entire transport uses the classic session `initialize()` handshake, which
# never negotiates 2026-07-28 (that revision is reached only via the
# stateless per-request `server/discover` mode this RI does not use) -- so
# there is no e2e test here asserting on `result.cache_scope`/`result.ttl_ms`:
# whatever the Gateway sets internally, a client on this transport only ever
# sees the field's own class default, making such an assertion pass
# regardless of what the Gateway actually computed. `tools_list_cache_hint()`
# itself (always `scope="private"`, configurable `ttl_ms`) is the real,
# meaningful test -- see tests/unit/test_gateway_cache.py -- and
# `emcp_bus.gateway.cache`'s module docstring has the full, verified
# explanation.
async def test_finance_full_approval_cycle_routes_through_fabric_to_finance_domain(
governed_stack_with_finance: None, sign_token: Callable[..., str]
) -> None:
"""EP-06-T01/T03 + EP-14 end to end against the *Finance* domain server:
the Fabric router resolves finance.payment.create/execute to the Finance
backend, and the same deny -> grant -> execute -> replay-denied cycle as
`test_full_approval_cycle_grant_then_execute_then_replay_denied` (Sales)
holds identically here."""
import emcp_bus.gateway.server as gateway_module
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
create_result = await run_against_gateway(
lambda session: session.call_tool(
"finance.payment.create", {"invoice_id": "inv-002", "amount": 5_000}
),
token=token,
)
assert create_result.is_error is not True
assert create_result.structured_content is not None
payment_id = create_result.structured_content["payment_id"]
execute_arguments = {"payment_id": payment_id, "amount": 50_000}
first_attempt = await run_against_gateway(
lambda session: session.call_tool("finance.payment.execute", execute_arguments),
token=token,
)
assert first_attempt.is_error is True
assert "REQUIRE_APPROVAL" in _text(first_attempt)
approval_id = _text(first_attempt).rsplit(":", 1)[-1]
gateway_module.get_current_state().approval_service.grant(approval_id)
granted_attempt = await run_against_gateway(
lambda session: session.call_tool("finance.payment.execute", execute_arguments),
token=token,
)
assert granted_attempt.is_error is not True
assert granted_attempt.structured_content is not None
assert granted_attempt.structured_content["status"] == "executed"
replay_attempt = await run_against_gateway(
lambda session: session.call_tool("finance.payment.execute", execute_arguments),
token=token,
)
assert replay_attempt.is_error is True
assert "REQUIRE_APPROVAL" in _text(replay_attempt)
async def test_gateway_lifespan_fails_closed_when_pdp_unreachable( async def test_gateway_lifespan_fails_closed_when_pdp_unreachable(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -188,10 +334,19 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
""" """
import uvicorn import uvicorn
from tests.e2e.conftest import GATEWAY_PORT, SALES_PORT, RunningServer, run_against_gateway from tests.e2e.conftest import (
RunningServer,
free_port,
run_against_gateway,
set_current_gateway_port,
)
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{SALES_PORT}/mcp") sales_port = free_port()
gateway_port = free_port()
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
monkeypatch.setenv("OPA_URL", opa_url) monkeypatch.setenv("OPA_URL", opa_url)
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
monkeypatch.delenv("OIDC_JWKS_URL", raising=False) monkeypatch.delenv("OIDC_JWKS_URL", raising=False)
monkeypatch.delenv("OIDC_ISSUER", raising=False) monkeypatch.delenv("OIDC_ISSUER", raising=False)
@@ -201,7 +356,7 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
uvicorn.Config( uvicorn.Config(
sales_module.server.streamable_http_app(host="127.0.0.1"), sales_module.server.streamable_http_app(host="127.0.0.1"),
host="127.0.0.1", host="127.0.0.1",
port=SALES_PORT, port=sales_port,
log_level="warning", log_level="warning",
) )
) )
@@ -214,15 +369,14 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
gateway_server = RunningServer( gateway_server = RunningServer(
uvicorn.Config( uvicorn.Config(
gateway_module.server.streamable_http_app( gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
host="127.0.0.1", auth=auth_settings, token_verifier=token_verifier
),
host="127.0.0.1", host="127.0.0.1",
port=GATEWAY_PORT, port=gateway_port,
log_level="warning", log_level="warning",
) )
) )
await gateway_server.start() await gateway_server.start()
set_current_gateway_port(gateway_port)
try: try:
list_result = await run_against_gateway(lambda session: session.list_tools()) list_result = await run_against_gateway(lambda session: session.list_tools())
assert list_result.tools == [] assert list_result.tools == []
@@ -233,6 +387,7 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
assert call_result.is_error is True assert call_result.is_error is True
assert "UNAUTHENTICATED" in _text(call_result) assert "UNAUTHENTICATED" in _text(call_result)
finally: finally:
set_current_gateway_port(None)
await gateway_server.stop() await gateway_server.stop()
await sales_server.stop() await sales_server.stop()

View File

@@ -0,0 +1,249 @@
"""EP-01-T01: the *actual* Keycloak, not the JWKS-server stand-in the rest of
the e2e suite uses (see tests/e2e/conftest.py's module docstring). Real
Keycloak 26.7.3 container (Docker required -- skips otherwise), our real
`deploy/keycloak/realm-export.json`, real `client_credentials` grants, real
tokens validated by the real Gateway/PDP/Entitlement chain.
Session-scoped: Keycloak's cold start (schema init + realm import) takes
~15-20s, too slow to pay per-test. Gated on `docker` being on PATH, exactly
like `opa_url`/`OPA_BINARY` in conftest.py -- this whole file is skipped, not
failed, in an environment without Docker.
"""
from __future__ import annotations
import shutil
import subprocess
import time
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
import httpx
import pytest
from emcp_bus.identity.authn import AuthenticationError, TokenValidator
from tests.e2e.conftest import RunningServer, free_port, set_current_gateway_port
REPO_ROOT = Path(__file__).parents[2]
REALM_EXPORT = REPO_ROOT / "deploy" / "keycloak" / "realm-export.json"
REALM_NAME = "emcp"
CLIENT_SECRETS = {
"sales-read-agent": "dev-sales-read-agent-secret",
"sales-write-agent": "dev-sales-write-agent-secret",
"finance-payments-agent": "dev-finance-payments-agent-secret",
}
def _docker_binary() -> str | None:
return shutil.which("docker")
@pytest.fixture(scope="session")
def keycloak_url() -> Iterator[str]:
docker_binary = _docker_binary()
if docker_binary is None:
pytest.skip("docker not found on PATH -- required for the real-Keycloak suite")
port = free_port()
container_name = f"emcp-ri-test-keycloak-{port}"
# `KC_HOSTNAME` fixed to `127.0.0.1:<port>` so every issued token's `iss`
# matches what *this test* (running on the host) uses to reach it --
# unlike docker-compose.yml's `keycloak` service, which fixes it to the
# internal `keycloak:8080` DNS name instead, since there the Gateway
# reaches it over the compose network, not localhost. Same underlying
# gotcha either way: Keycloak derives `iss` from the request's Host
# header unless pinned, so a token fetched via one path and validated
# via another fails issuer validation for a boring reason.
subprocess.run(
[
docker_binary,
"run",
"-d",
"--name",
container_name,
"-p",
f"{port}:8080",
"-e",
"KC_BOOTSTRAP_ADMIN_USERNAME=admin",
"-e",
"KC_BOOTSTRAP_ADMIN_PASSWORD=admin",
"-e",
# Full URL form, not a bare "host:port" -- Keycloak 26's
# `KC_HOSTNAME` only accepts a plain hostname or a complete URL
# with scheme ("Provided hostname is neither a plain hostname
# nor a valid URL" otherwise); a plain hostname alone can't carry
# this test's dynamically allocated port.
f"KC_HOSTNAME=http://127.0.0.1:{port}",
"-e",
"KC_HOSTNAME_STRICT=false",
"-v",
f"{REALM_EXPORT}:/opt/keycloak/data/import/realm-export.json:ro",
"quay.io/keycloak/keycloak:26.7.3",
"start-dev",
"--import-realm",
],
check=True,
capture_output=True,
)
base_url = f"http://127.0.0.1:{port}"
try:
for _ in range(120):
try:
response = httpx.get(f"{base_url}/realms/{REALM_NAME}", timeout=1)
if response.status_code == 200:
break
except httpx.HTTPError:
pass
time.sleep(0.5)
else:
raise RuntimeError("Keycloak did not become ready in time")
yield base_url
finally:
subprocess.run([docker_binary, "rm", "-f", container_name], capture_output=True)
def _fetch_token(keycloak_url: str, client_id: str) -> str:
response = httpx.post(
f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/token",
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": CLIENT_SECRETS[client_id],
},
timeout=5,
)
response.raise_for_status()
token: str = response.json()["access_token"]
return token
# --- TokenValidator against real Keycloak (no Gateway involved) -----------
def test_real_keycloak_token_validates_with_the_real_azp_claim(keycloak_url: str) -> None:
token = _fetch_token(keycloak_url, "sales-read-agent")
validator = TokenValidator(
jwks_url=f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/certs",
issuer=f"{keycloak_url}/realms/{REALM_NAME}",
audience="emcp-gateway",
)
authenticated = validator.validate(token)
assert authenticated.client_id == "sales-read-agent"
def test_real_keycloak_token_rejected_for_wrong_audience(keycloak_url: str) -> None:
token = _fetch_token(keycloak_url, "sales-read-agent")
validator = TokenValidator(
jwks_url=f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/certs",
issuer=f"{keycloak_url}/realms/{REALM_NAME}",
audience="some-other-resource-server",
)
with pytest.raises(AuthenticationError):
validator.validate(token)
# --- Full Gateway chain against real Keycloak ------------------------------
@pytest.fixture
async def governed_stack_with_real_keycloak(
monkeypatch: pytest.MonkeyPatch, opa_url: str, keycloak_url: str
) -> AsyncIterator[None]:
"""Same shape as conftest.py's `governed_stack`, except OIDC points at
the real, dockerized Keycloak from `keycloak_url` instead of the JWKS
stand-in the rest of the suite uses."""
import uvicorn
gateway_port = free_port()
sales_port = free_port()
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
monkeypatch.setenv("OPA_URL", opa_url)
monkeypatch.setenv(
"OIDC_JWKS_URL", f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/certs"
)
monkeypatch.setenv("OIDC_ISSUER", f"{keycloak_url}/realms/{REALM_NAME}")
monkeypatch.setenv("OIDC_AUDIENCE", "emcp-gateway")
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
import example_mcp_servers.sales_domain.server as sales_module
sales_server = RunningServer(
uvicorn.Config(
sales_module.server.streamable_http_app(host="127.0.0.1"),
host="127.0.0.1",
port=sales_port,
log_level="warning",
)
)
await sales_server.start()
import emcp_bus.gateway.server as gateway_module
auth_settings, token_verifier = gateway_module.build_auth()
gateway_server = RunningServer(
uvicorn.Config(
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
host="127.0.0.1",
port=gateway_port,
log_level="warning",
)
)
await gateway_server.start()
set_current_gateway_port(gateway_port)
try:
yield
finally:
set_current_gateway_port(None)
await gateway_server.stop()
await sales_server.stop()
async def test_full_chain_with_real_keycloak_token(
governed_stack_with_real_keycloak: None, keycloak_url: str
) -> None:
"""README.md §45 Teste 1, against a real IdP end to end: the Gateway
validates a token *actually issued by Keycloak*, resolves the real
Sales.Read entitlement, and reaches the real downstream Sales server."""
from tests.e2e.conftest import run_against_gateway
token = _fetch_token(keycloak_url, "sales-read-agent")
tools = await run_against_gateway(lambda session: session.list_tools(), token=token)
assert {t.name for t in tools.tools} == {
"sales.customer.search",
"sales.customer.get",
"sales.order.get",
"sales.inventory.check",
}
result = await run_against_gateway(
lambda session: session.call_tool("sales.customer.get", {"customer_id": "cust-001"}),
token=token,
)
assert result.is_error is not True
assert result.structured_content == {
"customer_id": "cust-001",
"name": "Aurora Ltda",
"region": "BR-SP",
}
async def test_cross_domain_denial_with_real_keycloak_token(
governed_stack_with_real_keycloak: None, keycloak_url: str
) -> None:
"""A real Sales.Read token from real Keycloak still cannot reach Finance."""
from tests.e2e.conftest import run_against_gateway
token = _fetch_token(keycloak_url, "sales-read-agent")
result = await run_against_gateway(
lambda session: session.call_tool("finance.payment.execute", {"amount": 999}),
token=token,
)
assert result.is_error is True

View File

@@ -0,0 +1,163 @@
"""Approval workflow (EP-14-T01/T02/T03, README.md §26, §46 approval replay)."""
from __future__ import annotations
import pytest
from emcp_bus.approval.models import compute_operation_hash
from emcp_bus.approval.service import ApprovalNotFoundError, ApprovalService
def test_operation_hash_is_stable_regardless_of_argument_order() -> None:
a = compute_operation_hash(
client_profile="Finance.Payments",
tool="finance.payment.execute",
arguments={"amount": 50_000, "currency": "BRL"},
)
b = compute_operation_hash(
client_profile="Finance.Payments",
tool="finance.payment.execute",
arguments={"currency": "BRL", "amount": 50_000},
)
assert a == b
def test_operation_hash_differs_for_different_arguments() -> None:
"""A grant for one exact call must never satisfy a different one (README.md §26)."""
a = compute_operation_hash(
client_profile="Finance.Payments",
tool="finance.payment.execute",
arguments={"amount": 50_000},
)
b = compute_operation_hash(
client_profile="Finance.Payments",
tool="finance.payment.execute",
arguments={"amount": 999_999},
)
assert a != b
def test_unapproved_operation_is_not_consumed() -> None:
service = ApprovalService()
consumed = service.check_and_consume(
client_profile="Finance.Payments",
tool="finance.payment.execute",
arguments={"amount": 50_000},
)
assert consumed is False
def test_request_then_grant_then_consume_succeeds_exactly_once() -> None:
service = ApprovalService()
arguments = {"amount": 50_000}
request = service.request(
client_profile="Finance.Payments",
tool="finance.payment.execute",
arguments=arguments,
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
)
service.grant(request.approval_id)
assert (
service.check_and_consume(
client_profile="Finance.Payments", tool="finance.payment.execute", arguments=arguments
)
is True
)
# README.md §46 "approval replay": the second attempt of the *same* call
# must not be satisfied by an already-consumed grant.
assert (
service.check_and_consume(
client_profile="Finance.Payments", tool="finance.payment.execute", arguments=arguments
)
is False
)
def test_denied_request_is_never_consumable() -> None:
service = ApprovalService()
arguments = {"amount": 50_000}
request = service.request(
client_profile="Finance.Payments",
tool="finance.payment.execute",
arguments=arguments,
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
)
service.deny(request.approval_id)
assert (
service.check_and_consume(
client_profile="Finance.Payments", tool="finance.payment.execute", arguments=arguments
)
is False
)
def test_grant_does_not_satisfy_a_different_operation() -> None:
"""Granting approval for one amount must not let a *different* amount through."""
service = ApprovalService()
request = service.request(
client_profile="Finance.Payments",
tool="finance.payment.execute",
arguments={"amount": 50_000},
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
)
service.grant(request.approval_id)
consumed = service.check_and_consume(
client_profile="Finance.Payments",
tool="finance.payment.execute",
arguments={"amount": 999_999}, # different operation entirely
)
assert consumed is False
def test_expired_grant_is_not_consumable() -> None:
service = ApprovalService()
arguments = {"amount": 50_000}
request = service.request(
client_profile="Finance.Payments",
tool="finance.payment.execute",
arguments=arguments,
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
ttl_seconds=0,
)
service.grant(request.approval_id)
consumed = service.check_and_consume(
client_profile="Finance.Payments",
tool="finance.payment.execute",
arguments=arguments,
)
assert consumed is False
def test_repeated_pending_requests_for_the_same_operation_do_not_duplicate() -> None:
service = ApprovalService()
first = service.request(
client_profile="Finance.Payments",
tool="finance.payment.execute",
arguments={"amount": 50_000},
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
)
second = service.request(
client_profile="Finance.Payments",
tool="finance.payment.execute",
arguments={"amount": 50_000},
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
)
assert first.approval_id == second.approval_id
def test_grant_unknown_approval_id_raises() -> None:
service = ApprovalService()
with pytest.raises(ApprovalNotFoundError):
service.grant("no-such-id")

139
RI/tests/unit/test_audit.py Normal file
View File

@@ -0,0 +1,139 @@
"""Audit events, sinks and correlation (EP-08-T02/T03, README.md §27/§28)."""
from __future__ import annotations
import json
from pathlib import Path
from emcp_bus.audit import events
from emcp_bus.audit.correlation import (
DecisionCorrelation,
compute_policy_decision_id,
new_decision_id,
)
from emcp_bus.audit.models import EventCategory
from emcp_bus.audit.sink import InMemoryAuditSink, JSONLFileAuditSink
def test_tool_call_denied_is_classified_as_grl() -> None:
event = events.tool_call_denied(
client_id="sales-read-agent",
profile_name="Sales.Read",
tool="finance.payment.execute",
reason_code="NOT_IN_ENTITLEMENT",
)
assert event.category is EventCategory.GRL
assert event.reason_code == "NOT_IN_ENTITLEMENT"
def test_tool_call_allowed_is_classified_as_ic() -> None:
correlation = DecisionCorrelation(
decision_id="d1",
policy_decision_id="p1",
entitlement_version="e1",
policy_version="v1",
mcp_request_id="1",
)
event = events.tool_call_allowed(
client_id="sales-read-agent",
profile_name="Sales.Read",
tool="sales.customer.get",
correlation=correlation,
)
assert event.category is EventCategory.IC
assert event.decision_id == "d1"
assert event.policy_decision_id == "p1"
assert event.entitlement_version == "e1"
def test_pdp_unavailable_is_classified_as_noc() -> None:
event = events.pdp_unavailable(client_id="sales-read-agent", tool="sales.customer.get")
assert event.category is EventCategory.NOC
def test_bypass_attempt_detected_is_classified_as_grl() -> None:
event = events.bypass_attempt_detected(backend="sales-domain", reason="missing_credential")
assert event.category is EventCategory.GRL
assert event.payload == {"backend": "sales-domain", "reason": "missing_credential"}
def test_event_payload_never_contains_a_raw_token_value() -> None:
"""README.md §27, 'Não registrar': a payload key that looks like a secret
is replaced by a hash reference, never left as the raw value."""
event = events.tool_call_allowed(
client_id="c1",
profile_name="Sales.Read",
tool="sales.customer.get",
correlation=DecisionCorrelation(
decision_id="d1",
policy_decision_id="p1",
entitlement_version=None,
policy_version=None,
mcp_request_id=None,
),
)
event.payload = {"backend_token": "super-secret-value", "customer_id": "cust-001"}
# Re-trigger sanitization the way construction does (model_post_init runs
# once at construction; this simulates a caller building payload then
# assigning it, which the real call sites never do -- included as an
# explicit regression guard on the sanitizer itself).
from emcp_bus.audit.models import _sanitize
sanitized = _sanitize(event.payload)
assert sanitized["backend_token"].startswith("sha256:")
assert sanitized["backend_token"] != "super-secret-value"
assert sanitized["customer_id"] == "cust-001"
def test_in_memory_sink_collects_emitted_events() -> None:
sink = InMemoryAuditSink()
event = events.client_authenticated(client_id="c1", mcp_request_id="1")
sink.emit(event)
assert sink.events == [event]
def test_jsonl_file_sink_appends_one_line_per_event(tmp_path: Path) -> None:
path = tmp_path / "audit" / "events.jsonl"
sink = JSONLFileAuditSink(path)
sink.emit(events.client_authenticated(client_id="c1", mcp_request_id="1"))
sink.emit(events.client_authenticated(client_id="c2", mcp_request_id="2"))
lines = path.read_text(encoding="utf-8").splitlines()
assert len(lines) == 2
assert json.loads(lines[0])["client_id"] == "c1"
assert json.loads(lines[1])["client_id"] == "c2"
def test_new_decision_id_is_unique_per_call() -> None:
assert new_decision_id() != new_decision_id()
def test_policy_decision_id_is_deterministic_for_identical_inputs() -> None:
def _compute() -> str:
return compute_policy_decision_id(
policy_version="v1",
client_profile="Sales.Write",
tool="sales.quote.create",
arguments={"amount": 100, "region": "BR-SP"},
)
assert _compute() == _compute()
def test_policy_decision_id_differs_for_different_arguments() -> None:
def _compute(amount: int) -> str:
return compute_policy_decision_id(
policy_version="v1",
client_profile="Sales.Write",
tool="sales.quote.create",
arguments={"amount": amount},
)
assert _compute(100) != _compute(200)

View File

@@ -0,0 +1,87 @@
"""Anti-bypass credential gate for domain servers (EP-05-T07, README.md §21).
Real ASGI request handling (`httpx.ASGITransport`) against a real
`BackendCredentialGate`-wrapped app -- not a hand-mocked request object.
"""
from __future__ import annotations
import httpx
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
from emcp_bus.audit.bypass_detection import BackendCredentialGate
from emcp_bus.audit.sink import InMemoryAuditSink
async def _ok(request: object) -> JSONResponse:
return JSONResponse({"ok": True})
def _gated_app(audit_sink: InMemoryAuditSink | None = None) -> BackendCredentialGate:
inner = Starlette(routes=[Route("/mcp", _ok, methods=["POST"])])
return BackendCredentialGate(
inner,
expected_token="the-real-gateway-credential",
backend="sales-domain",
audit_sink=audit_sink,
)
async def test_request_with_correct_bearer_token_reaches_the_app() -> None:
app = _gated_app()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
response = await client.post(
"/mcp", headers={"Authorization": "Bearer the-real-gateway-credential"}
)
assert response.status_code == 200
assert response.json() == {"ok": True}
async def test_request_with_no_authorization_header_is_rejected() -> None:
sink = InMemoryAuditSink()
app = _gated_app(sink)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
response = await client.post("/mcp")
assert response.status_code == 401
assert len(sink.events) == 1
assert sink.events[0].event_type == "bypass_attempt_detected"
assert sink.events[0].payload["reason"] == "missing_credential"
async def test_request_with_wrong_bearer_token_is_rejected() -> None:
"""This is the concrete Teste 7 (§45, 'Gateway bypass') scenario: someone
who can reach the domain server's port directly, but does not hold the
Gateway's outbound service credential, must not reach a tool handler."""
sink = InMemoryAuditSink()
app = _gated_app(sink)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
response = await client.post(
"/mcp", headers={"Authorization": "Bearer someone-elses-token"}
)
assert response.status_code == 401
assert sink.events[0].payload["reason"] == "wrong_credential"
async def test_a_stolen_inbound_client_token_does_not_satisfy_the_gate() -> None:
"""README.md §20/ADR-006: even a real, valid *inbound* MCP Client token
is not the outbound credential -- presenting one here must still fail."""
app = _gated_app()
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
) as client:
response = await client.post(
"/mcp", headers={"Authorization": "Bearer some-inbound-oidc-access-token"}
)
assert response.status_code == 401

View File

@@ -0,0 +1,72 @@
"""Header/body consistency check (EP-05-T08, README.md §22, SEP-2243)."""
from __future__ import annotations
import json
from emcp_bus.gateway.canonical_request import find_header_body_mismatch
def _body(method: str, params: dict[str, object] | None = None) -> bytes:
return json.dumps(
{"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}
).encode()
def test_no_headers_present_is_never_a_mismatch() -> None:
assert (
find_header_body_mismatch(_body("tools/call"), method_header=None, name_header=None) is None
)
def test_matching_method_header_is_not_a_mismatch() -> None:
body = _body("tools/list")
assert find_header_body_mismatch(body, method_header="tools/list", name_header=None) is None
def test_mismatched_method_header_is_rejected() -> None:
body = _body("tools/list")
reason = find_header_body_mismatch(body, method_header="tools/call", name_header=None)
assert reason is not None
assert "mcp-method" in reason
def test_matching_name_header_for_tools_call_is_not_a_mismatch() -> None:
body = _body("tools/call", {"name": "sales.customer.get", "arguments": {}})
reason = find_header_body_mismatch(
body, method_header="tools/call", name_header="sales.customer.get"
)
assert reason is None
def test_mismatched_name_header_for_tools_call_is_rejected() -> None:
"""The concrete attack this closes: a header claiming one tool while the
body actually invokes another -- whichever a naive reader trusted, the
request must instead be rejected outright (README.md §22)."""
body = _body("tools/call", {"name": "finance.payment.execute", "arguments": {}})
reason = find_header_body_mismatch(
body, method_header="tools/call", name_header="sales.customer.get"
)
assert reason is not None
assert "mcp-name" in reason
def test_name_header_is_irrelevant_for_a_non_name_bearing_method() -> None:
body = _body("tools/list")
reason = find_header_body_mismatch(body, method_header="tools/list", name_header="anything")
assert reason is None
def test_unparseable_body_is_left_to_the_app_itself() -> None:
reason = find_header_body_mismatch(b"not json", method_header="tools/list", name_header=None)
assert reason is None

View File

@@ -0,0 +1,127 @@
"""Outbound (backend) identity (EP-07-T01/T02, README.md §20)."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from emcp_bus.downstream.identity import (
BackendCredentialProvider,
BackendCredentialUnavailableError,
UnknownBackendError,
)
from emcp_bus.downstream.models import BackendConfig
def test_backend_config_rejects_a_literal_secret_in_the_env_var_name_field() -> None:
"""README.md §35.5: credentials must never live inline in config."""
with pytest.raises(ValidationError):
BackendConfig(
backend="sales-domain",
url="http://127.0.0.1:8100/mcp",
audience="sales-domain-service",
service_account_token_env_var="eyJhbGciOiJSUzI1NiJ9.not-an-env-var-name",
)
def test_backend_config_requires_env_var_for_service_account_mode() -> None:
with pytest.raises(ValidationError):
BackendConfig(
backend="sales-domain", url="http://127.0.0.1:8100/mcp", audience="sales-domain-service"
)
def test_backend_config_requires_endpoint_for_token_exchange_mode() -> None:
with pytest.raises(ValidationError):
BackendConfig(
backend="sales-domain",
url="http://127.0.0.1:8100/mcp",
audience="sales-domain-service",
credential_mode="token_exchange",
)
def test_credential_for_configured_backend_resolves_from_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "backend-secret-token")
provider = BackendCredentialProvider(
[
BackendConfig(
backend="sales-domain",
url="http://127.0.0.1:8100/mcp",
audience="sales-domain-service",
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
)
]
)
credential = provider.credential_for("sales-domain")
assert credential.token == "backend-secret-token"
assert credential.audience == "sales-domain-service"
def test_credential_differs_from_a_hypothetical_inbound_client_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The property README.md §20/ADR-006 actually cares about: whatever the
inbound MCP Client token was, the outbound credential is never it."""
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "backend-secret-token")
provider = BackendCredentialProvider(
[
BackendConfig(
backend="sales-domain",
url="http://127.0.0.1:8100/mcp",
audience="sales-domain-service",
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
)
]
)
inbound_client_token = "inbound-oidc-access-token" # nosec -- test fixture, not a real secret
credential = provider.credential_for("sales-domain")
assert credential.token != inbound_client_token
assert credential.audience != "emcp-gateway" # the Gateway's own inbound audience
def test_unknown_backend_raises() -> None:
provider = BackendCredentialProvider([])
with pytest.raises(UnknownBackendError):
provider.credential_for("no-such-backend")
def test_missing_env_var_at_call_time_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("SALES_DOMAIN_SERVICE_TOKEN", raising=False)
provider = BackendCredentialProvider(
[
BackendConfig(
backend="sales-domain",
url="http://127.0.0.1:8100/mcp",
audience="sales-domain-service",
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
)
]
)
with pytest.raises(BackendCredentialUnavailableError):
provider.credential_for("sales-domain")
def test_token_exchange_mode_is_a_documented_but_unimplemented_extension() -> None:
provider = BackendCredentialProvider(
[
BackendConfig(
backend="sales-domain",
url="http://127.0.0.1:8100/mcp",
audience="sales-domain-service",
credential_mode="token_exchange",
token_exchange_endpoint="https://keycloak.example/realms/emcp/token",
)
]
)
with pytest.raises(NotImplementedError):
provider.credential_for("sales-domain")

View File

@@ -0,0 +1,114 @@
"""Fabric core capability router (EP-06-T01, README.md §6.7).
Real Registry (seeded from the actual config/capabilities/ manifests, same
as tests/unit/test_registry_seed_manifests.py) + real BackendCredentialProvider,
not hand-built fixtures -- a config mistake in either fails here too.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from emcp_bus.downstream.identity import BackendCredentialProvider
from emcp_bus.downstream.models import BackendConfig
from emcp_bus.fabric.router import CapabilityRouter, UnroutableCapabilityError
from emcp_bus.registry.models import LifecycleStatus
from emcp_bus.registry.seed import load_all_capability_manifests, publish_all
from emcp_bus.registry.store import RegistryStore
REPO_ROOT = Path(__file__).parents[2]
CAPABILITIES_DIR = REPO_ROOT / "config" / "capabilities"
@pytest.fixture
def router(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> CapabilityRouter:
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "sales-secret")
monkeypatch.setenv("FINANCE_DOMAIN_SERVICE_TOKEN", "finance-secret")
store = RegistryStore(tmp_path / "registry.db")
publish_all(store, load_all_capability_manifests(CAPABILITIES_DIR))
backend_credentials = BackendCredentialProvider(
[
BackendConfig(
backend="sales-domain",
url="http://127.0.0.1:8100/mcp",
audience="sales-domain-service",
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
),
BackendConfig(
backend="finance-domain",
url="http://127.0.0.1:8101/mcp",
audience="finance-domain-service",
service_account_token_env_var="FINANCE_DOMAIN_SERVICE_TOKEN",
),
]
)
return CapabilityRouter(store, backend_credentials)
def test_sales_capability_routes_to_sales_backend_not_finance(router: CapabilityRouter) -> None:
"""README.md §6.7 EP-06-T01 acceptance: a Sales capability must reach the
Sales backend, never Finance."""
route = router.route("sales.customer.get")
assert route.backend_id == "sales-domain"
assert route.url == "http://127.0.0.1:8100/mcp"
assert route.credential_token == "sales-secret"
def test_finance_capability_routes_to_finance_backend(router: CapabilityRouter) -> None:
route = router.route("finance.payment.execute")
assert route.backend_id == "finance-domain"
assert route.url == "http://127.0.0.1:8101/mcp"
assert route.credential_token == "finance-secret"
def test_unknown_capability_is_unroutable(router: CapabilityRouter) -> None:
with pytest.raises(UnroutableCapabilityError):
router.route("sales.does.not.exist")
def test_deprecated_capability_is_unroutable(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Only ACTIVE capabilities are routable (README.md §6.6) -- a capability
that was published then deprecated must stop being reachable."""
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "sales-secret")
store = RegistryStore(tmp_path / "registry.db")
publish_all(store, load_all_capability_manifests(CAPABILITIES_DIR))
store.set_lifecycle_status("sales.customer.get", LifecycleStatus.DEPRECATED)
backend_credentials = BackendCredentialProvider(
[
BackendConfig(
backend="sales-domain",
url="http://127.0.0.1:8100/mcp",
audience="sales-domain-service",
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
)
]
)
router = CapabilityRouter(store, backend_credentials)
with pytest.raises(UnroutableCapabilityError):
router.route("sales.customer.get")
def test_distinct_backends_for_sales_only_entitlement_excludes_finance(
router: CapabilityRouter,
) -> None:
sales_read_tools = frozenset(
{"sales.customer.search", "sales.customer.get", "sales.order.get", "sales.inventory.check"}
)
assert router.distinct_backends_for(sales_read_tools) == {"sales-domain"}
def test_distinct_backends_for_mixed_entitlement_includes_both(router: CapabilityRouter) -> None:
mixed_tools = frozenset({"sales.customer.get", "finance.invoice.get"})
assert router.distinct_backends_for(mixed_tools) == {"sales-domain", "finance-domain"}

View File

@@ -0,0 +1,23 @@
"""Private tools/list cache hint (EP-05-T03, README.md §13/§28)."""
from __future__ import annotations
import pytest
from emcp_bus.gateway.cache import tools_list_cache_hint
def test_default_hint_is_private_scope_with_a_positive_ttl() -> None:
hint = tools_list_cache_hint()
assert hint.scope == "private"
assert hint.ttl_ms > 0
def test_ttl_is_configurable_via_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("EMCP_TOOLS_LIST_CACHE_TTL_MS", "12345")
hint = tools_list_cache_hint()
assert hint.ttl_ms == 12345
assert hint.scope == "private" # never configurable to "public" (Teste 5, §45)

View File

@@ -0,0 +1,165 @@
"""Registry store + publishing pipeline + lifecycle state machine (EP-02-T02/T03/T04)."""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from emcp_bus.registry.lifecycle import InvalidLifecycleTransitionError, validate_transition
from emcp_bus.registry.models import CapabilityManifest, LifecycleStatus
from emcp_bus.registry.pipeline import PublishingPipeline, PublishingRejectedError
from emcp_bus.registry.store import CapabilityNotFoundError, RegistryStore
def _manifest(**overrides: object) -> CapabilityManifest:
data: dict[str, object] = {
"name": "sales.customer.get",
"version": "1.0.0",
"domain": "sales",
"owner": "sales-platform",
"risk_tier": "R1",
"side_effects": False,
"idempotent": True,
"entitlements": ["Sales.Read"],
"backend": {"type": "mcp", "service": "sales-domain"},
}
data.update(overrides)
return CapabilityManifest.model_validate(data)
@pytest.fixture
def store(tmp_path: Path) -> Iterator[RegistryStore]:
yield RegistryStore(tmp_path / "registry.db")
def test_upsert_and_get_round_trip(store: RegistryStore) -> None:
store.upsert(_manifest())
fetched = store.get("sales.customer.get")
assert fetched.owner == "sales-platform"
assert fetched.risk_tier.value == "R1"
def test_get_missing_capability_raises(store: RegistryStore) -> None:
with pytest.raises(CapabilityNotFoundError):
store.get("no.such.capability")
def test_list_active_excludes_non_active_capabilities(store: RegistryStore) -> None:
store.upsert(_manifest(name="sales.customer.get")) # default lifecycle: NOMINATE
active = _manifest(name="sales.order.get")
active = active.model_copy(
update={"lifecycle": active.lifecycle.model_copy(update={"status": LifecycleStatus.ACTIVE})}
)
store.upsert(active)
result = store.list_active()
assert [m.name for m in result] == ["sales.order.get"]
def test_list_by_domain_filters_correctly(store: RegistryStore) -> None:
store.upsert(_manifest(name="sales.customer.get", domain="sales"))
store.upsert(
_manifest(
name="finance.invoice.get",
domain="finance",
entitlements=["Finance.Read"],
backend={"type": "mcp", "service": "finance-domain"},
)
)
assert [m.name for m in store.list_by_domain("finance")] == ["finance.invoice.get"]
# --- lifecycle state machine ------------------------------------------------
def test_nominate_to_review_is_allowed() -> None:
validate_transition(LifecycleStatus.NOMINATE, LifecycleStatus.REVIEW)
def test_nominate_directly_to_active_is_rejected() -> None:
"""The task's own acceptance criterion: never skip REVIEW on the way to ACTIVE."""
with pytest.raises(InvalidLifecycleTransitionError):
validate_transition(LifecycleStatus.NOMINATE, LifecycleStatus.ACTIVE)
def test_register_directly_to_active_requires_no_skip_but_is_the_publish_step() -> None:
validate_transition(LifecycleStatus.REGISTER, LifecycleStatus.ACTIVE)
def test_retired_is_terminal() -> None:
with pytest.raises(InvalidLifecycleTransitionError):
validate_transition(LifecycleStatus.RETIRED, LifecycleStatus.ACTIVE)
# --- publishing pipeline -----------------------------------------------------
def test_pipeline_submit_then_publish_reaches_active(store: RegistryStore) -> None:
pipeline = PublishingPipeline(store)
registered = pipeline.submit(_manifest())
assert registered.lifecycle.status is LifecycleStatus.REGISTER
published = pipeline.publish("sales.customer.get")
assert published.lifecycle.status is LifecycleStatus.ACTIVE
def test_pipeline_rejects_high_risk_capability_without_approval(store: RegistryStore) -> None:
pipeline = PublishingPipeline(store)
manifest = _manifest(
name="finance.payment.execute",
domain="finance",
risk_tier="R3",
side_effects=True,
idempotent=False,
entitlements=["Finance.Payments"],
backend={"type": "mcp", "service": "finance-domain"},
)
with pytest.raises(PublishingRejectedError) as exc_info:
pipeline.submit(manifest)
assert any("approval" in reason for reason in exc_info.value.reasons)
def test_pipeline_accepts_high_risk_capability_with_approval_declared(store: RegistryStore) -> None:
pipeline = PublishingPipeline(store)
manifest = _manifest(
name="finance.payment.execute",
domain="finance",
risk_tier="R3",
side_effects=True,
idempotent=False,
entitlements=["Finance.Payments"],
backend={"type": "mcp", "service": "finance-domain"},
approval={"required": True},
)
registered = pipeline.submit(manifest)
assert registered.lifecycle.status is LifecycleStatus.REGISTER
def test_pipeline_rejects_manifest_with_blank_owner(store: RegistryStore) -> None:
pipeline = PublishingPipeline(store)
with pytest.raises(PublishingRejectedError) as exc_info:
pipeline.submit(_manifest(owner=" "))
assert any("owner" in reason for reason in exc_info.value.reasons)
def test_publish_before_register_is_rejected(store: RegistryStore) -> None:
"""A manifest sitting in NOMINATE/REVIEW (e.g. review not yet approved)
must not be publishable -- README.md §23 governance lifecycle."""
store.upsert(_manifest()) # lifecycle defaults to NOMINATE
pipeline = PublishingPipeline(store)
with pytest.raises(InvalidLifecycleTransitionError):
pipeline.publish("sales.customer.get")

View File

@@ -0,0 +1,57 @@
"""EP-02-T05: the real seed manifests under config/capabilities/ go through the
real pipeline end to end -- not hand-built fixtures, so a schema mistake or a
missing `approval.required` on an R3 capability fails here too."""
from __future__ import annotations
from pathlib import Path
from emcp_bus.registry.models import LifecycleStatus
from emcp_bus.registry.seed import load_all_capability_manifests, publish_all
from emcp_bus.registry.store import RegistryStore
REPO_ROOT = Path(__file__).parents[2]
CAPABILITIES_DIR = REPO_ROOT / "config" / "capabilities"
def test_all_seed_manifests_publish_successfully(tmp_path: Path) -> None:
store = RegistryStore(tmp_path / "registry.db")
manifests = load_all_capability_manifests(CAPABILITIES_DIR)
assert len(manifests) == 9 # 6 sales + 3 finance -- update this if seed data changes
publish_all(store, manifests)
active = store.list_active()
assert len(active) == len(manifests)
assert all(m.lifecycle.status is LifecycleStatus.ACTIVE for m in active)
def test_seed_manifests_by_domain(tmp_path: Path) -> None:
store = RegistryStore(tmp_path / "registry.db")
publish_all(store, load_all_capability_manifests(CAPABILITIES_DIR))
sales_tools = {m.name for m in store.list_by_domain("sales")}
finance_tools = {m.name for m in store.list_by_domain("finance")}
assert sales_tools == {
"sales.customer.search",
"sales.customer.get",
"sales.order.get",
"sales.inventory.check",
"sales.quote.create",
"sales.quote.update",
}
assert finance_tools == {
"finance.invoice.get",
"finance.payment.create",
"finance.payment.execute",
}
def test_finance_payment_execute_is_r3_with_approval_required() -> None:
manifests = {m.name: m for m in load_all_capability_manifests(CAPABILITIES_DIR)}
payment_execute = manifests["finance.payment.execute"]
assert payment_execute.risk_tier.value == "R3"
assert payment_execute.approval.required is True

View File

@@ -0,0 +1,104 @@
"""Generic REST adapter (EP-06-T04, README.md §6.7).
Real httpx request/response handling against an `httpx.MockTransport`
in-process endpoint -- no real REST backend exists in this RI (both example
domains are MCP-native, see the module docstring in rest_adapter.py), so this
is the closest equivalent to "real dependency, fake network" available for
this specific adapter.
"""
from __future__ import annotations
import json
import httpx
import pytest
from emcp_bus.fabric.adapters.rest_adapter import (
RestAdapter,
RestBackendConfig,
RestOperation,
UnknownOperationError,
)
CONFIG = RestBackendConfig(
backend="crm-rest",
base_url="https://crm.example",
operations={
"crm.customer.get": RestOperation(method="GET", path="/v2/customers/{customer_id}"),
"crm.customer.create": RestOperation(method="POST", path="/v2/customers"),
},
)
def _adapter(handler: httpx.MockTransport) -> RestAdapter:
client = httpx.AsyncClient(base_url=CONFIG.base_url, transport=handler)
return RestAdapter(CONFIG, client=client)
async def test_get_operation_substitutes_path_placeholder_and_sends_remaining_as_query() -> None:
captured: dict[str, httpx.Request] = {}
def handle(request: httpx.Request) -> httpx.Response:
captured["request"] = request
return httpx.Response(200, json={"customer_id": "cust-001", "name": "Aurora Ltda"})
adapter = _adapter(httpx.MockTransport(handle))
result = await adapter.call("crm.customer.get", {"customer_id": "cust-001", "region": "BR-SP"})
assert result == {"customer_id": "cust-001", "name": "Aurora Ltda"}
request = captured["request"]
assert request.url.path == "/v2/customers/cust-001"
assert dict(request.url.params) == {"region": "BR-SP"}
await adapter.aclose()
async def test_post_operation_sends_remaining_arguments_as_json_body() -> None:
captured: dict[str, httpx.Request] = {}
def handle(request: httpx.Request) -> httpx.Response:
captured["request"] = request
return httpx.Response(201, json={"customer_id": "cust-002"})
adapter = _adapter(httpx.MockTransport(handle))
result = await adapter.call("crm.customer.create", {"name": "Boreal S.A."})
assert result == {"customer_id": "cust-002"}
body = json.loads(captured["request"].content)
assert body == {"name": "Boreal S.A."}
await adapter.aclose()
async def test_bearer_token_is_forwarded_as_authorization_header() -> None:
captured: dict[str, httpx.Request] = {}
def handle(request: httpx.Request) -> httpx.Response:
captured["request"] = request
return httpx.Response(200, json={})
adapter = _adapter(httpx.MockTransport(handle))
await adapter.call(
"crm.customer.get", {"customer_id": "cust-001"}, bearer_token="outbound-token"
)
assert captured["request"].headers["authorization"] == "Bearer outbound-token"
await adapter.aclose()
async def test_unconfigured_capability_raises() -> None:
adapter = _adapter(httpx.MockTransport(lambda request: httpx.Response(200, json={})))
with pytest.raises(UnknownOperationError):
await adapter.call("crm.customer.delete", {})
await adapter.aclose()
async def test_backend_error_status_raises() -> None:
adapter = _adapter(httpx.MockTransport(lambda request: httpx.Response(500)))
with pytest.raises(httpx.HTTPStatusError):
await adapter.call("crm.customer.get", {"customer_id": "cust-001"})
await adapter.aclose()

View File

@@ -0,0 +1,72 @@
"""Shared-client lint (EP-01-T05, README.md §18/§35)."""
from __future__ import annotations
import pytest
from emcp_bus.identity.models import ClientRegistration
from emcp_bus.identity.shared_client_lint import (
SharedClientLintError,
assert_no_shared_client_violations,
find_shared_client_violations,
)
def test_distinct_client_ids_are_never_a_violation() -> None:
registrations = [
ClientRegistration(client_id="sales-read-agent", profile="Sales.Read"),
ClientRegistration(client_id="sales-write-agent", profile="Sales.Write"),
]
assert find_shared_client_violations(registrations) == []
def test_same_client_id_and_same_profile_is_not_a_violation() -> None:
"""Sharing is fine when both registrations agree on the profile -- this
is the legitimate case README.md §18 describes."""
registrations = [
ClientRegistration(client_id="shared-agent", profile="Sales.Read"),
ClientRegistration(client_id="shared-agent", profile="Sales.Read"),
]
assert find_shared_client_violations(registrations) == []
def test_same_client_id_with_different_profiles_is_a_violation() -> None:
registrations = [
ClientRegistration(client_id="shared-agent", profile="Sales.Read"),
ClientRegistration(client_id="shared-agent", profile="Finance.Payments"),
]
violations = find_shared_client_violations(registrations)
assert len(violations) == 1
assert "shared-agent" in violations[0]
def test_assert_raises_with_all_violations_when_present() -> None:
registrations = [
ClientRegistration(client_id="shared-agent", profile="Sales.Read"),
ClientRegistration(client_id="shared-agent", profile="Finance.Payments"),
]
with pytest.raises(SharedClientLintError):
assert_no_shared_client_violations(registrations)
def test_assert_does_not_raise_when_clean() -> None:
registrations = [ClientRegistration(client_id="sales-read-agent", profile="Sales.Read")]
assert_no_shared_client_violations(registrations) # must not raise
def test_the_real_seed_client_registrations_have_no_violations() -> None:
"""The RI's own config/clients/*.yaml must pass its own lint."""
from pathlib import Path
from emcp_bus.common.config import load_yaml_models
repo_root = Path(__file__).parents[2]
registrations = load_yaml_models(repo_root / "config" / "clients", ClientRegistration)
assert_no_shared_client_violations(registrations) # must not raise

View File

@@ -1509,8 +1509,8 @@ backlog:
| Marco | Tarefas | Critério de "pronto" | | Marco | Tarefas | Critério de "pronto" |
|---|---|---| |---|---|---|
| **M0 — Walking skeleton** | EP-00-T01, EP-00-T02, EP-00-T04, EP-06-T02, EP-05-T01, EP-08-T01 | Cliente MCP → Gateway → (roteamento estático) → MCP Server de exemplo (Sales) → resposta, com `trace_id` correlacionado visível em log/coletor OTel. Sem enforcement real ainda. | | **M0 — Walking skeleton** | EP-00-T01, EP-00-T02, EP-00-T04, EP-06-T02, EP-05-T01, EP-08-T01 | Cliente MCP → Gateway → (roteamento estático) → MCP Server de exemplo (Sales) → resposta, com `trace_id` correlacionado visível em log/coletor OTel. Sem enforcement real ainda. |
| **M1 — Segurança núcleo (Fase 1 do README)** | EP-01 completo, EP-03 completo, EP-04 completo, EP-05-T02/T03/T04/T06/T07/T08, EP-02-T01/T02/T03/T05, EP-06-T01/T03/T04, EP-08-T02/T03/T04 | `tools/list` filtrado por entitlement real (Keycloak+OPA), `tools/call` reautorizado, cache privado, fail-closed, anti-bypass, auditoria completa. Cobre RF-01 a RF-09 e Fases 0–2 do roadmap do README. | | **M1 — Segurança núcleo (Fase 1 do README) ✅ concluído (2026-09-11)** | EP-01 completo, EP-03 completo, EP-04 completo, EP-05-T02/T03/T04/T06/T07/T08, EP-02-T01/T02/T03/T05 (via M2), EP-06-T01/T03/T04, EP-08-T02/T03/T04 | `tools/list` filtrado por entitlement real (Keycloak+OPA), `tools/call` reautorizado, cache privado, fail-closed, anti-bypass, auditoria completa. Cobre RF-01 a RF-09 e Fases 0–2 do roadmap do README. **Adendo (2026-09-11, fechamento retroativo pós-M2)**: todos os itens acima que ainda restavam abertos após o M2 (EP-01-T04/T05, EP-05-T03/T07/T08, EP-06-T01/T03/T04, EP-08-T02/T03/T04) foram implementados nesta rodada — Fabric router real (multi-backend, EP-06-T01), segundo domínio Finance com backend real (EP-06-T03), adapter REST genérico standalone (EP-06-T04), taxonomia de auditoria IC/NOC/GRL + correlação decisionId/policyDecisionId/entitlementVersion (EP-08-T02/T03), stack local otel-collector+Jaeger (EP-08-T04), cache hint SEP-2549 (EP-05-T03 — ver limitação de wire-mode abaixo), segmentação de rede + gate de credencial nos domain servers (EP-05-T07), validação header/body Mcp-Method/Mcp-Name (EP-05-T08), lint de shared client (EP-01-T05), spike de mTLS documentado em `docs/adr/ADR-019-identity-extension.md` (EP-01-T04). **Limitação real descoberta e documentada**: SEP-2549 (`ttlMs`) é vocabulário exclusivo do protocolo 2026-07-28, que só existe no modo *stateless per-request* do SDK (`server/discover`, sem handshake `initialize`) — esta RI usa o handshake de sessão clássico em toda parte, então o campo nunca chega ao wire nesta arquitetura (o SDK descarta o campo na serialização para versões de protocolo anteriores); `cache_scope="private"` e o não-caching server-side já garantem a propriedade de segurança do Teste 5 independentemente disso. Ver `src/emcp_bus/gateway/cache.py`. **EP-00-T03 (pipeline de CI)**, apesar de não listado nesta linha, também foi implementado (`.github/workflows/ci.yml`). |
| **M2 — Governança e downstream** | EP-02-T04, EP-04-T04, EP-07 completo, EP-14 completo | Publishing pipeline com lifecycle completo, outbound identity sem passthrough, aprovação humana funcionando fail-closed. Cobre RF-10, RF-11, Fase 3 parcial. | | **M2 — Governança e downstream ✅ concluído (2026-09-11)** | EP-02 completo (T01-T05, incluindo T02/T03/T05 adiados do M1), EP-04-T04, EP-07 completo, EP-14 completo | Publishing pipeline com lifecycle completo, outbound identity sem passthrough, aprovação humana funcionando fail-closed — todos ligados ao Gateway real (EP-05) e testados com OPA real. Cobre RF-10, RF-11, Fase 3 parcial. Detalhes e limitações em `RI/README.md`. **Adendo (2026-09-11, mesmo dia — Docker e Java passaram a estar disponíveis no ambiente)**: `docker-compose.yml` validado com daemon Docker real (build/up/fail-closed/trace cross-container), e **EP-01-T01 (Keycloak) implementado e validado com Keycloak 26.7.3 real** (`deploy/keycloak/realm-export.json`, perfil `identity` no compose, `tests/e2e/test_real_keycloak.py`) — item que a linha do M1 acima já citava como pré-requisito ("Keycloak+OPA") mas que só foi de fato implementado/testado agora. |
| **M3 — Personalização e orquestração (Fase 4 do README)** | EP-09, EP-10, EP-11, EP-12, EP-13-T01..T04 | Perfil→entitlement→ofertas filtradas→NBA→dispatch→execução MCP funcionando ponta a ponta com LangGraph no Orchestrator e Langfuse no agente de exemplo. Cobre RF-12 a RF-17. | | **M3 — Personalização e orquestração (Fase 4 do README)** | EP-09, EP-10, EP-11, EP-12, EP-13-T01..T04 | Perfil→entitlement→ofertas filtradas→NBA→dispatch→execução MCP funcionando ponta a ponta com LangGraph no Orchestrator e Langfuse no agente de exemplo. Cobre RF-12 a RF-17. |
| **M4 — Hardening e entrega** | EP-13-T05, EP-13-T06 (could), EP-15 completo, EP-16 completo | Suíte de 10 testes de aceite (§45) e cenários adversariais (§46, incluindo o de EP-15-T03/ADR-023) passando contra a stack completa; **os dois modos de integração do ADR-024 demonstrados** — Modo A (EP-13-T05: Global Supervisor externo, dois backends/MCP Clients, handoff sem elevação de privilégio) e, se o tempo permitir, Modo B (EP-13-T06: nosso Service Orchestrator como único cérebro de NBA/NBO, mesma stack de segurança); ADRs documentados; README da RI publicado. | | **M4 — Hardening e entrega** | EP-13-T05, EP-13-T06 (could), EP-15 completo, EP-16 completo | Suíte de 10 testes de aceite (§45) e cenários adversariais (§46, incluindo o de EP-15-T03/ADR-023) passando contra a stack completa; **os dois modos de integração do ADR-024 demonstrados** — Modo A (EP-13-T05: Global Supervisor externo, dois backends/MCP Clients, handoff sem elevação de privilégio) e, se o tempo permitir, Modo B (EP-13-T06: nosso Service Orchestrator como único cérebro de NBA/NBO, mesma stack de segurança); ADRs documentados; README da RI publicado. |

View File

@@ -0,0 +1,101 @@
# ADR-019: MCP Client identity -- production extension point (mTLS / workload identity)
**Status:** Documented (spike), not implemented in the RI (EP-01-T04, `could`).
**Related:** `docs/RI-PLANNING.md` ADR-019 entry (confirmed decision, 2026-09-11), README.md §19, RF-02, Axiom 2.
## Context
The RI's identity boundary (`src/emcp_bus/identity/authn.py`, EP-01-T03) is
OAuth 2.1 `client_credentials` against Keycloak (EP-01-T01): every MCP Client
authenticates with a client_id + secret, gets a short-lived JWT, and the
Gateway validates that JWT's signature/issuer/audience/expiry against the
IdP's JWKS before trusting the `azp` claim as the caller's identity.
This is a deliberate, confirmed decision for the RI (not a placeholder): OAuth
2.1 satisfies every property README.md §19 actually requires of an identity
mechanism -- short-lived, revocable, audience-bound, verifiable without a
shared long-term secret in the request path. Real enterprise deployments
commonly go further, using mTLS (mutual TLS client certificates) or a cloud
provider's workload identity federation (e.g. SPIFFE/SPIRE, AWS IAM roles for
service accounts, GCP Workload Identity, Azure Managed Identity) instead of --
or in addition to -- a bearer JWT. This ADR documents how that extension
plugs into the RI's existing seam, without requiring one, so a production
fork isn't blocked on a redesign.
## Decision
Do **not** implement mTLS/workload identity in the RI. Document the
extension point instead, so `EP-01-T03`'s contract is provably stable under
it.
## The extension point
`TokenValidator.validate(token: str) -> AuthenticatedClient` is the *only*
function in the RI that produces client identity for authorization purposes
(`emcp_bus.gateway.server.on_list_tools`/`on_call_tool` both call
`get_access_token()`, which is populated by the SDK's `TokenVerifier`
protocol wrapping this same validator -- see
`emcp_bus.identity.token_verifier.MCPTokenVerifier`). Everything downstream
of it -- `EntitlementManager.resolve`, the PDP, the audit/correlation layer
(EP-08) -- consumes only the resulting `AuthenticatedClient.client_id`. None
of that code cares *how* the identity was established.
A production deployment adding mTLS or workload identity would:
1. Implement a second identity source honoring the same output contract:
```python
class MTLSClientValidator:
def validate(self, request: ...) -> AuthenticatedClient: ...
```
fed from the terminating proxy/load balancer's verified client
certificate (a `X-Client-Cert-CN` style header from a trusted mTLS
terminator, or the ASGI `scope["extensions"]["tls"]` a compliant server
exposes) instead of a JWT. `AuthenticatedClient.client_id` would be the
certificate's Subject/SAN, mapped 1:1 to a `ClientRegistration.client_id`
exactly the way a JWT's `azp` is today -- **no other model in the RI
changes**: `ClientRegistration`, `ClientProfile`, `EntitlementManager`,
the PDP contract, and the audit schema are all already identity-mechanism
agnostic.
2. Provide an alternate `build_auth()`-equivalent in
`emcp_bus/gateway/server.py` that wires the new validator in instead of
`MCPTokenVerifier`, gated by deployment config -- the same shape as
today's `build_auth()` returning `(None, None)` for the "no OIDC
configured" mode. A hybrid deployment (mTLS for one class of backend
service, OAuth for interactive agents) is possible by making the
`TokenVerifier`/equivalent selection per-listener rather than
per-process, which the SDK's `streamable_http_app(auth=..., token_verifier=...)`
parameters already support per app instance.
3. Cloud workload identity (SPIFFE/SPIRE, IRSA, Workload Identity, Managed
Identity) is a variant of the same shape: the "verify a credential the
caller cannot forge, and extract a stable identifier from it" contract is
identical, only the credential format and verification library differ.
None of it requires touching `EntitlementManager`, the PDP, the Fabric
router (EP-06-T01), or the audit/correlation layer (EP-08-T02/T03).
## What must not change
- **`AuthenticatedClient` shape** (`client_id`, `issuer`, `expires_at`):
every downstream consumer types against this, not against a JWT-specific
claim set.
- **Fail-closed contract**: any identity mechanism's failure path must raise
(an `AuthenticationError`-shaped exception) rather than fall back to an
unauthenticated or agent-declared identity (README.md §5, Axiom 2) -- this
is the actual security property EP-01-T03's acceptance criteria test, and
it is mechanism-independent.
- **No new privileged path for `clientInfo`**: whatever the identity
mechanism, only a value verified independently of the MCP request body
may ever populate `client_id`.
## Consequences
- Confirmed for the RI: reduces EP-01's implementation effort without
weakening any of README.md §19's desirable identity properties (all are
satisfied by OAuth 2.1 `client_credentials` against Keycloak).
- A production fork adding mTLS/workload identity changes exactly one
module (`identity/`) and one wiring function (`build_auth`-equivalent);
every other component in the RI (Entitlement Manager, PDP, Fabric,
Registry, audit/correlation, approval) requires zero changes.