M0 e M1 revisados e fixes implementados

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

View File

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,13 @@ not configured for a given deployment, not a bug to work around.
Fail-closed (README.md §38, EP-05-T06): a PDP that cannot be reached is a
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")

View File

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

View File

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

View File

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

View File

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

View File

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