mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-05 22:14:11 +00:00
M0 e M1 revisados e fixes implementados
This commit is contained in:
@@ -12,8 +12,53 @@ SALES_DOMAIN_PORT=8100
|
||||
# use 127.0.0.1 for `make dev`, which runs both processes on the host).
|
||||
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) ---
|
||||
# Unset (default): traces print to stdout via ConsoleSpanExporter.
|
||||
# Set to enable OTLP/HTTP export instead (requires the `emcp-bus[otlp]` extra
|
||||
# and a running collector -- see EP-08-T04).
|
||||
# Set to "http://otel-collector:4318" and run with `--profile observability`
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
19
RI/Makefile
19
RI/Makefile
@@ -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
|
||||
PYTHON := $(VENV)/bin/python3
|
||||
@@ -19,16 +19,21 @@ format:
|
||||
$(VENV)/bin/ruff check --fix .
|
||||
|
||||
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:
|
||||
$(VENV)/bin/python3 -m pytest
|
||||
$(VENV)/bin/python3 -m pytest --forked
|
||||
|
||||
validate-schemas:
|
||||
# Placeholder until EP-02/EP-04 introduce the first JSON Schema-backed
|
||||
# YAML config types; will validate every file under config/ against its
|
||||
# schema (see docs/RI-PLANNING.md, EP-00-T03).
|
||||
@echo "No schema-validated config types yet (see EP-02-T01, EP-04-T01)."
|
||||
$(VENV)/bin/python3 scripts/validate_config.py
|
||||
|
||||
export-schemas:
|
||||
$(VENV)/bin/python3 scripts/export_schemas.py
|
||||
|
||||
# Runs both M0 services directly on the host (no Docker required) --
|
||||
# handy for local development and for environments without a Docker daemon.
|
||||
|
||||
142
RI/README.md
142
RI/README.md
@@ -2,18 +2,24 @@
|
||||
|
||||
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)
|
||||
|
||||
```bash
|
||||
make install # cria .venv e instala o pacote em modo editável (dev extras inclusos)
|
||||
make lint # ruff check + ruff format --check
|
||||
make typecheck # mypy --strict
|
||||
make test # pytest (38 testes: unit + e2e)
|
||||
make install # cria .venv e instala o pacote em modo editável (dev extras inclusos)
|
||||
make lint # ruff check + ruff format --check
|
||||
make typecheck # mypy --strict
|
||||
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)
|
||||
|
||||
@@ -33,21 +39,54 @@ Versão usada durante o desenvolvimento desta RI: **OPA v1.20.2** (Rego v1 é o
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
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.finance_domain.server &
|
||||
# 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).
|
||||
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)
|
||||
|
||||
```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)
|
||||
|
||||
@@ -62,37 +101,32 @@ Os dois modos são testados explicitamente em `tests/e2e/test_governed_gateway.p
|
||||
|
||||
| Tarefa | Componente | Status |
|
||||
|---|---|---|
|
||||
| EP-00 (completo) | Scaffold, config loader, docker-compose base | ✅ (ver README anterior/git history) |
|
||||
| EP-01-T02 | Schema de registro de MCP Client | ✅ `src/emcp_bus/identity/models.py` |
|
||||
| EP-01-T03 | Middleware de autenticação OIDC/JWT | ✅ validado com JWKS real + tokens RS256 reais (assinatura, expiração, issuer, audience) |
|
||||
| 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-01-T05 | Lint de shared client | ❌ não implementado (prioridade `could`) |
|
||||
| EP-02 (Registry) | Schema de capability manifest | ✅ `src/emcp_bus/registry/models.py` (schema apenas — pipeline/storage/API ainda não) |
|
||||
| EP-02-T02/T03/T05 | Publishing pipeline, storage, manifests de exemplo | ❌ não implementado |
|
||||
| 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-04 (completo) | Entitlement Manager: resolve, revoke, versionamento | ✅ validado com OPA real + YAML reais de `config/clients/` |
|
||||
| EP-05-T01 | Gateway skeleton | ✅ (M0) |
|
||||
| EP-05-T02 | `tools/list` filtrado por entitlement | ✅ testado (README.md §45 Teste 1) |
|
||||
| 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-00 (completo) | Scaffold, config loader, docker-compose base, **CI** | ✅ `.github/workflows/ci.yml` (EP-00-T03) |
|
||||
| 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-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-03 (completo) | PDP/OPA: entitlement.rego, transaction.rego, cliente, contrato de decisão | ✅ validado com **OPA real** — 9 testes Rego + 6 Python |
|
||||
| EP-04 (completo) | Entitlement Manager: resolve, revoke, versionamento | ✅ validado com OPA real + YAML reais |
|
||||
| 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-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-06-T02 | MCP Server de exemplo — Sales | ✅ 6 tools (R1 + R2), agora com `BackendCredentialGate` (EP-05-T07) |
|
||||
| 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-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-07 (completo) | Outbound/downstream identity: credencial de backend distinta da do client | ✅ `src/emcp_bus/downstream/{models,identity}.py`, ligado ao Gateway, testado |
|
||||
| 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:
|
||||
|
||||
- **Docker**: não validado (ver nota acima). Risco considerado baixo — o Dockerfile usa a mesma instalação (`pip install -e .`) já validada localmente.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -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`)
|
||||
- `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)
|
||||
- `pytest-forked` 1.7.x (resolve a flakiness da suíte e2e, ver acima)
|
||||
|
||||
## Estrutura
|
||||
|
||||
@@ -110,21 +145,38 @@ Ver `../docs/RI-PLANNING.md`, seção 8.5, para a árvore completa planejada. O
|
||||
RI/
|
||||
├── pyproject.toml
|
||||
├── 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
|
||||
├── 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/
|
||||
│ ├── policies/ # EP-03: entitlement.rego, transaction.rego (+ testes opa test)
|
||||
│ └── clients/ # EP-01/EP-04: registrations + profiles/*.yaml
|
||||
│ ├── policies/ # EP-03: entitlement.rego, transaction.rego (+ opa test)
|
||||
│ ├── 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/
|
||||
│ ├── 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
|
||||
│ ├── registry/models.py # EP-02-T01 (schema apenas)
|
||||
│ ├── registry/{models,store,pipeline,lifecycle,seed}.py # EP-02
|
||||
│ ├── pdp/{client,models}.py # EP-03
|
||||
│ └── gateway/server.py # EP-05-T01/T02/T04/T06
|
||||
├── services/example_mcp_servers/sales_domain/server.py # EP-06-T02
|
||||
│ ├── fabric/router.py # EP-06-T01
|
||||
│ ├── 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/
|
||||
├── unit/ # schemas, PDP client, entitlement manager, authn — todos contra dependências reais
|
||||
└── e2e/ # conftest.py (fixtures compartilhados), walking skeleton, governed gateway
|
||||
├── unit/ # schemas, PDP, entitlement, authn, registry, downstream identity, approval,
|
||||
│ # 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)
|
||||
```
|
||||
|
||||
5
RI/config/backends/finance-domain.yaml
Normal file
5
RI/config/backends/finance-domain.yaml
Normal 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
|
||||
8
RI/config/backends/sales-domain.yaml
Normal file
8
RI/config/backends/sales-domain.yaml
Normal 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
|
||||
12
RI/config/capabilities/finance/invoice-get.yaml
Normal file
12
RI/config/capabilities/finance/invoice-get.yaml
Normal 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
|
||||
12
RI/config/capabilities/finance/payment-create.yaml
Normal file
12
RI/config/capabilities/finance/payment-create.yaml
Normal 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
|
||||
16
RI/config/capabilities/finance/payment-execute.yaml
Normal file
16
RI/config/capabilities/finance/payment-execute.yaml
Normal 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
|
||||
12
RI/config/capabilities/sales/customer-get.yaml
Normal file
12
RI/config/capabilities/sales/customer-get.yaml
Normal 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
|
||||
12
RI/config/capabilities/sales/customer-search.yaml
Normal file
12
RI/config/capabilities/sales/customer-search.yaml
Normal 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
|
||||
12
RI/config/capabilities/sales/inventory-check.yaml
Normal file
12
RI/config/capabilities/sales/inventory-check.yaml
Normal 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
|
||||
12
RI/config/capabilities/sales/order-get.yaml
Normal file
12
RI/config/capabilities/sales/order-get.yaml
Normal 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
|
||||
12
RI/config/capabilities/sales/quote-create.yaml
Normal file
12
RI/config/capabilities/sales/quote-create.yaml
Normal 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
|
||||
12
RI/config/capabilities/sales/quote-update.yaml
Normal file
12
RI/config/capabilities/sales/quote-update.yaml
Normal 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
|
||||
@@ -13,6 +13,7 @@ allowed_tools:
|
||||
restrictions:
|
||||
max_transaction_value: 500000
|
||||
allowed_regions: ["BR-SP", "BR-RJ"]
|
||||
approval_threshold: 100000
|
||||
credential_policy:
|
||||
workload_identity: true
|
||||
short_lived: true
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# Shared image for every RI service (Gateway, example MCP servers, ...).
|
||||
# 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
|
||||
# daemon was available in the sandbox this was authored in (see RI/README.md,
|
||||
# "Validado nesta sessão" for exactly what *was* run). Please verify on first
|
||||
# use in an environment with Docker and report back if anything needs fixing.
|
||||
# Verified with a real `docker build`/`docker compose up` (RI/README.md, "O
|
||||
# que foi validado com Docker") -- this image originally omitted `config ./config`
|
||||
# below, which built fine but made the Gateway fail closed at startup
|
||||
# (`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
|
||||
|
||||
WORKDIR /app
|
||||
@@ -14,16 +15,19 @@ WORKDIR /app
|
||||
COPY pyproject.toml ./
|
||||
COPY src ./src
|
||||
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 \
|
||||
&& 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
|
||||
# container network namespace, never the host network directly.
|
||||
RUN useradd --create-home --uid 10001 emcp
|
||||
USER emcp
|
||||
|
||||
EXPOSE 8000 8100
|
||||
EXPOSE 8000 8100 8101
|
||||
|
||||
# Overridden per-service by docker-compose.yml `command:`.
|
||||
CMD ["python3", "-m", "emcp_bus.gateway.server"]
|
||||
|
||||
75
RI/deploy/keycloak/realm-export.json
Normal file
75
RI/deploy/keycloak/realm-export.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
29
RI/deploy/otel/collector-config.yaml
Normal file
29
RI/deploy/otel/collector-config.yaml
Normal 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]
|
||||
@@ -1,19 +1,74 @@
|
||||
# EP-00-T04 (walking skeleton) + EP-16-T01 (grows into the full stack).
|
||||
#
|
||||
# `core` profile = M0: gateway + one example MCP server, static routing, no
|
||||
# enforcement yet. Later milestones add services under additional profiles
|
||||
# (`identity`, `policy`, `observability`, `llm-observability`, ...) so `docker
|
||||
# compose --profile core up` stays cheap through M0/M1 instead of requiring
|
||||
# the whole stack (Keycloak, OPA, Langfuse, ...) just to see the walking
|
||||
# skeleton trace.
|
||||
# `core` profile = the minimum that actually runs: gateway + sales-domain +
|
||||
# finance-domain + OPA. OPA stopped being optional as of M1 (EP-03/EP-05-T06):
|
||||
# the Gateway's `lifespan()` fails closed at startup if it cannot sync
|
||||
# entitlements to a PDP, so "gateway without OPA" is not a smaller working
|
||||
# stack, it's a Gateway that refuses to start (README.md §38) -- by design,
|
||||
# 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
|
||||
# in (no `docker` binary in that sandbox) -- validated instead by running the
|
||||
# same two processes directly on the host (see RI/README.md). Please run
|
||||
# `docker compose --profile core up --build` on first use and report back if
|
||||
# anything needs adjusting.
|
||||
# EP-05-T07 (anti-bypass): `opa`, `sales-domain` and `finance-domain`
|
||||
# deliberately publish no host port below -- on a real Docker host, that
|
||||
# already means nothing outside the compose network can reach them at all
|
||||
# (Docker only exposes a container port past the host via `ports:`); only
|
||||
# `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:
|
||||
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:
|
||||
build:
|
||||
context: .
|
||||
@@ -22,9 +77,10 @@ services:
|
||||
environment:
|
||||
SALES_DOMAIN_HOST: "0.0.0.0"
|
||||
SALES_DOMAIN_PORT: "8100"
|
||||
ports:
|
||||
- "8100:8100"
|
||||
SALES_DOMAIN_SERVICE_TOKEN: "dev-sales-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:
|
||||
# A bare TCP connect, not an HTTP/MCP-protocol check: streamable-http
|
||||
# rejects a plain GET, so this only confirms the process is listening.
|
||||
@@ -33,6 +89,24 @@ services:
|
||||
timeout: 3s
|
||||
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:
|
||||
build:
|
||||
context: .
|
||||
@@ -42,9 +116,49 @@ services:
|
||||
GATEWAY_HOST: "0.0.0.0"
|
||||
GATEWAY_PORT: "8000"
|
||||
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:
|
||||
- "8000:8000"
|
||||
profiles: ["core"]
|
||||
restart: on-failure:5
|
||||
depends_on:
|
||||
sales-domain:
|
||||
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"]
|
||||
|
||||
@@ -25,6 +25,13 @@ dev = [
|
||||
"mypy>=2.3.1,<3",
|
||||
"pytest>=9.1.1,<10",
|
||||
"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",
|
||||
]
|
||||
|
||||
|
||||
60
RI/schemas/approval-request.schema.json
Normal file
60
RI/schemas/approval-request.schema.json
Normal 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"
|
||||
}
|
||||
56
RI/schemas/backend-credentials.schema.json
Normal file
56
RI/schemas/backend-credentials.schema.json
Normal 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"
|
||||
}
|
||||
199
RI/schemas/capability-manifest.schema.json
Normal file
199
RI/schemas/capability-manifest.schema.json
Normal 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"
|
||||
}
|
||||
148
RI/schemas/client-profile.schema.json
Normal file
148
RI/schemas/client-profile.schema.json
Normal 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"
|
||||
}
|
||||
40
RI/schemas/client-registration.schema.json
Normal file
40
RI/schemas/client-registration.schema.json
Normal 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"
|
||||
}
|
||||
33
RI/schemas/pdp-decision.schema.json
Normal file
33
RI/schemas/pdp-decision.schema.json
Normal 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"
|
||||
}
|
||||
44
RI/scripts/export_schemas.py
Normal file
44
RI/scripts/export_schemas.py
Normal 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()
|
||||
67
RI/scripts/validate_config.py
Normal file
67
RI/scripts/validate_config.py
Normal 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())
|
||||
111
RI/services/example_mcp_servers/finance_domain/server.py
Normal file
111
RI/services/example_mcp_servers/finance_domain/server.py
Normal 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()
|
||||
@@ -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,
|
||||
customer.get, order.get, inventory.check. Backed by static mock data -- this
|
||||
server is deliberately "dumb": it never authorizes anything (ADR-023 in
|
||||
docs/RI-PLANNING.md). Authorization happens exclusively upstream, in the
|
||||
Gateway/PEP (EP-05) before a call ever reaches this process.
|
||||
customer.get, order.get, inventory.check. Write tools (R2): quote.create,
|
||||
quote.update. Backed by static mock data -- this server is deliberately
|
||||
"dumb": it never authorizes anything (ADR-023 in docs/RI-PLANNING.md).
|
||||
Authorization happens exclusively upstream, in the Gateway/PEP (EP-05)
|
||||
before a call ever reaches this process.
|
||||
|
||||
Run directly for local development:
|
||||
|
||||
@@ -13,10 +14,14 @@ Run directly for local development:
|
||||
|
||||
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("sales-domain-mcp-server")
|
||||
@@ -37,6 +42,9 @@ _INVENTORY: dict[str, int] = {
|
||||
"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.")
|
||||
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)}
|
||||
|
||||
|
||||
@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:
|
||||
host = os.environ.get("SALES_DOMAIN_HOST", "127.0.0.1")
|
||||
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__":
|
||||
|
||||
0
RI/src/emcp_bus/approval/__init__.py
Normal file
0
RI/src/emcp_bus/approval/__init__.py
Normal file
52
RI/src/emcp_bus/approval/models.py
Normal file
52
RI/src/emcp_bus/approval/models.py
Normal 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
|
||||
113
RI/src/emcp_bus/approval/service.py
Normal file
113
RI/src/emcp_bus/approval/service.py
Normal 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
|
||||
0
RI/src/emcp_bus/audit/__init__.py
Normal file
0
RI/src/emcp_bus/audit/__init__.py
Normal file
80
RI/src/emcp_bus/audit/bypass_detection.py
Normal file
80
RI/src/emcp_bus/audit/bypass_detection.py
Normal 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
|
||||
76
RI/src/emcp_bus/audit/correlation.py
Normal file
76
RI/src/emcp_bus/audit/correlation.py
Normal 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")
|
||||
154
RI/src/emcp_bus/audit/events.py
Normal file
154
RI/src/emcp_bus/audit/events.py
Normal 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},
|
||||
)
|
||||
89
RI/src/emcp_bus/audit/models.py
Normal file
89
RI/src/emcp_bus/audit/models.py
Normal 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)
|
||||
38
RI/src/emcp_bus/audit/sink.py
Normal file
38
RI/src/emcp_bus/audit/sink.py
Normal 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")
|
||||
0
RI/src/emcp_bus/downstream/__init__.py
Normal file
0
RI/src/emcp_bus/downstream/__init__.py
Normal file
75
RI/src/emcp_bus/downstream/identity.py
Normal file
75
RI/src/emcp_bus/downstream/identity.py
Normal 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
|
||||
69
RI/src/emcp_bus/downstream/models.py
Normal file
69
RI/src/emcp_bus/downstream/models.py
Normal 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
|
||||
0
RI/src/emcp_bus/fabric/__init__.py
Normal file
0
RI/src/emcp_bus/fabric/__init__.py
Normal file
0
RI/src/emcp_bus/fabric/adapters/__init__.py
Normal file
0
RI/src/emcp_bus/fabric/adapters/__init__.py
Normal file
90
RI/src/emcp_bus/fabric/adapters/rest_adapter.py
Normal file
90
RI/src/emcp_bus/fabric/adapters/rest_adapter.py
Normal 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
|
||||
88
RI/src/emcp_bus/fabric/router.py
Normal file
88
RI/src/emcp_bus/fabric/router.py
Normal 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}")
|
||||
66
RI/src/emcp_bus/gateway/cache.py
Normal file
66
RI/src/emcp_bus/gateway/cache.py
Normal 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")
|
||||
119
RI/src/emcp_bus/gateway/canonical_request.py
Normal file
119
RI/src/emcp_bus/gateway/canonical_request.py
Normal 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)
|
||||
@@ -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
|
||||
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()`/
|
||||
`build_auth()`, rather than into module-level constants at import time:
|
||||
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).
|
||||
|
||||
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
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
@@ -36,23 +46,46 @@ from pathlib import Path
|
||||
|
||||
import mcp_types as types
|
||||
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.provider import TokenVerifier
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel import Server
|
||||
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.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.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.models import ClientRegistration
|
||||
from emcp_bus.identity.token_verifier import MCPTokenVerifier
|
||||
from emcp_bus.pdp.client import PDPClient, PDPUnavailableError, compute_policy_version
|
||||
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")
|
||||
|
||||
@@ -61,22 +94,22 @@ _REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewayConfig:
|
||||
downstream_url: str
|
||||
opa_url: str
|
||||
config_dir: Path
|
||||
oidc_jwks_url: str
|
||||
oidc_issuer: str
|
||||
oidc_audience: str
|
||||
audit_log_path: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> GatewayConfig:
|
||||
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"),
|
||||
config_dir=Path(os.environ.get("EMCP_CONFIG_DIR", str(_REPO_ROOT / "config"))),
|
||||
oidc_jwks_url=os.environ.get("OIDC_JWKS_URL", ""),
|
||||
oidc_issuer=os.environ.get("OIDC_ISSUER", ""),
|
||||
oidc_audience=os.environ.get("OIDC_AUDIENCE", "emcp-gateway"),
|
||||
audit_log_path=os.environ.get("EMCP_AUDIT_LOG_PATH", "/tmp/emcp-audit.jsonl"),
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -91,16 +124,47 @@ class GatewayConfig:
|
||||
def policies_dir(self) -> Path:
|
||||
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
|
||||
class GatewayState:
|
||||
entitlement_manager: EntitlementManager
|
||||
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
|
||||
async def lifespan(server: Server[GatewayState]) -> AsyncIterator[GatewayState]:
|
||||
global _current_state
|
||||
config = GatewayConfig.from_env()
|
||||
pdp = PDPClient(
|
||||
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
|
||||
# to at startup must not come up looking healthy (README.md §38).
|
||||
await manager.sync_profiles_to_pdp()
|
||||
|
||||
backend_credentials = BackendCredentialProvider(
|
||||
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 GatewayState(
|
||||
entitlement_manager=manager, pdp=pdp, downstream_url=config.downstream_url
|
||||
)
|
||||
yield state
|
||||
finally:
|
||||
_current_state = None
|
||||
await pdp.aclose()
|
||||
registry_db_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def _with_downstream_session[ResultT](
|
||||
downstream_url: str,
|
||||
backend_token: str,
|
||||
coro_factory: Callable[[ClientSession], Awaitable[ResultT]],
|
||||
) -> ResultT:
|
||||
"""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
|
||||
session under concurrent requests; the Enterprise MCP Fabric (EP-06) is
|
||||
where connection pooling/reuse per backend would be introduced later.
|
||||
`backend_token` is the *outbound* credential (EP-07-T01) -- never the
|
||||
inbound MCP Client's own bearer token (README.md §20, ADR-006: no blind
|
||||
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 (
|
||||
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,
|
||||
):
|
||||
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(
|
||||
ctx: ServerRequestContext[GatewayState],
|
||||
params: types.PaginatedRequestParams | None,
|
||||
@@ -162,13 +266,42 @@ async def on_list_tools(
|
||||
except UnknownClientError:
|
||||
return types.ListToolsResult(tools=[])
|
||||
|
||||
downstream_result = await _with_downstream_session(
|
||||
state.downstream_url, lambda session: session.list_tools(params=params)
|
||||
state.audit_sink.emit(
|
||||
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
|
||||
]
|
||||
return downstream_result.model_copy(update={"tools": filtered_tools})
|
||||
|
||||
# EP-06-T01: query only the backends that actually serve a tool this
|
||||
# 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(
|
||||
@@ -185,26 +318,110 @@ async def on_call_tool(
|
||||
except UnknownClientError:
|
||||
return _denied("UNKNOWN_CLIENT")
|
||||
|
||||
try:
|
||||
decision = await state.pdp.evaluate(
|
||||
arguments = dict(params.arguments or {})
|
||||
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,
|
||||
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:
|
||||
state.audit_sink.emit(
|
||||
events.pdp_unavailable(client_id=access_token.client_id, tool=params.name)
|
||||
)
|
||||
return _denied("PDP_UNAVAILABLE")
|
||||
|
||||
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)
|
||||
|
||||
if decision.outcome is PDPOutcome.REQUIRE_APPROVAL:
|
||||
# EP-14 (human approval workflow) does not exist yet in this milestone
|
||||
# -- fail closed rather than silently upgrading to ALLOW.
|
||||
return _denied(f"REQUIRE_APPROVAL:{decision.reason_code}")
|
||||
try:
|
||||
approved = state.approval_service.check_and_consume(
|
||||
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(
|
||||
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):
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
import uvicorn
|
||||
|
||||
host = os.environ.get("GATEWAY_HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("GATEWAY_PORT", "8000"))
|
||||
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")
|
||||
|
||||
|
||||
|
||||
45
RI/src/emcp_bus/identity/shared_client_lint.py
Normal file
45
RI/src/emcp_bus/identity/shared_client_lint.py
Normal 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)
|
||||
44
RI/src/emcp_bus/registry/lifecycle.py
Normal file
44
RI/src/emcp_bus/registry/lifecycle.py
Normal 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)"
|
||||
)
|
||||
83
RI/src/emcp_bus/registry/pipeline.py
Normal file
83
RI/src/emcp_bus/registry/pipeline.py
Normal 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
|
||||
38
RI/src/emcp_bus/registry/seed.py
Normal file
38
RI/src/emcp_bus/registry/seed.py
Normal 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
|
||||
107
RI/src/emcp_bus/registry/store.py
Normal file
107
RI/src/emcp_bus/registry/store.py
Normal 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)
|
||||
@@ -37,8 +37,28 @@ ISSUER = "https://keycloak.example/realms/emcp"
|
||||
AUDIENCE = "emcp-gateway"
|
||||
KID = "test-key-1"
|
||||
|
||||
GATEWAY_PORT = 18000
|
||||
SALES_PORT = 18100
|
||||
_current_gateway_port: int | None = None
|
||||
"""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:
|
||||
@@ -180,19 +200,34 @@ async def governed_stack(
|
||||
) -> AsyncIterator[None]:
|
||||
"""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
|
||||
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")
|
||||
itself is substituted by the JWKS server -- see RI/README.md).
|
||||
|
||||
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("OIDC_JWKS_URL", jwks_url)
|
||||
monkeypatch.setenv("OIDC_ISSUER", ISSUER)
|
||||
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
|
||||
|
||||
sales_config = uvicorn.Config(
|
||||
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=SALES_PORT,
|
||||
port=sales_port,
|
||||
log_level="warning",
|
||||
)
|
||||
sales_server = RunningServer(sales_config)
|
||||
@@ -202,23 +237,91 @@ async def governed_stack(
|
||||
|
||||
auth_settings, token_verifier = gateway_module.build_auth()
|
||||
gateway_config = uvicorn.Config(
|
||||
gateway_module.server.streamable_http_app(
|
||||
host="127.0.0.1", auth=auth_settings, token_verifier=token_verifier
|
||||
),
|
||||
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=GATEWAY_PORT,
|
||||
port=gateway_port,
|
||||
log_level="warning",
|
||||
)
|
||||
gateway_server = RunningServer(gateway_config)
|
||||
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()
|
||||
|
||||
|
||||
@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](
|
||||
operation: Callable[[ClientSession], Awaitable[ResultT]],
|
||||
*,
|
||||
@@ -232,7 +335,7 @@ async def run_against_gateway[ResultT](
|
||||
Gateway defect: production deployments run one process per service.
|
||||
"""
|
||||
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
|
||||
for attempt in range(3):
|
||||
if attempt:
|
||||
|
||||
@@ -136,13 +136,13 @@ async def test_transaction_policy_denies_over_limit_even_though_entitled(
|
||||
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]
|
||||
) -> 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
|
||||
REQUIRE_APPROVAL to ALLOW -- and EP-14 (approval workflow) does not exist
|
||||
yet in this milestone, so the only safe outcome is DENY."""
|
||||
REQUIRE_APPROVAL to ALLOW -- the first attempt of any such call is always
|
||||
denied, since no approval has been granted for it yet (EP-14-T02)."""
|
||||
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
|
||||
|
||||
result = await run_against_gateway(
|
||||
@@ -154,6 +154,152 @@ async def test_require_approval_is_denied_pending_ep14(
|
||||
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(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -188,10 +334,19 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
||||
"""
|
||||
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("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
|
||||
monkeypatch.delenv("OIDC_JWKS_URL", 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(
|
||||
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=SALES_PORT,
|
||||
port=sales_port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
@@ -214,15 +369,14 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
||||
|
||||
gateway_server = RunningServer(
|
||||
uvicorn.Config(
|
||||
gateway_module.server.streamable_http_app(
|
||||
host="127.0.0.1", auth=auth_settings, token_verifier=token_verifier
|
||||
),
|
||||
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
||||
host="127.0.0.1",
|
||||
port=GATEWAY_PORT,
|
||||
port=gateway_port,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
await gateway_server.start()
|
||||
set_current_gateway_port(gateway_port)
|
||||
try:
|
||||
list_result = await run_against_gateway(lambda session: session.list_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 "UNAUTHENTICATED" in _text(call_result)
|
||||
finally:
|
||||
set_current_gateway_port(None)
|
||||
await gateway_server.stop()
|
||||
await sales_server.stop()
|
||||
|
||||
|
||||
249
RI/tests/e2e/test_real_keycloak.py
Normal file
249
RI/tests/e2e/test_real_keycloak.py
Normal 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
|
||||
163
RI/tests/unit/test_approval.py
Normal file
163
RI/tests/unit/test_approval.py
Normal 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
139
RI/tests/unit/test_audit.py
Normal 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)
|
||||
87
RI/tests/unit/test_bypass_detection.py
Normal file
87
RI/tests/unit/test_bypass_detection.py
Normal 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
|
||||
72
RI/tests/unit/test_canonical_request.py
Normal file
72
RI/tests/unit/test_canonical_request.py
Normal 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
|
||||
127
RI/tests/unit/test_downstream_identity.py
Normal file
127
RI/tests/unit/test_downstream_identity.py
Normal 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")
|
||||
114
RI/tests/unit/test_fabric_router.py
Normal file
114
RI/tests/unit/test_fabric_router.py
Normal 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"}
|
||||
23
RI/tests/unit/test_gateway_cache.py
Normal file
23
RI/tests/unit/test_gateway_cache.py
Normal 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)
|
||||
165
RI/tests/unit/test_registry.py
Normal file
165
RI/tests/unit/test_registry.py
Normal 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")
|
||||
57
RI/tests/unit/test_registry_seed_manifests.py
Normal file
57
RI/tests/unit/test_registry_seed_manifests.py
Normal 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
|
||||
104
RI/tests/unit/test_rest_adapter.py
Normal file
104
RI/tests/unit/test_rest_adapter.py
Normal 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()
|
||||
72
RI/tests/unit/test_shared_client_lint.py
Normal file
72
RI/tests/unit/test_shared_client_lint.py
Normal 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
|
||||
Reference in New Issue
Block a user