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:
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)
|
||||
Reference in New Issue
Block a user