mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
106 lines
4.2 KiB
Python
106 lines
4.2 KiB
Python
"""M0 walking skeleton, now exercised through M1's real enforcement chain
|
|
(docs/RI-PLANNING.md, Marco M0 + M1: EP-01/EP-03/EP-04/EP-05).
|
|
|
|
Client -> Gateway (real OIDC auth + real OPA-backed entitlement) -> (static
|
|
routing) -> example Sales MCP server -> response, over real HTTP
|
|
(streamable-http), with a single OTel trace_id correlating spans across both
|
|
processes. Authenticates as `sales-read-agent` (Sales.Read, README.md §7.1)
|
|
calling only tools within its own entitlement -- the denial paths (no token,
|
|
wrong tool, expired/forged token, PDP down) are tests/e2e/test_governed_gateway.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
|
|
from opentelemetry.sdk.trace import ReadableSpan
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
from opentelemetry.trace import SpanKind
|
|
|
|
from tests.e2e.conftest import run_against_gateway
|
|
|
|
SALES_READ_CLIENT_ID = "sales-read-agent"
|
|
|
|
|
|
async def test_tools_list_is_filtered_to_sales_read_entitlement(
|
|
governed_stack: None, sign_token: Callable[..., str]
|
|
) -> None:
|
|
token = sign_token(SALES_READ_CLIENT_ID)
|
|
|
|
result = await run_against_gateway(lambda session: session.list_tools(), token=token)
|
|
|
|
# README.md §7.1: Sales.Read sees exactly these four -- not
|
|
# sales.quote.create/update (Sales.Write-only) and not any Finance tool.
|
|
assert {tool.name for tool in result.tools} == {
|
|
"sales.customer.search",
|
|
"sales.customer.get",
|
|
"sales.order.get",
|
|
"sales.inventory.check",
|
|
}
|
|
|
|
|
|
async def test_tools_call_reaches_the_backend_and_returns_its_result(
|
|
governed_stack: None, sign_token: Callable[..., str]
|
|
) -> None:
|
|
token = sign_token(SALES_READ_CLIENT_ID)
|
|
|
|
result = await run_against_gateway(
|
|
lambda session: session.call_tool("sales.customer.get", {"customer_id": "cust-001"}),
|
|
token=token,
|
|
)
|
|
|
|
assert result.is_error is not True
|
|
assert result.structured_content == {
|
|
"customer_id": "cust-001",
|
|
"name": "Aurora Ltda",
|
|
"region": "BR-SP",
|
|
}
|
|
|
|
|
|
async def test_a_single_trace_id_correlates_gateway_and_backend_spans(
|
|
governed_stack: None, sign_token: Callable[..., str]
|
|
) -> None:
|
|
"""Both hops of one `tools/call` must land on the same trace_id.
|
|
|
|
This test runs both server processes in-process (in-process uvicorn, for
|
|
speed), so they share one OTel global TracerProvider/resource -- unlike a
|
|
real multi-process deployment (verified manually: each process gets its
|
|
own `service.name` resource), so `resource.attributes["service.name"]`
|
|
cannot be used here to tell the two hops apart. `SpanKind.SERVER` can:
|
|
the gateway receiving the call from the client, and the Sales server
|
|
receiving it from the gateway, are each a distinct SERVER-kind
|
|
`tools/call ...` span -- exactly two (or more, under a rare transport
|
|
retry -- see `run_against_gateway`) of them sharing one trace_id proves
|
|
the request was correlated across both hops.
|
|
"""
|
|
from opentelemetry import trace
|
|
|
|
exporter = InMemorySpanExporter()
|
|
trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore[attr-defined]
|
|
|
|
token = sign_token(SALES_READ_CLIENT_ID)
|
|
await run_against_gateway(
|
|
lambda session: session.call_tool("sales.customer.get", {"customer_id": "cust-001"}),
|
|
token=token,
|
|
)
|
|
|
|
spans: tuple[ReadableSpan, ...] = exporter.get_finished_spans()
|
|
server_tool_call_spans = [
|
|
span
|
|
for span in spans
|
|
if span.name.startswith("tools/call") and span.kind == SpanKind.SERVER
|
|
]
|
|
trace_ids = {span.context.trace_id for span in server_tool_call_spans}
|
|
|
|
assert len(trace_ids) == 1, (
|
|
"Expected exactly one trace_id shared by both hops, got "
|
|
f"{[f'{t:#x}' for t in trace_ids]} across spans {[s.name for s in server_tool_call_spans]}"
|
|
)
|
|
assert len(server_tool_call_spans) >= 2, (
|
|
"Expected at least one SERVER 'tools/call' span for the gateway hop and one "
|
|
f"for the backend hop, got {len(server_tool_call_spans)}: "
|
|
f"{[s.name for s in server_tool_call_spans]} -- see Marco M0 acceptance "
|
|
"criterion in docs/RI-PLANNING.md"
|
|
)
|