Files
Enterprise-MCP-Service-Bus/RI/src/emcp_bus/gateway/canonical_request.py
2026-09-14 08:18:53 -03:00

120 lines
4.8 KiB
Python

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