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:
119
RI/src/emcp_bus/gateway/canonical_request.py
Normal file
119
RI/src/emcp_bus/gateway/canonical_request.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user