arcade-mcp/libs/arcade-mcp-server/arcade_mcp_server/middleware/base.py
Sankara R. Avula 78c8e6fb99
feat: Add TelemetryPassbackMiddleware for serverExecutionTelemetry capability (#797)
**Implements**: [SEP-2448: server execution telemetry]
(https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2448)



**Description:**

**The Observability Gap (The Problem)**

MCP clients propagate trace context to servers, but server-side
execution remains a black box. The client sees a single tools/call or
resources/read span; everything the server does (auth checks, policy
evaluation, API calls, sub-tool invocations) is invisible. In
cross-organization deployments, clients and servers use separate
observability backends with no shared collector access, making
traditional span export useless.

<img width="1015" height="450" alt="Screenshot 2026-03-23 at 3 43 21 PM"
src="https://github.com/user-attachments/assets/58c817b5-fee6-46a3-9877-d523a25368ad"
/>


**Server Execution Telemetry (The Solution)**

Servers advertise serverExecutionTelemetry and return a curated slice of
their execution spans directly in _meta.otel of the response. Clients
ingest these verbatim OTLP spans into their own collector, stitching
server-side execution into their distributed trace; no shared
infrastructure required. The black box becomes transparent.

<img width="945" height="574" alt="Screenshot 2026-03-23 at 3 43 44 PM"
src="https://github.com/user-attachments/assets/38d97c94-aa73-4e62-9b4e-3264600e5ed0"
/>

.
**Summary:**

Implement MCP serverExecutionTelemetry capability that enables
cross-organization distributed tracing by returning server-side
OpenTelemetry spans to clients inline via _meta.otel.traces.

  Server-side (middleware):
  - TelemetryPassbackMiddleware intercepts tools/call and resources/read
- ContextVarSpanCollector isolates span collection per-request via
ContextVar
- Propagates traceparent from client request for distributed trace
stitching
- Serializes collected spans to verbatim OTLP JSON (resourceSpans
format), directly POSTable to /v1/traces
- Top-level span filtering by default; full span tree via detailed
opt-in
- Middleware advertises capabilities via get_capabilities() on the
Middleware base class
  - Provisional API: FutureWarning emitted until SEP-2448 is ratified

  Client-side (reference agent):
- LangChain ReAct agent connects to MCP server via
streamable_http_client with OAuth 2.1
  - Detects serverExecutionTelemetry capability at initialization
- Dynamically wraps discovered MCP tools with traceparent propagation
and _meta.otel span request
- Ingests returned server spans into Jaeger (OTLP JSON) and Galileo
(OTLP protobuf)
- Two-act demo: --no-passback (black box) vs default (full server-side
visibility)

  Dependencies:
  - opentelemetry-api and opentelemetry-sdk added to arcade-mcp-server

  Bump arcade-mcp-server version to 1.18.0.
2026-03-25 15:57:50 -07:00

248 lines
8.3 KiB
Python

"""Base middleware classes for MCP server."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from functools import partial
from typing import Any, Generic, Literal, Protocol, TypeVar, cast, runtime_checkable
from arcade_mcp_server.types import (
CallToolParams,
CallToolResult,
GetPromptParams,
GetPromptResult,
JSONRPCMessage,
ListPromptsRequest,
ListResourcesRequest,
ListResourceTemplatesRequest,
ListToolsRequest,
MCPTool,
Prompt,
ReadResourceParams,
ReadResourceResult,
Resource,
ResourceTemplate,
)
T = TypeVar("T")
R = TypeVar("R", covariant=True)
@runtime_checkable
class CallNext(Protocol[T, R]):
"""Protocol for the next handler in the middleware chain."""
def __call__(self, context: "MiddlewareContext[T]") -> Awaitable[R]: ...
@dataclass(kw_only=True)
class MiddlewareContext(Generic[T]):
"""Context passed through the middleware chain.
Contains the message being processed and metadata about the request.
"""
# The message being processed
message: T
# The MCP context (optional, set when in request context)
mcp_context: Any | None = None
# Metadata
source: Literal["client", "server"] = "client"
type: Literal["request", "notification"] = "request"
method: str | None = None
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
# Request-specific metadata
request_id: str | None = None
session_id: str | None = None
# Additional metadata that can be added by middleware
metadata: dict[str, Any] = field(default_factory=dict)
def copy(self, **kwargs: Any) -> "MiddlewareContext[T]":
"""Create a copy with updated fields."""
return replace(self, **kwargs)
class Middleware:
"""Base class for MCP middleware with typed handlers for each method.
Middleware can intercept and modify requests and responses at various
stages of processing. Each handler receives the context and a call_next
function to invoke the next handler in the chain.
"""
def get_capabilities(self) -> dict[str, Any]:
"""Return extra server capabilities to advertise.
Override in subclasses to declare capabilities (e.g. serverExecutionTelemetry).
"""
return {}
async def __call__(
self,
context: MiddlewareContext[T],
call_next: CallNext[T, Any],
) -> Any:
"""Main entry point that orchestrates the middleware chain."""
# Build handler chain based on message type
handler = await self._build_handler_chain(context, call_next)
return await handler(context)
async def _build_handler_chain(
self,
context: MiddlewareContext[Any],
call_next: CallNext[Any, Any],
) -> CallNext[Any, Any]:
"""Build the handler chain for the specific message type."""
handler = call_next
# Method-specific handlers
if context.method:
match context.method:
case "tools/call":
handler = partial(self.on_call_tool, call_next=handler)
case "tools/list":
handler = partial(self.on_list_tools, call_next=handler)
case "resources/read":
handler = partial(self.on_read_resource, call_next=handler)
case "resources/list":
handler = partial(self.on_list_resources, call_next=handler)
case "resources/templates/list":
handler = partial(self.on_list_resource_templates, call_next=handler)
case "prompts/get":
handler = partial(self.on_get_prompt, call_next=handler)
case "prompts/list":
handler = partial(self.on_list_prompts, call_next=handler)
# Type-specific handlers
match context.type:
case "request":
handler = partial(self.on_request, call_next=handler)
case "notification":
handler = partial(self.on_notification, call_next=handler)
# Generic message handler (always runs)
handler = partial(self.on_message, call_next=handler)
return handler
# Generic handlers
async def on_message(
self,
context: MiddlewareContext[Any],
call_next: CallNext[Any, Any],
) -> Any:
"""Handle any message. Override to add generic processing."""
return await call_next(context)
async def on_request(
self,
context: MiddlewareContext[JSONRPCMessage],
call_next: CallNext[JSONRPCMessage, Any],
) -> Any:
"""Handle request messages. Override to add request processing."""
return await call_next(context)
async def on_notification(
self,
context: MiddlewareContext[JSONRPCMessage],
call_next: CallNext[JSONRPCMessage, Any],
) -> Any:
"""Handle notification messages. Override to add notification processing."""
return await call_next(context)
# Tool handlers
async def on_call_tool(
self,
context: MiddlewareContext[CallToolParams],
call_next: CallNext[CallToolParams, CallToolResult],
) -> CallToolResult:
"""Handle tool calls. Override to add tool-specific processing."""
return await call_next(context)
async def on_list_tools(
self,
context: MiddlewareContext[ListToolsRequest],
call_next: CallNext[ListToolsRequest, list[MCPTool]],
) -> list[MCPTool]:
"""Handle tool listing. Override to filter or modify tool list."""
return await call_next(context)
# Resource handlers
async def on_read_resource(
self,
context: MiddlewareContext[ReadResourceParams],
call_next: CallNext[ReadResourceParams, ReadResourceResult],
) -> ReadResourceResult:
"""Handle resource reading. Override to add resource processing."""
return await call_next(context)
async def on_list_resources(
self,
context: MiddlewareContext[ListResourcesRequest],
call_next: CallNext[ListResourcesRequest, list[Resource]],
) -> list[Resource]:
"""Handle resource listing. Override to filter or modify resource list."""
return await call_next(context)
async def on_list_resource_templates(
self,
context: MiddlewareContext[ListResourceTemplatesRequest],
call_next: CallNext[ListResourceTemplatesRequest, list[ResourceTemplate]],
) -> list[ResourceTemplate]:
"""Handle resource template listing. Override to filter or modify template list."""
return await call_next(context)
# Prompt handlers
async def on_get_prompt(
self,
context: MiddlewareContext[GetPromptParams],
call_next: CallNext[GetPromptParams, GetPromptResult],
) -> GetPromptResult:
"""Handle prompt retrieval. Override to add prompt processing."""
return await call_next(context)
async def on_list_prompts(
self,
context: MiddlewareContext[ListPromptsRequest],
call_next: CallNext[ListPromptsRequest, list[Prompt]],
) -> list[Prompt]:
"""Handle prompt listing. Override to filter or modify prompt list."""
return await call_next(context)
def compose_middleware(
*middleware: Middleware,
) -> Callable[[MiddlewareContext[T], CallNext[T, R]], Awaitable[R]]:
"""Compose multiple middleware into a single handler.
The middleware are applied in reverse order, so the first middleware
in the list is the outermost (runs first on request, last on response).
"""
async def composed(
context: MiddlewareContext[T],
call_next: CallNext[T, R],
) -> R:
# Build the chain in reverse order into a CallNext[T, R]
current: CallNext[T, R] = call_next
for mw in reversed(middleware):
async def wrapper(
ctx: MiddlewareContext[T],
next_handler: CallNext[T, R] = current,
m: Middleware = mw,
) -> R:
result = await m(ctx, next_handler)
return cast(R, result)
# wrapper conforms to CallNext[T, R]
current = wrapper # type: ignore[assignment]
return await current(context)
return composed