mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
M0 e M1 revisados e fixes implementados
This commit is contained in:
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}")
|
||||
Reference in New Issue
Block a user