mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-05 22:14:11 +00:00
120 lines
4.8 KiB
Python
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)
|