From 202094c4e49ab352c45dc7071002652c2cba27d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:37:16 -0700 Subject: [PATCH 01/25] test(red): require exact-origin MCP browser preflight --- tests/test_mcp_cors_preflight.py | 126 +++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/test_mcp_cors_preflight.py diff --git a/tests/test_mcp_cors_preflight.py b/tests/test_mcp_cors_preflight.py new file mode 100644 index 000000000..339ff8ff1 --- /dev/null +++ b/tests/test_mcp_cors_preflight.py @@ -0,0 +1,126 @@ +"""Browser admission regressions for the authenticated MCP endpoint.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from starlette.testclient import TestClient + +from backend.app import mcp_server +from backend.app.config import Settings + + +def _settings() -> Settings: + """Return one exact browser Origin and Host admission policy.""" + return Settings( + database_url="postgresql://example", + keycloak_base_url="https://issuer.example", + keycloak_realm="realm", + keycloak_client_id="frontend", + keycloak_issuer="https://issuer.example/realms/realm", + frontend_origins=[], + orchestrator_base_url="", + orchestrator_api_key="", + vision_model="", + valkey_url="redis://example", + searxng_base_url="", + tepp_transport_url="", + rankweave_disabled=False, + mcp_resource_url="https://lineage.example/mcp", + mcp_audience="https://lineage.example/mcp", + mcp_required_scopes=[], + mcp_allowed_hosts=["testserver"], + mcp_allowed_origins=["https://buyer.example"], + ) + + +@dataclass +class _Pool: + """Minimal lifespan pool for transport-only requests.""" + + closed: bool = False + + async def close(self) -> None: + self.closed = True + + +def _app(): + """Build the real MCP ASGI surface without a reachable database.""" + cfg = _settings() + pool = _Pool() + + async def pool_factory(_database_url: str): + return pool + + server = mcp_server.build_mcp_server(cfg, pool_factory=pool_factory) + return mcp_server.build_mcp_http_app(server, cfg), pool + + +def test_allowed_exact_origin_preflight_finishes_before_oauth() -> None: + """A browser can preflight the authenticated MCP request contract.""" + app, pool = _app() + with TestClient(app) as client: + response = client.options( + "/mcp", + headers={ + "Origin": "https://buyer.example", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": ( + "authorization, content-type, mcp-protocol-version, mcp-session-id" + ), + }, + ) + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "https://buyer.example" + assert "POST" in response.headers["access-control-allow-methods"] + assert "authorization" in response.headers["access-control-allow-headers"].casefold() + assert "mcp-protocol-version" in response.headers["access-control-allow-headers"].casefold() + assert "www-authenticate" not in response.headers + assert pool.closed is True + + +def test_disallowed_origin_preflight_fails_closed_without_reflection() -> None: + """Prefix, suffix, null, and unrelated Origins are never reflected.""" + app, pool = _app() + with TestClient(app) as client: + for origin in ( + "https://buyer.example.attacker.test", + "https://prefix-buyer.example", + "null", + "https://attacker.example", + ): + response = client.options( + "/mcp", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "authorization, content-type", + }, + ) + assert response.status_code == 403 + assert response.headers.get("access-control-allow-origin") != origin + assert "www-authenticate" not in response.headers + assert pool.closed is True + + +def test_no_origin_non_browser_post_keeps_oauth_challenge() -> None: + """Non-browser clients may omit Origin and still reach the OAuth boundary.""" + app, pool = _app() + with TestClient(app) as client: + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1"}, + }, + }, + headers={"MCP-Protocol-Version": "2025-11-25"}, + ) + assert response.status_code == 401 + assert "resource_metadata" in response.headers["www-authenticate"] + assert pool.closed is True From b3398807669b844fea32b5737e6d81160695813f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:37:56 -0700 Subject: [PATCH 02/25] test(red): bound MCP request bytes before parsing --- tests/test_mcp_request_admission.py | 151 ++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 tests/test_mcp_request_admission.py diff --git a/tests/test_mcp_request_admission.py b/tests/test_mcp_request_admission.py new file mode 100644 index 000000000..379783d56 --- /dev/null +++ b/tests/test_mcp_request_admission.py @@ -0,0 +1,151 @@ +"""Request-byte admission regressions for the MCP Streamable HTTP boundary.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from backend.app.mcp_admission import BoundedRequestBodyApp + + +class _RecordingApp: + """Record the replayed request body when admission succeeds.""" + + def __init__(self) -> None: + self.body = b"" + self.calls = 0 + + async def __call__(self, scope, receive, send) -> None: + self.calls += 1 + while True: + message = await receive() + if message["type"] != "http.request": + break + self.body += message.get("body", b"") + if not message.get("more_body", False): + break + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + +async def _invoke( + app, + *, + headers: list[tuple[bytes, bytes]], + chunks: list[bytes], +) -> tuple[int, dict[str, Any]]: + """Invoke an ASGI app with an exact chunked request and decode its response.""" + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "POST", + "scheme": "https", + "path": "/mcp", + "raw_path": b"/mcp", + "query_string": b"", + "root_path": "", + "headers": headers, + "client": ("127.0.0.1", 12345), + "server": ("testserver", 443), + } + messages = [ + { + "type": "http.request", + "body": chunk, + "more_body": index < len(chunks) - 1, + } + for index, chunk in enumerate(chunks) + ] + sent: list[dict[str, Any]] = [] + + async def receive() -> dict[str, Any]: + return messages.pop(0) + + async def send(message: dict[str, Any]) -> None: + sent.append(message) + + await app(scope, receive, send) + start = next(message for message in sent if message["type"] == "http.response.start") + body = b"".join( + message.get("body", b"") + for message in sent + if message["type"] == "http.response.body" + ) + return int(start["status"]), json.loads(body) if body else {} + + +@pytest.mark.asyncio +async def test_over_limit_declared_length_is_rejected_before_downstream() -> None: + downstream = _RecordingApp() + app = BoundedRequestBodyApp(downstream, maximum_bytes=8) + + status, payload = await _invoke( + app, + headers=[ + (b"content-type", b"application/json"), + (b"content-length", b"9"), + ], + chunks=[b"123456789"], + ) + + assert status == 413 + assert payload == {"error_code": "mcp_request_too_large"} + assert downstream.calls == 0 + + +@pytest.mark.asyncio +async def test_chunked_body_is_bounded_without_content_length() -> None: + downstream = _RecordingApp() + app = BoundedRequestBodyApp(downstream, maximum_bytes=8) + + status, payload = await _invoke( + app, + headers=[(b"content-type", b"application/json")], + chunks=[b"1234", b"5678", b"9"], + ) + + assert status == 413 + assert payload == {"error_code": "mcp_request_too_large"} + assert downstream.calls == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "headers", + [ + [(b"content-length", b"-1")], + [(b"content-length", b"not-a-number")], + [(b"content-length", b"3"), (b"content-length", b"4")], + [(b"content-length", b"3"), (b"transfer-encoding", b"chunked")], + ], +) +async def test_ambiguous_or_invalid_length_fails_closed(headers) -> None: + downstream = _RecordingApp() + app = BoundedRequestBodyApp(downstream, maximum_bytes=8) + + status, payload = await _invoke(app, headers=headers, chunks=[b"123"]) + + assert status == 400 + assert payload == {"error_code": "mcp_invalid_content_length"} + assert downstream.calls == 0 + + +@pytest.mark.asyncio +async def test_under_limit_body_is_replayed_byte_exactly() -> None: + downstream = _RecordingApp() + app = BoundedRequestBodyApp(downstream, maximum_bytes=8) + + status, payload = await _invoke( + app, + headers=[(b"content-length", b"8")], + chunks=[b"123", b"45678"], + ) + + assert status == 204 + assert payload == {} + assert downstream.calls == 1 + assert downstream.body == b"12345678" From 54fa2a208ea477a80601754121b42f44f2af62ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:42:33 -0700 Subject: [PATCH 03/25] fix(mcp): bound request bytes before SDK parsing --- backend/app/mcp_admission.py | 133 +++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 backend/app/mcp_admission.py diff --git a/backend/app/mcp_admission.py b/backend/app/mcp_admission.py new file mode 100644 index 000000000..8fff805c0 --- /dev/null +++ b/backend/app/mcp_admission.py @@ -0,0 +1,133 @@ +"""Bound MCP request bodies before OAuth and JSON decoding.""" + +from __future__ import annotations + +import json +from collections.abc import Sequence +from typing import Any + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + + +class BoundedRequestBodyApp: + """Reject ambiguous or oversized MCP POST bodies before downstream parsing. + + ``Content-Length`` is useful for an early rejection but is never trusted as + the only resource boundary. Requests without it are read incrementally into + a buffer capped at ``maximum_bytes``. A successful request is replayed once + to the downstream ASGI application without changing its bytes. + """ + + def __init__(self, app: ASGIApp, *, maximum_bytes: int) -> None: + """Wrap ``app`` with one positive finite body-size limit.""" + if maximum_bytes <= 0: + raise ValueError("maximum_bytes must be positive") + self._app = app + self._maximum_bytes = maximum_bytes + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Apply admission to HTTP POST and pass other ASGI traffic unchanged.""" + if scope["type"] != "http" or scope.get("method") != "POST": + await self._app(scope, receive, send) + return + + content_lengths = _header_values(scope, b"content-length") + transfer_encodings = _header_values(scope, b"transfer-encoding") + declared_length = _parse_content_length(content_lengths, transfer_encodings) + if declared_length is _INVALID_LENGTH: + await _send_error(send, 400, "mcp_invalid_content_length") + return + if isinstance(declared_length, int) and declared_length > self._maximum_bytes: + await _send_error(send, 413, "mcp_request_too_large") + return + + body = bytearray() + while True: + message = await receive() + message_type = message.get("type") + if message_type == "http.disconnect": + await _send_error(send, 400, "mcp_request_disconnected") + return + if message_type != "http.request": + await _send_error(send, 400, "mcp_invalid_request_body") + return + chunk = message.get("body", b"") + if not isinstance(chunk, bytes): + await _send_error(send, 400, "mcp_invalid_request_body") + return + if len(body) + len(chunk) > self._maximum_bytes: + await _send_error(send, 413, "mcp_request_too_large") + return + body.extend(chunk) + if not message.get("more_body", False): + break + + if isinstance(declared_length, int) and declared_length != len(body): + await _send_error(send, 400, "mcp_content_length_mismatch") + return + + replayed = False + + async def replay_receive() -> Message: + nonlocal replayed + if replayed: + return {"type": "http.disconnect"} + replayed = True + return {"type": "http.request", "body": bytes(body), "more_body": False} + + await self._app(scope, replay_receive, send) + + +class _InvalidLength: + """Sentinel distinguishing an invalid length from an absent one.""" + + +_INVALID_LENGTH = _InvalidLength() + + +def _header_values(scope: Scope, name: bytes) -> tuple[bytes, ...]: + """Return every raw value for one case-insensitive ASGI request header.""" + return tuple( + value + for header_name, value in scope.get("headers", []) + if header_name.casefold() == name + ) + + +def _parse_content_length( + content_lengths: Sequence[bytes], + transfer_encodings: Sequence[bytes], +) -> int | None | _InvalidLength: + """Return an unambiguous nonnegative length, absence, or invalid sentinel.""" + if len(content_lengths) > 1: + return _INVALID_LENGTH + if content_lengths and transfer_encodings: + return _INVALID_LENGTH + if not content_lengths: + return None + try: + decoded = content_lengths[0].decode("ascii") + except UnicodeDecodeError: + return _INVALID_LENGTH + if not decoded or not decoded.isdecimal(): + return _INVALID_LENGTH + try: + return int(decoded, 10) + except ValueError: + return _INVALID_LENGTH + + +async def _send_error(send: Send, status_code: int, error_code: str) -> None: + """Send one bounded payload-safe admission error response.""" + body = json.dumps( + {"error_code": error_code}, + ensure_ascii=True, + separators=(",", ":"), + ).encode("ascii") + headers = [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode("ascii")), + (b"cache-control", b"no-store"), + ] + await send({"type": "http.response.start", "status": status_code, "headers": headers}) + await send({"type": "http.response.body", "body": body}) From 56625dfd25a3cf9a2acc1e240b9e181e59b9e1a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:43:30 -0700 Subject: [PATCH 04/25] feat(mcp): configure a bounded request admission limit --- backend/app/config.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/backend/app/config.py b/backend/app/config.py index 78778f563..e83e36e50 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,6 +14,24 @@ def _csv_setting(name: str, default: str = "") -> list[str]: return [value.strip() for value in os.environ.get(name, default).split(",") if value.strip()] +def _bounded_int_setting( + name: str, + default: int, + *, + minimum: int, + maximum: int, +) -> int: + """Read one base-10 integer and fail closed outside its configured bounds.""" + raw_value = os.environ.get(name, str(default)) + try: + value = int(raw_value, 10) + except ValueError as exc: + raise ValueError(f"{name} must be a base-10 integer") from exc + if not minimum <= value <= maximum: + raise ValueError(f"{name} must be between {minimum} and {maximum}") + return value + + @dataclass(frozen=True) class Settings: """Runtime settings shared by the REST API and MCP resource server.""" @@ -76,6 +94,9 @@ class Settings: default_factory=lambda: ["localhost:*", "127.0.0.1:*", "mcp:8001"] ) mcp_allowed_origins: list[str] = field(default_factory=list) + # A 4,000-character tool question fits comfortably inside this default. + # The ASGI boundary enforces the bytes before OAuth or SDK JSON parsing. + mcp_max_request_bytes: int = 65_536 @property def keycloak_jwks_uri(self) -> str: @@ -117,4 +138,10 @@ def load_settings() -> Settings: "MCP_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*,mcp:8001" ), mcp_allowed_origins=_csv_setting("MCP_ALLOWED_ORIGINS"), + mcp_max_request_bytes=_bounded_int_setting( + "MCP_MAX_REQUEST_BYTES", + 65_536, + minimum=8_192, + maximum=1_048_576, + ), ) \ No newline at end of file From c0cf0bb59e371aafd35914d68b0cadc9696650b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:44:41 -0700 Subject: [PATCH 05/25] fix(mcp): admit exact-origin bounded browser requests --- backend/app/mcp_server.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index ffcbf54a4..7189f27b7 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -19,6 +19,7 @@ ) from mcp.types import ToolAnnotations from pydantic import AnyHttpUrl, BaseModel, Field +from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request from starlette.types import ASGIApp, Receive, Scope, Send @@ -33,6 +34,7 @@ NullGlobalAskExternalVerifier, SearxngOrchestratorGlobalAskVerifier, ) +from backend.app.mcp_admission import BoundedRequestBodyApp from backend.app.mcp_auth import KeycloakMcpTokenVerifier from lineageweave.image_content import orchestrator_vision_client from lineageweave.post_chat import ContextualOrchestratorPostChatClient, NullPostChatClient, PostChatClient @@ -235,16 +237,36 @@ def build_mcp_http_app( server: MCPServer[McpAppContext], settings: Settings, ) -> ASGIApp: - """Build the Streamable HTTP app with transport checks outside OAuth.""" + """Build exact-origin, byte-bounded Streamable HTTP outside OAuth.""" transport_security = TransportSecuritySettings( enable_dns_rebinding_protection=True, allowed_hosts=settings.mcp_allowed_hosts, allowed_origins=settings.mcp_allowed_origins, ) sdk_app = server.streamable_http_app(transport_security=transport_security) - return PreAuthTransportSecurityApp(sdk_app, transport_security) + cors_app = CORSMiddleware( + sdk_app, + allow_origins=settings.mcp_allowed_origins, + allow_methods=["GET", "POST", "DELETE"], + allow_headers=[ + "Accept", + "Authorization", + "Content-Type", + "Last-Event-ID", + "MCP-Protocol-Version", + "Mcp-Session-Id", + ], + expose_headers=["MCP-Protocol-Version", "Mcp-Session-Id"], + allow_credentials=False, + max_age=600, + ) + bounded_app = BoundedRequestBodyApp( + cors_app, + maximum_bytes=settings.mcp_max_request_bytes, + ) + return PreAuthTransportSecurityApp(bounded_app, transport_security) _settings = load_settings() mcp = build_mcp_server(_settings) -app = build_mcp_http_app(mcp, _settings) +app = build_mcp_http_app(mcp, _settings) \ No newline at end of file From 09148839109bdc22fa9f73d6bb68a07abd7709e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:45:31 -0700 Subject: [PATCH 06/25] fix(mcp): compare raw admission headers safely --- backend/app/mcp_admission.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/app/mcp_admission.py b/backend/app/mcp_admission.py index 8fff805c0..d23f1f13c 100644 --- a/backend/app/mcp_admission.py +++ b/backend/app/mcp_admission.py @@ -4,7 +4,6 @@ import json from collections.abc import Sequence -from typing import Any from starlette.types import ASGIApp, Message, Receive, Scope, Send @@ -90,7 +89,7 @@ def _header_values(scope: Scope, name: bytes) -> tuple[bytes, ...]: return tuple( value for header_name, value in scope.get("headers", []) - if header_name.casefold() == name + if header_name.lower() == name ) From 6463ea3c3ce158f6e552f3d594b5c18efc3922d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:46:42 -0700 Subject: [PATCH 07/25] test(mcp): validate request-byte configuration --- tests/test_mcp_config_admission.py | 78 ++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/test_mcp_config_admission.py diff --git a/tests/test_mcp_config_admission.py b/tests/test_mcp_config_admission.py new file mode 100644 index 000000000..731a75ba8 --- /dev/null +++ b/tests/test_mcp_config_admission.py @@ -0,0 +1,78 @@ +"""Configuration contracts for MCP request-byte admission.""" + +from __future__ import annotations + +import pytest + +from backend.app.config import _bounded_int_setting, load_settings + + +def test_bounded_integer_setting_uses_default_and_explicit_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing value uses the safe default and a valid override is accepted.""" + monkeypatch.delenv("TEST_BOUNDED_INTEGER", raising=False) + assert _bounded_int_setting( + "TEST_BOUNDED_INTEGER", + 64, + minimum=8, + maximum=128, + ) == 64 + monkeypatch.setenv("TEST_BOUNDED_INTEGER", "96") + assert _bounded_int_setting( + "TEST_BOUNDED_INTEGER", + 64, + minimum=8, + maximum=128, + ) == 96 + + +@pytest.mark.parametrize("raw_value", ["not-a-number", "8.5", ""]) +def test_bounded_integer_setting_rejects_non_integer( + monkeypatch: pytest.MonkeyPatch, + raw_value: str, +) -> None: + """Malformed values fail during settings construction rather than at runtime.""" + monkeypatch.setenv("TEST_BOUNDED_INTEGER", raw_value) + with pytest.raises(ValueError, match="must be a base-10 integer"): + _bounded_int_setting( + "TEST_BOUNDED_INTEGER", + 64, + minimum=8, + maximum=128, + ) + + +@pytest.mark.parametrize("raw_value", ["7", "129"]) +def test_bounded_integer_setting_rejects_out_of_range( + monkeypatch: pytest.MonkeyPatch, + raw_value: str, +) -> None: + """A value outside the explicit resource envelope fails closed.""" + monkeypatch.setenv("TEST_BOUNDED_INTEGER", raw_value) + with pytest.raises(ValueError, match="must be between 8 and 128"): + _bounded_int_setting( + "TEST_BOUNDED_INTEGER", + 64, + minimum=8, + maximum=128, + ) + + +def test_load_settings_exposes_validated_mcp_request_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The public environment contract reaches the immutable Settings object.""" + monkeypatch.setenv("MCP_MAX_REQUEST_BYTES", "32768") + assert load_settings().mcp_max_request_bytes == 32768 + + +@pytest.mark.parametrize("raw_value", ["8191", "1048577", "invalid"]) +def test_load_settings_rejects_invalid_mcp_request_limit( + monkeypatch: pytest.MonkeyPatch, + raw_value: str, +) -> None: + """Unsafe MCP limits prevent process startup rather than silently drifting.""" + monkeypatch.setenv("MCP_MAX_REQUEST_BYTES", raw_value) + with pytest.raises(ValueError): + load_settings() From 0208932144d1d60988af88c890543b8f6c97c63f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:48:13 -0700 Subject: [PATCH 08/25] test(mcp): cover every request admission branch --- tests/test_mcp_request_admission.py | 167 ++++++++++++++++++++++------ 1 file changed, 136 insertions(+), 31 deletions(-) diff --git a/tests/test_mcp_request_admission.py b/tests/test_mcp_request_admission.py index 379783d56..3eb0d2bf3 100644 --- a/tests/test_mcp_request_admission.py +++ b/tests/test_mcp_request_admission.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -from collections.abc import Awaitable, Callable from typing import Any import pytest @@ -12,21 +11,27 @@ class _RecordingApp: - """Record the replayed request body when admission succeeds.""" + """Record the replayed request body and terminal replay message.""" - def __init__(self) -> None: + def __init__(self, *, read_after_body: bool = False) -> None: self.body = b"" self.calls = 0 + self.read_after_body = read_after_body + self.terminal_message: dict[str, Any] | None = None async def __call__(self, scope, receive, send) -> None: self.calls += 1 - while True: - message = await receive() - if message["type"] != "http.request": - break - self.body += message.get("body", b"") - if not message.get("more_body", False): - break + if scope["type"] == "http" and scope.get("method") == "POST": + while True: + message = await receive() + if message["type"] != "http.request": + self.terminal_message = message + break + self.body += message.get("body", b"") + if not message.get("more_body", False): + if self.read_after_body: + self.terminal_message = await receive() + break await send({"type": "http.response.start", "status": 204, "headers": []}) await send({"type": "http.response.body", "body": b""}) @@ -35,14 +40,17 @@ async def _invoke( app, *, headers: list[tuple[bytes, bytes]], - chunks: list[bytes], -) -> tuple[int, dict[str, Any]]: - """Invoke an ASGI app with an exact chunked request and decode its response.""" + chunks: list[Any] | None = None, + messages: list[dict[str, Any]] | None = None, + method: str = "POST", + scope_type: str = "http", +) -> tuple[int, dict[str, Any], list[dict[str, Any]]]: + """Invoke an ASGI app with exact messages and decode its response.""" scope = { - "type": "http", + "type": scope_type, "asgi": {"version": "3.0"}, "http_version": "1.1", - "method": "POST", + "method": method, "scheme": "https", "path": "/mcp", "raw_path": b"/mcp", @@ -52,18 +60,21 @@ async def _invoke( "client": ("127.0.0.1", 12345), "server": ("testserver", 443), } - messages = [ - { - "type": "http.request", - "body": chunk, - "more_body": index < len(chunks) - 1, - } - for index, chunk in enumerate(chunks) - ] + if messages is None: + body_chunks = chunks if chunks is not None else [b""] + messages = [ + { + "type": "http.request", + "body": chunk, + "more_body": index < len(body_chunks) - 1, + } + for index, chunk in enumerate(body_chunks) + ] + queued_messages = list(messages) sent: list[dict[str, Any]] = [] async def receive() -> dict[str, Any]: - return messages.pop(0) + return queued_messages.pop(0) async def send(message: dict[str, Any]) -> None: sent.append(message) @@ -75,7 +86,39 @@ async def send(message: dict[str, Any]) -> None: for message in sent if message["type"] == "http.response.body" ) - return int(start["status"]), json.loads(body) if body else {} + return int(start["status"]), json.loads(body) if body else {}, sent + + +def test_nonpositive_limit_is_rejected_at_construction() -> None: + """An invalid resource envelope cannot create an admission boundary.""" + with pytest.raises(ValueError, match="maximum_bytes must be positive"): + BoundedRequestBodyApp(_RecordingApp(), maximum_bytes=0) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("scope_type", "method"), + [("lifespan", "POST"), ("http", "GET")], +) +async def test_non_post_traffic_passes_through_unchanged( + scope_type: str, + method: str, +) -> None: + """Only MCP POST bodies are buffered by the admission layer.""" + downstream = _RecordingApp() + app = BoundedRequestBodyApp(downstream, maximum_bytes=8) + + status, payload, _ = await _invoke( + app, + headers=[], + chunks=[b""], + method=method, + scope_type=scope_type, + ) + + assert status == 204 + assert payload == {} + assert downstream.calls == 1 @pytest.mark.asyncio @@ -83,7 +126,7 @@ async def test_over_limit_declared_length_is_rejected_before_downstream() -> Non downstream = _RecordingApp() app = BoundedRequestBodyApp(downstream, maximum_bytes=8) - status, payload = await _invoke( + status, payload, sent = await _invoke( app, headers=[ (b"content-type", b"application/json"), @@ -94,6 +137,7 @@ async def test_over_limit_declared_length_is_rejected_before_downstream() -> Non assert status == 413 assert payload == {"error_code": "mcp_request_too_large"} + assert (b"cache-control", b"no-store") in sent[0]["headers"] assert downstream.calls == 0 @@ -102,7 +146,7 @@ async def test_chunked_body_is_bounded_without_content_length() -> None: downstream = _RecordingApp() app = BoundedRequestBodyApp(downstream, maximum_bytes=8) - status, payload = await _invoke( + status, payload, _ = await _invoke( app, headers=[(b"content-type", b"application/json")], chunks=[b"1234", b"5678", b"9"], @@ -119,6 +163,8 @@ async def test_chunked_body_is_bounded_without_content_length() -> None: [ [(b"content-length", b"-1")], [(b"content-length", b"not-a-number")], + [(b"content-length", b"\xff")], + [(b"content-length", b"9" * 5000)], [(b"content-length", b"3"), (b"content-length", b"4")], [(b"content-length", b"3"), (b"transfer-encoding", b"chunked")], ], @@ -127,7 +173,7 @@ async def test_ambiguous_or_invalid_length_fails_closed(headers) -> None: downstream = _RecordingApp() app = BoundedRequestBodyApp(downstream, maximum_bytes=8) - status, payload = await _invoke(app, headers=headers, chunks=[b"123"]) + status, payload, _ = await _invoke(app, headers=headers, chunks=[b"123"]) assert status == 400 assert payload == {"error_code": "mcp_invalid_content_length"} @@ -135,13 +181,54 @@ async def test_ambiguous_or_invalid_length_fails_closed(headers) -> None: @pytest.mark.asyncio -async def test_under_limit_body_is_replayed_byte_exactly() -> None: +async def test_declared_and_actual_length_must_match() -> None: + """A truncated or smuggled body cannot cross the admission boundary.""" + downstream = _RecordingApp() + app = BoundedRequestBodyApp(downstream, maximum_bytes=8) + + status, payload, _ = await _invoke( + app, + headers=[(b"content-length", b"4")], + chunks=[b"123"], + ) + + assert status == 400 + assert payload == {"error_code": "mcp_content_length_mismatch"} + assert downstream.calls == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("messages", "expected_error"), + [ + ([{"type": "http.disconnect"}], "mcp_request_disconnected"), + ([{"type": "unexpected"}], "mcp_invalid_request_body"), + ( + [{"type": "http.request", "body": "not-bytes", "more_body": False}], + "mcp_invalid_request_body", + ), + ], +) +async def test_invalid_asgi_body_stream_fails_closed(messages, expected_error) -> None: + """Disconnects and malformed ASGI messages never reach OAuth or JSON parsing.""" downstream = _RecordingApp() app = BoundedRequestBodyApp(downstream, maximum_bytes=8) - status, payload = await _invoke( + status, payload, _ = await _invoke(app, headers=[], messages=messages) + + assert status == 400 + assert payload == {"error_code": expected_error} + assert downstream.calls == 0 + + +@pytest.mark.asyncio +async def test_under_limit_body_is_replayed_byte_exactly() -> None: + downstream = _RecordingApp(read_after_body=True) + app = BoundedRequestBodyApp(downstream, maximum_bytes=8) + + status, payload, _ = await _invoke( app, - headers=[(b"content-length", b"8")], + headers=[(b"Content-Length", b"8")], chunks=[b"123", b"45678"], ) @@ -149,3 +236,21 @@ async def test_under_limit_body_is_replayed_byte_exactly() -> None: assert payload == {} assert downstream.calls == 1 assert downstream.body == b"12345678" + assert downstream.terminal_message == {"type": "http.disconnect"} + + +@pytest.mark.asyncio +async def test_under_limit_stream_without_declared_length_is_replayed() -> None: + """Chunked/streamed requests remain supported when their actual bytes are bounded.""" + downstream = _RecordingApp() + app = BoundedRequestBodyApp(downstream, maximum_bytes=8) + + status, payload, _ = await _invoke( + app, + headers=[(b"transfer-encoding", b"chunked")], + chunks=[b"12", b"345"], + ) + + assert status == 204 + assert payload == {} + assert downstream.body == b"12345" From c6d549ecded1a35bf7162273bd9c341b29f2f815 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:49:27 -0700 Subject: [PATCH 09/25] test(mcp): require origin-aware browser responses --- tests/test_mcp_cors_preflight.py | 51 +++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/tests/test_mcp_cors_preflight.py b/tests/test_mcp_cors_preflight.py index 339ff8ff1..ea77c1f76 100644 --- a/tests/test_mcp_cors_preflight.py +++ b/tests/test_mcp_cors_preflight.py @@ -56,6 +56,20 @@ async def pool_factory(_database_url: str): return mcp_server.build_mcp_http_app(server, cfg), pool +def _initialize_request() -> dict[str, object]: + """Return one protocol-valid initialization body.""" + return { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1"}, + }, + } + + def test_allowed_exact_origin_preflight_finishes_before_oauth() -> None: """A browser can preflight the authenticated MCP request contract.""" app, pool = _app() @@ -72,13 +86,37 @@ def test_allowed_exact_origin_preflight_finishes_before_oauth() -> None: ) assert response.status_code == 200 assert response.headers["access-control-allow-origin"] == "https://buyer.example" + assert "Origin" in response.headers["vary"] assert "POST" in response.headers["access-control-allow-methods"] assert "authorization" in response.headers["access-control-allow-headers"].casefold() assert "mcp-protocol-version" in response.headers["access-control-allow-headers"].casefold() + assert "mcp-session-id" in response.headers["access-control-allow-headers"].casefold() assert "www-authenticate" not in response.headers assert pool.closed is True +def test_allowed_origin_post_reaches_oauth_with_cors_response_contract() -> None: + """An allowed browser Origin receives CORS metadata on the OAuth challenge.""" + app, pool = _app() + with TestClient(app) as client: + response = client.post( + "/mcp", + json=_initialize_request(), + headers={ + "Origin": "https://buyer.example", + "MCP-Protocol-Version": "2025-11-25", + }, + ) + assert response.status_code == 401 + assert response.headers["access-control-allow-origin"] == "https://buyer.example" + assert "Origin" in response.headers["vary"] + exposed = response.headers["access-control-expose-headers"].casefold() + assert "mcp-session-id" in exposed + assert "mcp-protocol-version" in exposed + assert "resource_metadata" in response.headers["www-authenticate"] + assert pool.closed is True + + def test_disallowed_origin_preflight_fails_closed_without_reflection() -> None: """Prefix, suffix, null, and unrelated Origins are never reflected.""" app, pool = _app() @@ -99,6 +137,7 @@ def test_disallowed_origin_preflight_fails_closed_without_reflection() -> None: ) assert response.status_code == 403 assert response.headers.get("access-control-allow-origin") != origin + assert "Origin" in response.headers["vary"] assert "www-authenticate" not in response.headers assert pool.closed is True @@ -109,18 +148,10 @@ def test_no_origin_non_browser_post_keeps_oauth_challenge() -> None: with TestClient(app) as client: response = client.post( "/mcp", - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-11-25", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "1"}, - }, - }, + json=_initialize_request(), headers={"MCP-Protocol-Version": "2025-11-25"}, ) assert response.status_code == 401 + assert "access-control-allow-origin" not in response.headers assert "resource_metadata" in response.headers["www-authenticate"] assert pool.closed is True From 6035aeb4c9e95bb2037423da5c177343cbfd9a7d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:50:37 -0700 Subject: [PATCH 10/25] fix(mcp): vary transport rejections by Origin --- backend/app/mcp_server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 7189f27b7..5f84c8ce7 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -90,6 +90,8 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: is_post=request.method == "POST", ) if rejection is not None: + if request.headers.get("origin") is not None: + rejection.headers.add_vary_header("Origin") await rejection(scope, receive, send) return await self._app(scope, receive, send) From 5121917eb29be60bf3454ea2f0eaac1fcfd2e62c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:59:11 -0700 Subject: [PATCH 11/25] docs: record MCP browser admission architecture --- .../adr/0036-mcp-browser-request-admission.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/adr/0036-mcp-browser-request-admission.md diff --git a/docs/adr/0036-mcp-browser-request-admission.md b/docs/adr/0036-mcp-browser-request-admission.md new file mode 100644 index 000000000..8236f16b3 --- /dev/null +++ b/docs/adr/0036-mcp-browser-request-admission.md @@ -0,0 +1,107 @@ +# ADR 0036: MCP browser and request-byte admission precede OAuth + +- **Status:** Accepted +- **Date:** 2026-08-20 +- **Extends:** ADR 0031 + +## Context + +ADR 0031 establishes an authenticated Streamable HTTP MCP resource server with +Host and Origin validation. A browser client adds two concrete transport +requirements that a non-browser MCP client does not: + +1. an authenticated cross-origin POST commonly requires an unauthenticated + CORS preflight; and +2. the request body can arrive without a trusted `Content-Length`, so a limit + checked only after SDK JSON parsing is not a resource boundary. + +If preflight reaches OAuth first, the browser cannot complete the request even +when its exact Origin is allowed. If the server relies only on a declared +length, a chunked or otherwise streamed body can allocate and decode more data +than the intended MCP request envelope. Ambiguous `Content-Length` and +`Transfer-Encoding` combinations also create inconsistent framing boundaries. + +The MCP Streamable HTTP specification requires Origin validation on incoming +connections to prevent DNS rebinding. RFC 9112 defines request framing and +identifies `Content-Length` plus `Transfer-Encoding` as a request-smuggling +risk. The Fetch CORS protocol requires an Origin-sensitive response contract +for browser access. + +## Decision + +1. Keep MCP SDK Host and Origin validation as the outermost HTTP boundary. + A hostile Host returns `421`; an invalid present Origin returns `403` before + OAuth, database access, MCP parsing, or a tool invocation. +2. Handle an exact allowed-Origin CORS preflight outside OAuth. Never use `*`, + suffix matching, prefix matching, credentials, or Origin reflection. +3. Limit the browser contract to the Streamable HTTP methods used by the SDK: + `GET`, `POST`, and `DELETE`. Permit only the request headers needed by MCP + and OAuth: `Accept`, `Authorization`, `Content-Type`, `Last-Event-ID`, + `MCP-Protocol-Version`, and `Mcp-Session-Id`. +4. Expose only `MCP-Protocol-Version` and `Mcp-Session-Id` to browser clients. + Origin-sensitive responses include `Vary: Origin`. +5. Preserve non-browser clients: a request without `Origin` remains valid and + reaches the existing OAuth boundary. +6. Place a pure-ASGI body admission wrapper after Host/Origin validation and + before CORS, OAuth, or SDK JSON parsing for every POST. +7. Use `Content-Length` only for an early rejection. Independently count every + received body chunk and stop once the configured byte limit would be + exceeded. +8. Reject negative, nondecimal, non-ASCII, duplicate `Content-Length`, and a + request carrying both `Content-Length` and `Transfer-Encoding`. Reject a + declared/actual length mismatch. The stricter duplicate policy is deliberate: + LineageWeave does not need intermediary-compatible normalization at this + application boundary. +9. Accept a body without `Content-Length` when the actual streamed bytes remain + bounded. Replay an admitted body byte-for-byte exactly once to the SDK. +10. Use a default `MCP_MAX_REQUEST_BYTES` of 65,536 bytes. Operators may choose + 8,192 through 1,048,576 bytes; invalid values prevent process startup. +11. Return stable payload-safe admission errors without echoing request bytes: + `mcp_invalid_content_length`, `mcp_content_length_mismatch`, + `mcp_request_disconnected`, `mcp_invalid_request_body`, and + `mcp_request_too_large`. Admission errors are `Cache-Control: no-store`. +12. Keep distributed per-principal invocation limiting separate. A pre-auth + byte or CORS rejection must not consume an invocation quota, and a future + MCP-compatible rate-limit response must not be fabricated at this transport + boundary. + +The effective request path is: + +```text +Host/Origin transport validation +→ bounded POST body admission +→ exact CORS/preflight handling +→ OAuth resource-server middleware +→ MCP SDK JSON parsing and routing +→ database RBAC/ABAC +→ Global Ask +``` + +## Consequences + +- Browser MCP clients can complete preflight without weakening OAuth or exact + Origin validation. +- Fixed-length and streamed requests share one byte envelope before expensive + parsing or authentication work. +- A valid request is buffered once at the MCP ingress. The configured upper + bound makes that memory cost explicit and finite. +- Reverse proxies should still reject malformed framing and close unsafe + HTTP/1.1 connections. This application boundary is defense in depth, not a + replacement for correct proxy framing. +- The endpoint remains usable by Codex and other non-browser clients that omit + `Origin`. + +## Rejected alternatives + +- **OAuth before CORS:** makes an otherwise authorized browser integration + unusable because preflight has no bearer token. +- **Wildcard CORS:** permits arbitrary websites to address the protected MCP + resource and defeats the exact-Origin contract. +- **Trust `Content-Length` only:** does not bound a streamed body and cannot + defend against header/body disagreement. +- **Require `Content-Length` on every request:** unnecessarily rejects valid + bounded HTTP streaming clients. +- **Call `request.json()` and then check size:** performs the allocation and + decoding before enforcing the resource boundary. +- **Fold rate limiting into pre-auth admission:** lacks an authenticated + principal and would mix transport abuse controls with billable tool quota. From f12dcf416483776aa29f6585ae120354f3328ae9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:59:44 -0700 Subject: [PATCH 12/25] docs: expose MCP browser admission settings --- .env.example | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 03b0fb45e..e6418b6d5 100644 --- a/.env.example +++ b/.env.example @@ -22,9 +22,19 @@ KEYCLOAK_PORT=18080 BACKEND_PORT=18420 +# Dedicated authenticated Streamable HTTP MCP resource server (ADR 0031). +# A browser client requires an exact Origin; leave empty for non-browser +# clients such as Codex. Never use `*` and never include a path. +MCP_PORT=18001 +MCP_REQUIRED_SCOPES= +MCP_ALLOWED_ORIGINS= +# The server counts actual streamed bytes before OAuth or SDK JSON parsing. +# Valid range: 8192..1048576; default: 65536 bytes (ADR 0036). +MCP_MAX_REQUEST_BYTES=65536 + # Optional. Empty = every LLM/vision channel is unavailable (Null client, # dropped and renormalized -- never a placeholder score). Point these at a # running contextual-orchestrator to turn the channels on. ORCHESTRATOR_BASE_URL= ORCHESTRATOR_API_KEY= -VISION_MODEL= +VISION_MODEL= \ No newline at end of file From 2c82e87fbda4e0cdceb438ad35c223715a6e04a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:00:36 -0700 Subject: [PATCH 13/25] ops: pass the MCP request-byte limit to the resource server --- docker-compose.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index d3c75f23d..1c20477d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -135,6 +135,7 @@ services: MCP_REQUIRED_SCOPES: ${MCP_REQUIRED_SCOPES:-} MCP_ALLOWED_HOSTS: localhost:${MCP_PORT:-18001},127.0.0.1:${MCP_PORT:-18001},mcp:8001 MCP_ALLOWED_ORIGINS: ${MCP_ALLOWED_ORIGINS:-} + MCP_MAX_REQUEST_BYTES: ${MCP_MAX_REQUEST_BYTES:-65536} ORCHESTRATOR_BASE_URL: ${ORCHESTRATOR_BASE_URL:-} ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-} VISION_MODEL: ${VISION_MODEL:-} @@ -160,4 +161,4 @@ services: volumes: postgres_data: - valkey_data: + valkey_data: \ No newline at end of file From 79033034835531e0737af7ecc29a483a1bf11510 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:01:50 -0700 Subject: [PATCH 14/25] docs: specify exact MCP browser and byte admission --- docs/integrations/MCP.md | 94 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 7 deletions(-) diff --git a/docs/integrations/MCP.md b/docs/integrations/MCP.md index 03e0ed968..6229a863f 100644 --- a/docs/integrations/MCP.md +++ b/docs/integrations/MCP.md @@ -83,8 +83,9 @@ credentials. MCP_RESOURCE_URL=https://lineage.example.com/mcp MCP_AUDIENCE=https://lineage.example.com/mcp MCP_ALLOWED_HOSTS=lineage.example.com -MCP_ALLOWED_ORIGINS= +MCP_ALLOWED_ORIGINS=https://buyer.example.com MCP_REQUIRED_SCOPES=lineageweave:ask +MCP_MAX_REQUEST_BYTES=65536 ``` The identity provider must issue access tokens whose `aud` includes the exact @@ -96,6 +97,80 @@ DNS-rebinding protection is enabled. Do not disable it to make a deployment work; add only the real public hostname and, for browser MCP clients, exact allowed origins. +## Browser admission boundary + +A browser sends an unauthenticated CORS preflight before a cross-origin POST +that uses `Authorization`, JSON, and MCP headers. LineageWeave handles that +preflight **after exact Host/Origin transport validation and before OAuth**. +The preflight never verifies a token, resolves an account, opens Global Ask, or +consumes a tool invocation quota. + +The browser contract is deliberately narrow: + +```text +Allowed origins: exact MCP_ALLOWED_ORIGINS entries only +Allowed methods: GET, POST, DELETE +Allowed request headers: + Accept + Authorization + Content-Type + Last-Event-ID + MCP-Protocol-Version + Mcp-Session-Id +Exposed response headers: + MCP-Protocol-Version + Mcp-Session-Id +Credentials/cookies: disabled +Preflight cache: 600 seconds +``` + +LineageWeave never reflects an arbitrary Origin and never accepts `*`. Prefix, +suffix, `null`, and unrelated Origins fail with HTTP `403`. Origin-sensitive +responses include `Vary: Origin`. Non-browser clients may omit `Origin`; they +continue directly to the normal OAuth challenge or authenticated MCP request. + +Example preflight: + +```http +OPTIONS /mcp HTTP/1.1 +Host: lineage.example.com +Origin: https://buyer.example.com +Access-Control-Request-Method: POST +Access-Control-Request-Headers: authorization, content-type, mcp-protocol-version, mcp-session-id +``` + +## Request-byte admission + +For every MCP POST, the resource server validates and counts the request body +before OAuth or MCP SDK JSON decoding. The default maximum is 65,536 bytes. +`MCP_MAX_REQUEST_BYTES` may be set from 8,192 through 1,048,576 bytes; a value +outside that range or a malformed integer prevents process startup. + +`Content-Length` enables an early rejection but is not trusted as the only +boundary. Bodies without it remain supported and are counted chunk by chunk. +The endpoint rejects: + +- a negative, nondecimal, non-ASCII, or duplicate `Content-Length`; +- `Content-Length` together with `Transfer-Encoding`; +- a declared length larger than the configured maximum; +- an actual streamed body larger than the maximum; +- declared and received byte counts that differ; +- a disconnect or malformed ASGI body message during admission. + +An admitted body is replayed byte-for-byte once to the MCP SDK. Errors are +bounded JSON, do not echo request content, and include `Cache-Control: no-store`: + +| HTTP | `error_code` | Operator/client action | +|---:|---|---| +| 400 | `mcp_invalid_content_length` | Remove ambiguous or malformed framing headers. | +| 400 | `mcp_content_length_mismatch` | Correct the sender or intermediary framing. | +| 400 | `mcp_request_disconnected` | Retry only after the transport is stable. | +| 400 | `mcp_invalid_request_body` | Send a valid ASGI/HTTP request body. | +| 413 | `mcp_request_too_large` | Reduce the MCP request; do not raise the limit without capacity review. | + +A reverse proxy must still implement HTTP framing correctly. Application-level +admission is defense in depth and does not make an unsafe intermediary safe. + External verification additionally requires all three service settings: ```text @@ -144,11 +219,16 @@ docker compose up --build postgres keycloak mcp The default endpoint is `http://localhost:18001/mcp`. The demo realm adds that exact audience to access tokens issued by `lineageweave-frontend`. A different host or port requires a corresponding IdP audience and environment change; do -not accept the REST frontend audience as a substitute. +not accept the REST frontend audience as a substitute. Compose passes +`MCP_MAX_REQUEST_BYTES` into the dedicated MCP process rather than the product +REST API. ## Failure behavior - untrusted Host: HTTP `421` before authentication +- invalid present Origin: HTTP `403` before authentication +- invalid browser preflight: HTTP `400` or `403`, no bearer challenge +- oversized or ambiguously framed POST: HTTP `400` or `413` before authentication - no bearer or invalid bearer: HTTP `401` - valid bearer without a required OAuth scope: HTTP `403` - unprovisioned subject or missing `post_read`: tool error, no evidence returned @@ -162,8 +242,8 @@ not accept the REST frontend audience as a substitute. ## Operational checks A release must exercise the MCP SDK client against the in-process server, assert -tool annotations and structured output, verify Host and unauthenticated HTTP -rejection, and run the same auth, ABAC, source-boundary, citation, -contextual-orchestrator mode, explicit-consent, untrusted-input, URL-safety, and -external-evidence regressions in the normal test suite. `uv.lock` remains -authoritative for the MCP SDK version. +tool annotations and structured output, verify pre-auth Host, Origin, CORS, +request-byte, and unauthenticated HTTP behavior, and run the same auth, ABAC, +source-boundary, citation, contextual-orchestrator mode, explicit-consent, +untrusted-input, URL-safety, and external-evidence regressions in the normal +test suite. `uv.lock` remains authoritative for the MCP SDK version. \ No newline at end of file From 95c873d2a14aedce8976dbac63379388379fbe49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:02:29 -0700 Subject: [PATCH 15/25] docs: trace MCP browser admission to transport standards --- docs/doctoring/MCP_REFERENCES.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/MCP_REFERENCES.md b/docs/doctoring/MCP_REFERENCES.md index bf9d0e944..6c6ed6316 100644 --- a/docs/doctoring/MCP_REFERENCES.md +++ b/docs/doctoring/MCP_REFERENCES.md @@ -1,11 +1,13 @@ -# MCP and OAuth references +# MCP, OAuth, CORS, and HTTP-framing references ## Standards and research traceability | External source | LineageWeave decision | Evidence | |---|---|---| -| MCP Streamable HTTP transport | Dedicated `/mcp` ASGI resource server | `backend/app/mcp_server.py`; MCP client tests | +| MCP Streamable HTTP transport | Dedicated `/mcp` ASGI resource server; validate every present Origin before authentication | `backend/app/mcp_server.py`; Host/Origin tests | | MCP Authorization | OAuth protected-resource metadata and bearer validation | `AuthSettings`; unauthenticated HTTP test | +| WHATWG Fetch CORS protocol | Exact configured browser Origins; bounded method/header surface; `Vary: Origin`; preflight before OAuth | `CORSMiddleware`; `tests/test_mcp_cors_preflight.py` | +| RFC 9112 HTTP/1.1 message framing | Reject ambiguous `Content-Length`, `Content-Length` plus `Transfer-Encoding`, mismatches, and over-limit streams before parsing | `backend/app/mcp_admission.py`; request-admission tests | | RFC 8707 resource indicators | Exact `MCP_AUDIENCE` validation | `KeycloakMcpTokenVerifier`; wrong-audience regression | | RFC 9728 protected-resource metadata | SDK-generated resource metadata | HTTP `WWW-Authenticate` regression | | Codex MCP configuration | URL plus bearer-token environment variable; optional OAuth login | `docs/integrations/MCP.md` | @@ -21,8 +23,18 @@ require at least one valid cited external HTTP(S) evidence URL; otherwise the result is `insufficient_evidence`. This mirrors FEVER's core distinction between a claim label and the evidence required to justify Supported/Refuted judgments. +Browser admission is also separate from product authorization. Exact Host and +Origin validation runs first; bounded request-body admission runs before OAuth +and SDK JSON parsing; CORS preflight runs without a bearer token; then the +existing OAuth, database RBAC, and per-source ABAC controls run. An HTTP framing +rejection grants no identity and consumes no Global Ask invocation. + ## APA 7th references +Fielding, R. T., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP/1.1* +(RFC 9112). Internet Engineering Task Force. +https://doi.org/10.17487/RFC9112 + Jones, M., Bradley, J., & Sakimura, N. (2020). *Resource indicators for OAuth 2.0* (RFC 8707). Internet Engineering Task Force. https://doi.org/10.17487/RFC8707 @@ -35,6 +47,9 @@ in Neural Information Processing Systems, 33*, 9459–9474. Model Context Protocol. (2026). *Authorization*. Linux Foundation. https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization +Model Context Protocol. (2026). *Transports: Streamable HTTP*. Linux Foundation. +https://modelcontextprotocol.io/specification/2025-11-25/basic/transports + OpenAI. (2026). *Model Context Protocol*. OpenAI Developers. https://developers.openai.com/codex/mcp/ @@ -48,3 +63,6 @@ the 2018 Conference of the North American Chapter of the Association for Computational Linguistics: Human Language Technologies, Volume 1 (Long Papers)* (pp. 809–819). Association for Computational Linguistics. https://doi.org/10.18653/v1/N18-1074 + +WHATWG. (2026). *Fetch* (Living Standard). +https://fetch.spec.whatwg.org/ \ No newline at end of file From 53bfd6351e85c50cefaeb809a8ca1540ab5bd6ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:02:47 -0700 Subject: [PATCH 16/25] docs: record MCP browser admission hardening --- CHANGELOG.d/2.13.1-mcp-browser-admission.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 CHANGELOG.d/2.13.1-mcp-browser-admission.md diff --git a/CHANGELOG.d/2.13.1-mcp-browser-admission.md b/CHANGELOG.d/2.13.1-mcp-browser-admission.md new file mode 100644 index 000000000..ff11bb357 --- /dev/null +++ b/CHANGELOG.d/2.13.1-mcp-browser-admission.md @@ -0,0 +1,20 @@ +# 2.13.1 — MCP browser and request-byte admission + +## Fixed + +- Browser MCP clients can complete an exact-Origin CORS preflight before OAuth + while wildcard, prefix, suffix, `null`, and unrelated Origins fail closed. +- Origin-sensitive MCP responses vary by Origin and expose only the protocol + and session headers required by Streamable HTTP clients. +- MCP POST bodies are bounded by actual streamed bytes before OAuth and SDK JSON + decoding. Ambiguous framing, declared/actual mismatches, malformed body + streams, and over-limit requests return stable no-store errors without + echoing request content. +- Non-browser MCP clients may continue to omit `Origin`. + +## Operations + +- Added `MCP_MAX_REQUEST_BYTES` with a 65,536-byte default and an explicit + 8,192–1,048,576-byte startup-validated range. +- Docker Compose, `.env.example`, integration guidance, ADR 0036, and MCP + standards traceability now share the same browser and body-admission contract. \ No newline at end of file From 34f1d5ea7c6e6ca40b994579861d6ad056c328fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:03:11 -0700 Subject: [PATCH 17/25] test(mcp): bind deployment and documentation admission contracts --- tests/test_mcp_browser_admission_contract.py | 60 ++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/test_mcp_browser_admission_contract.py diff --git a/tests/test_mcp_browser_admission_contract.py b/tests/test_mcp_browser_admission_contract.py new file mode 100644 index 000000000..eeadd839d --- /dev/null +++ b/tests/test_mcp_browser_admission_contract.py @@ -0,0 +1,60 @@ +"""Repository contracts for MCP browser and request-byte admission.""" + +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _read(path: str) -> str: + """Read one repository contract as UTF-8 text.""" + return (ROOT / path).read_text(encoding="utf-8") + + +def test_environment_example_documents_the_bounded_browser_surface() -> None: + """Operators can discover exact Origin and request-byte settings.""" + env = _read(".env.example") + assert "MCP_ALLOWED_ORIGINS=" in env + assert "MCP_MAX_REQUEST_BYTES=65536" in env + assert "8192..1048576" in env + assert "Never use `*`" in env + + +def test_compose_passes_the_request_limit_only_to_the_mcp_service() -> None: + """The dedicated MCP process receives the admission limit.""" + compose = _read("docker-compose.yml") + assert compose.count("MCP_MAX_REQUEST_BYTES:") == 1 + assert "MCP_MAX_REQUEST_BYTES: ${MCP_MAX_REQUEST_BYTES:-65536}" in compose + mcp_section = compose.split("\n mcp:\n", 1)[1].split("\n frontend:\n", 1)[0] + assert "MCP_ALLOWED_ORIGINS:" in mcp_section + assert "MCP_MAX_REQUEST_BYTES:" in mcp_section + + +def test_integration_guide_names_every_stable_admission_error() -> None: + """Clients receive an actionable next step for every ingress rejection.""" + guide = _read("docs/integrations/MCP.md") + for error_code in ( + "mcp_invalid_content_length", + "mcp_content_length_mismatch", + "mcp_request_disconnected", + "mcp_invalid_request_body", + "mcp_request_too_large", + ): + assert error_code in guide + assert "Vary: Origin" in guide + assert "Mcp-Session-Id" in guide + assert "Non-browser clients may omit `Origin`" in guide + + +def test_architecture_and_doctoring_trace_the_same_boundary() -> None: + """The accepted decision and standards register are both present.""" + adr = _read("docs/adr/0036-mcp-browser-request-admission.md") + references = _read("docs/doctoring/MCP_REFERENCES.md") + changelog = _read("CHANGELOG.d/2.13.1-mcp-browser-admission.md") + assert "Host/Origin transport validation" in adr + assert "bounded POST body admission" in adr + assert "RFC 9112" in references + assert "WHATWG Fetch CORS protocol" in references + assert "MCP_MAX_REQUEST_BYTES" in changelog From 129150530984d065595b8f061241061002bcc946 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:21:30 -0700 Subject: [PATCH 18/25] test(mcp): expose OAuth metadata to browser clients --- tests/test_mcp_cors_preflight.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_mcp_cors_preflight.py b/tests/test_mcp_cors_preflight.py index ea77c1f76..a3e3946df 100644 --- a/tests/test_mcp_cors_preflight.py +++ b/tests/test_mcp_cors_preflight.py @@ -96,7 +96,7 @@ def test_allowed_exact_origin_preflight_finishes_before_oauth() -> None: def test_allowed_origin_post_reaches_oauth_with_cors_response_contract() -> None: - """An allowed browser Origin receives CORS metadata on the OAuth challenge.""" + """An allowed browser Origin can read the OAuth discovery challenge.""" app, pool = _app() with TestClient(app) as client: response = client.post( @@ -113,6 +113,7 @@ def test_allowed_origin_post_reaches_oauth_with_cors_response_contract() -> None exposed = response.headers["access-control-expose-headers"].casefold() assert "mcp-session-id" in exposed assert "mcp-protocol-version" in exposed + assert "www-authenticate" in exposed assert "resource_metadata" in response.headers["www-authenticate"] assert pool.closed is True From e1d2bd737c732c957af955d3a90230e58debf3d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:22:52 -0700 Subject: [PATCH 19/25] fix(mcp): expose the OAuth challenge to browser clients --- backend/app/mcp_server.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 5f84c8ce7..5d89a10f8 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -258,7 +258,11 @@ def build_mcp_http_app( "MCP-Protocol-Version", "Mcp-Session-Id", ], - expose_headers=["MCP-Protocol-Version", "Mcp-Session-Id"], + expose_headers=[ + "MCP-Protocol-Version", + "Mcp-Session-Id", + "WWW-Authenticate", + ], allow_credentials=False, max_age=600, ) @@ -271,4 +275,4 @@ def build_mcp_http_app( _settings = load_settings() mcp = build_mcp_server(_settings) -app = build_mcp_http_app(mcp, _settings) \ No newline at end of file +app = build_mcp_http_app(mcp, _settings) From aa0307e8b82b0aeee73810b13e6733c8a09a2bda Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:24:12 -0700 Subject: [PATCH 20/25] test(mcp): reject unsafe configured browser origins --- tests/test_mcp_cors_preflight.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/test_mcp_cors_preflight.py b/tests/test_mcp_cors_preflight.py index a3e3946df..8c45dfb17 100644 --- a/tests/test_mcp_cors_preflight.py +++ b/tests/test_mcp_cors_preflight.py @@ -2,8 +2,9 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace +import pytest from starlette.testclient import TestClient from backend.app import mcp_server @@ -70,6 +71,26 @@ def _initialize_request() -> dict[str, object]: } +@pytest.mark.parametrize( + "origin", + [ + "*", + "null", + "ftp://buyer.example", + "https://user@buyer.example", + "https://buyer.example/path", + "https://buyer.example?query=1", + "https://buyer.example#fragment", + ], +) +def test_unsafe_configured_browser_origin_prevents_startup(origin: str) -> None: + """The operator cannot turn an exact-Origin contract into reflection/wildcard CORS.""" + cfg = replace(_settings(), mcp_allowed_origins=[origin]) + server = mcp_server.build_mcp_server(cfg) + with pytest.raises(ValueError, match="MCP_ALLOWED_ORIGINS"): + mcp_server.build_mcp_http_app(server, cfg) + + def test_allowed_exact_origin_preflight_finishes_before_oauth() -> None: """A browser can preflight the authenticated MCP request contract.""" app, pool = _app() From d0df01d9a1f06d0d6c131e53243598caa6957016 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:25:38 -0700 Subject: [PATCH 21/25] fix(mcp): validate exact browser origin configuration --- backend/app/mcp_server.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 5d89a10f8..80511c6a3 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -7,6 +7,7 @@ from contextlib import asynccontextmanager from dataclasses import asdict, dataclass from typing import Any +from urllib.parse import urlsplit from mcp.server import MCPServer from mcp.server.auth.middleware.auth_context import get_access_token @@ -103,6 +104,27 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: AccessTokenProvider = Callable[[], AccessToken | None] +def _validate_mcp_allowed_origins(origins: list[str]) -> None: + """Require browser allowlist entries to be exact HTTP(S) origins.""" + for origin in origins: + parsed = urlsplit(origin) + invalid = ( + origin in {"*", "null"} + or parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.path != "" + or parsed.query != "" + or parsed.fragment != "" + ) + if invalid: + raise ValueError( + "MCP_ALLOWED_ORIGINS entries must be exact HTTP(S) origins " + "without wildcard, credentials, path, query, or fragment" + ) + + def _chat_client(settings: Settings) -> PostChatClient: """Build the existing contextual-orchestrator chat channel or its null client.""" if not (settings.orchestrator_base_url and settings.orchestrator_api_key): @@ -240,6 +262,7 @@ def build_mcp_http_app( settings: Settings, ) -> ASGIApp: """Build exact-origin, byte-bounded Streamable HTTP outside OAuth.""" + _validate_mcp_allowed_origins(settings.mcp_allowed_origins) transport_security = TransportSecuritySettings( enable_dns_rebinding_protection=True, allowed_hosts=settings.mcp_allowed_hosts, From b80729a09fc7f6488bc81ca7f293f24779fe89f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:27:41 -0700 Subject: [PATCH 22/25] docs: record browser-readable OAuth discovery --- CHANGELOG.d/2.13.1-mcp-browser-admission.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.d/2.13.1-mcp-browser-admission.md b/CHANGELOG.d/2.13.1-mcp-browser-admission.md index ff11bb357..1d2dd5696 100644 --- a/CHANGELOG.d/2.13.1-mcp-browser-admission.md +++ b/CHANGELOG.d/2.13.1-mcp-browser-admission.md @@ -4,8 +4,11 @@ - Browser MCP clients can complete an exact-Origin CORS preflight before OAuth while wildcard, prefix, suffix, `null`, and unrelated Origins fail closed. -- Origin-sensitive MCP responses vary by Origin and expose only the protocol - and session headers required by Streamable HTTP clients. +- Unsafe configured Origins now prevent startup instead of weakening the exact + allowlist into wildcard, credential-bearing, or path-bearing CORS. +- Origin-sensitive MCP responses vary by Origin and expose only the protocol, + session, and `WWW-Authenticate` headers needed for Streamable HTTP and OAuth + protected-resource discovery. - MCP POST bodies are bounded by actual streamed bytes before OAuth and SDK JSON decoding. Ambiguous framing, declared/actual mismatches, malformed body streams, and over-limit requests return stable no-store errors without @@ -17,4 +20,4 @@ - Added `MCP_MAX_REQUEST_BYTES` with a 65,536-byte default and an explicit 8,192–1,048,576-byte startup-validated range. - Docker Compose, `.env.example`, integration guidance, ADR 0036, and MCP - standards traceability now share the same browser and body-admission contract. \ No newline at end of file + standards traceability now share the same browser and body-admission contract. From 1393aca378969564f9876d5f85c110ec024b3a8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:28:38 -0700 Subject: [PATCH 23/25] docs: include browser OAuth discovery in admission ADR --- .../adr/0036-mcp-browser-request-admission.md | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/adr/0036-mcp-browser-request-admission.md b/docs/adr/0036-mcp-browser-request-admission.md index 8236f16b3..58413c049 100644 --- a/docs/adr/0036-mcp-browser-request-admission.md +++ b/docs/adr/0036-mcp-browser-request-admission.md @@ -16,10 +16,12 @@ requirements that a non-browser MCP client does not: checked only after SDK JSON parsing is not a resource boundary. If preflight reaches OAuth first, the browser cannot complete the request even -when its exact Origin is allowed. If the server relies only on a declared -length, a chunked or otherwise streamed body can allocate and decode more data -than the intended MCP request envelope. Ambiguous `Content-Length` and -`Transfer-Encoding` combinations also create inconsistent framing boundaries. +when its exact Origin is allowed. If the OAuth `WWW-Authenticate` challenge is +not exposed, browser JavaScript also cannot read the protected-resource +metadata needed for discovery. If the server relies only on a declared length, +a chunked or otherwise streamed body can allocate and decode more data than the +intended MCP request envelope. Ambiguous `Content-Length` and +`Transfer-Encoding` combinations create inconsistent framing boundaries. The MCP Streamable HTTP specification requires Origin validation on incoming connections to prevent DNS rebinding. RFC 9112 defines request framing and @@ -34,12 +36,16 @@ for browser access. OAuth, database access, MCP parsing, or a tool invocation. 2. Handle an exact allowed-Origin CORS preflight outside OAuth. Never use `*`, suffix matching, prefix matching, credentials, or Origin reflection. + Configured Origins must be exact HTTP(S) origins without credentials, path, + query, or fragment; an unsafe entry prevents process startup. 3. Limit the browser contract to the Streamable HTTP methods used by the SDK: `GET`, `POST`, and `DELETE`. Permit only the request headers needed by MCP and OAuth: `Accept`, `Authorization`, `Content-Type`, `Last-Event-ID`, `MCP-Protocol-Version`, and `Mcp-Session-Id`. -4. Expose only `MCP-Protocol-Version` and `Mcp-Session-Id` to browser clients. - Origin-sensitive responses include `Vary: Origin`. +4. Expose only `MCP-Protocol-Version`, `Mcp-Session-Id`, and + `WWW-Authenticate` to browser clients. The first two carry the MCP transport + contract; the last lets an allowed client read OAuth protected-resource + discovery. Origin-sensitive responses include `Vary: Origin`. 5. Preserve non-browser clients: a request without `Origin` remains valid and reaches the existing OAuth boundary. 6. Place a pure-ASGI body admission wrapper after Host/Origin validation and @@ -79,8 +85,8 @@ Host/Origin transport validation ## Consequences -- Browser MCP clients can complete preflight without weakening OAuth or exact - Origin validation. +- Browser MCP clients can complete preflight and read the OAuth discovery + challenge without weakening exact-Origin validation. - Fixed-length and streamed requests share one byte envelope before expensive parsing or authentication work. - A valid request is buffered once at the MCP ingress. The configured upper @@ -97,6 +103,8 @@ Host/Origin transport validation unusable because preflight has no bearer token. - **Wildcard CORS:** permits arbitrary websites to address the protected MCP resource and defeats the exact-Origin contract. +- **Document-only exact-Origin rules:** leave an operator able to configure a + wildcard or non-origin URL and silently weaken the runtime boundary. - **Trust `Content-Length` only:** does not bound a streamed body and cannot defend against header/body disagreement. - **Require `Content-Length` on every request:** unnecessarily rejects valid From 3db763c0e9570fe7b6abc75d2193122e08a6e2eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:30:17 -0700 Subject: [PATCH 24/25] docs: expose browser OAuth discovery and exact origin startup rules --- docs/integrations/MCP.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/integrations/MCP.md b/docs/integrations/MCP.md index 6229a863f..388f5fcc8 100644 --- a/docs/integrations/MCP.md +++ b/docs/integrations/MCP.md @@ -94,8 +94,10 @@ RBAC is mandatory regardless; production deployments should provision and require `lineageweave:ask`. DNS-rebinding protection is enabled. Do not disable it to make a deployment -work; add only the real public hostname and, for browser MCP clients, exact -allowed origins. +work; add only the real public hostname and exact browser origins. Each +`MCP_ALLOWED_ORIGINS` entry must be an HTTP(S) origin with no wildcard, +credentials, path, query, or fragment. `*`, `null`, malformed entries, and +non-origin URLs prevent process startup. ## Browser admission boundary @@ -120,10 +122,13 @@ Allowed request headers: Exposed response headers: MCP-Protocol-Version Mcp-Session-Id + WWW-Authenticate Credentials/cookies: disabled Preflight cache: 600 seconds ``` +`WWW-Authenticate` is exposed so an allowed browser client can read the OAuth +protected-resource metadata challenge. It does not expose a credential. LineageWeave never reflects an arbitrary Origin and never accepts `*`. Prefix, suffix, `null`, and unrelated Origins fail with HTTP `403`. Origin-sensitive responses include `Vary: Origin`. Non-browser clients may omit `Origin`; they @@ -225,11 +230,12 @@ REST API. ## Failure behavior +- unsafe configured browser Origin: process startup fails - untrusted Host: HTTP `421` before authentication - invalid present Origin: HTTP `403` before authentication - invalid browser preflight: HTTP `400` or `403`, no bearer challenge - oversized or ambiguously framed POST: HTTP `400` or `413` before authentication -- no bearer or invalid bearer: HTTP `401` +- no bearer or invalid bearer: HTTP `401`, with browser-readable OAuth metadata for an allowed Origin - valid bearer without a required OAuth scope: HTTP `403` - unprovisioned subject or missing `post_read`: tool error, no evidence returned - no matching authorized evidence: tool error, no unrelated recent-post fallback @@ -243,7 +249,8 @@ REST API. A release must exercise the MCP SDK client against the in-process server, assert tool annotations and structured output, verify pre-auth Host, Origin, CORS, -request-byte, and unauthenticated HTTP behavior, and run the same auth, ABAC, -source-boundary, citation, contextual-orchestrator mode, explicit-consent, -untrusted-input, URL-safety, and external-evidence regressions in the normal -test suite. `uv.lock` remains authoritative for the MCP SDK version. \ No newline at end of file +request-byte, OAuth-discovery, and unauthenticated HTTP behavior, and run the +same auth, ABAC, source-boundary, citation, contextual-orchestrator mode, +explicit-consent, untrusted-input, URL-safety, and external-evidence regressions +in the normal test suite. `uv.lock` remains authoritative for the MCP SDK +version. From 5b139f36eb2d2d8da153cc8c0cfd34e4fe06fd12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:31:04 -0700 Subject: [PATCH 25/25] test(mcp): bind browser OAuth discovery documentation --- tests/test_mcp_browser_admission_contract.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_mcp_browser_admission_contract.py b/tests/test_mcp_browser_admission_contract.py index eeadd839d..fce93583a 100644 --- a/tests/test_mcp_browser_admission_contract.py +++ b/tests/test_mcp_browser_admission_contract.py @@ -45,6 +45,9 @@ def test_integration_guide_names_every_stable_admission_error() -> None: assert error_code in guide assert "Vary: Origin" in guide assert "Mcp-Session-Id" in guide + assert "WWW-Authenticate" in guide + assert "browser-readable OAuth metadata" in guide + assert "prevent process startup" in guide assert "Non-browser clients may omit `Origin`" in guide @@ -55,6 +58,7 @@ def test_architecture_and_doctoring_trace_the_same_boundary() -> None: changelog = _read("CHANGELOG.d/2.13.1-mcp-browser-admission.md") assert "Host/Origin transport validation" in adr assert "bounded POST body admission" in adr + assert "WWW-Authenticate" in adr assert "RFC 9112" in references assert "WHATWG Fetch CORS protocol" in references assert "MCP_MAX_REQUEST_BYTES" in changelog