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,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}")