Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
202094c
test(red): require exact-origin MCP browser preflight
seonghobae Aug 20, 2026
b339880
test(red): bound MCP request bytes before parsing
seonghobae Aug 20, 2026
54fa2a2
fix(mcp): bound request bytes before SDK parsing
seonghobae Aug 20, 2026
56625df
feat(mcp): configure a bounded request admission limit
seonghobae Aug 20, 2026
c0cf0bb
fix(mcp): admit exact-origin bounded browser requests
seonghobae Aug 20, 2026
0914883
fix(mcp): compare raw admission headers safely
seonghobae Aug 20, 2026
6463ea3
test(mcp): validate request-byte configuration
seonghobae Aug 20, 2026
0208932
test(mcp): cover every request admission branch
seonghobae Aug 20, 2026
c6d549e
test(mcp): require origin-aware browser responses
seonghobae Aug 20, 2026
6035aeb
fix(mcp): vary transport rejections by Origin
seonghobae Aug 20, 2026
5121917
docs: record MCP browser admission architecture
seonghobae Aug 20, 2026
f12dcf4
docs: expose MCP browser admission settings
seonghobae Aug 20, 2026
2c82e87
ops: pass the MCP request-byte limit to the resource server
seonghobae Aug 20, 2026
7903303
docs: specify exact MCP browser and byte admission
seonghobae Aug 20, 2026
95c873d
docs: trace MCP browser admission to transport standards
seonghobae Aug 20, 2026
53bfd63
docs: record MCP browser admission hardening
seonghobae Aug 20, 2026
34f1d5e
test(mcp): bind deployment and documentation admission contracts
seonghobae Aug 20, 2026
1291505
test(mcp): expose OAuth metadata to browser clients
seonghobae Aug 20, 2026
e1d2bd7
fix(mcp): expose the OAuth challenge to browser clients
seonghobae Aug 20, 2026
aa0307e
test(mcp): reject unsafe configured browser origins
seonghobae Aug 20, 2026
d0df01d
fix(mcp): validate exact browser origin configuration
seonghobae Aug 20, 2026
b80729a
docs: record browser-readable OAuth discovery
seonghobae Aug 20, 2026
1393aca
docs: include browser OAuth discovery in admission ADR
seonghobae Aug 20, 2026
3db763c
docs: expose browser OAuth discovery and exact origin startup rules
seonghobae Aug 20, 2026
5b139f3
test(mcp): bind browser OAuth discovery documentation
seonghobae Aug 20, 2026
16277d2
merge: restack onto agent/authenticated-mcp-global-ask
seonghobae Aug 20, 2026
d2c2a6b
Merge current authenticated MCP base into browser admission
seonghobae Aug 20, 2026
2b86ec2
merge: propagate current MCP base
seonghobae Aug 20, 2026
1175cd9
Merge remote-tracking branch 'origin/agent/authenticated-mcp-global-a…
seonghobae Aug 20, 2026
65a461d
Merge remote-tracking branch 'origin/agent/authenticated-mcp-global-a…
seonghobae Aug 20, 2026
ca84b63
merge: restack MCP browser admission on authenticated Ask
seonghobae Aug 20, 2026
510d7fc
Merge remote-tracking branch 'origin/agent/authenticated-mcp-global-a…
seonghobae Aug 20, 2026
f277ea2
Merge remote-tracking branch 'origin/agent/authenticated-mcp-global-a…
seonghobae Aug 21, 2026
8be7fbb
Merge remote-tracking branch 'origin/agent/authenticated-mcp-global-a…
seonghobae Aug 21, 2026
040aabb
chore: restack browser admission on current MCP parent
seonghobae Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ OIDC_AUDIENCE=lineageweave-api

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 0119).
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.
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.d/2.13.1-mcp-browser-admission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 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.
- 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
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 0119, and MCP
standards traceability now share the same browser and body-admission contract.
27 changes: 27 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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


def _home_dotenv_values(names: set[str]) -> dict[str, str]:
"""Read only requested runtime setting names from the user's home dotenv."""
try:
Expand Down Expand Up @@ -99,6 +117,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:
Expand Down Expand Up @@ -195,4 +216,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,
),
)
132 changes: 132 additions & 0 deletions backend/app/mcp_admission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Bound MCP request bodies before OAuth and JSON decoding."""

from __future__ import annotations

import json
from collections.abc import Sequence

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
Comment on lines +43 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Body is fully buffered even when downstream OAuth will reject it

For every POST, BoundedRequestBodyApp.__call__ reads the entire request body into a bytearray (mcp_admission.py) before delegating to CORS/OAuth. An unauthenticated POST (no/invalid bearer) still gets its full body (up to mcp_max_request_bytes, max 1 MiB) buffered before the 401 is returned. This is bounded and intentional per ADR 0119 (byte admission precedes OAuth), so it is not a resource bug, but it does mean unauthenticated callers can cause up to the configured limit of memory buffering per request before authentication.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


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.lower() == 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
Comment on lines +107 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Oversized Content-Length relies on Python 3.12 int-string digit limit

_parse_content_length (mcp_admission.py) has no explicit cap on the number of Content-Length digits. The test test_ambiguous_or_invalid_length_fails_closed feeds b"9" * 5000 and expects HTTP 400 mcp_invalid_content_length. This only works because int("9"*5000, 10) raises ValueError under CPython's default int-string conversion limit (4300 digits), which the repo satisfies via requires-python = ">=3.12". If that interpreter limit were raised via sys.set_int_max_str_digits, the 5000-digit value would instead parse to a huge int and return HTTP 413 mcp_request_too_large. Behavior is correct for the pinned runtime but implicitly depends on that interpreter setting rather than an explicit length guard.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



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})
55 changes: 53 additions & 2 deletions backend/app/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from contextlib import asynccontextmanager
from dataclasses import asdict, dataclass
from typing import Annotated, Any, Literal
from urllib.parse import urlsplit

from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import get_access_token
Expand All @@ -19,6 +20,7 @@
)
from mcp.types import CallToolResult, ImageContent, TextContent, 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

Expand All @@ -33,6 +35,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 (
Expand Down Expand Up @@ -107,6 +110,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
Comment on lines 112 to 116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Transport security validates disallowed Origins on preflight before CORS short-circuits

The disallowed-origin preflight test (test_mcp_cors_preflight.py) expects HTTP 403 with Vary: Origin and no reflection. This only holds if the SDK TransportSecurityMiddleware.validate_request in PreAuthTransportSecurityApp (mcp_server.py:102-117) rejects OPTIONS requests carrying a non-allowlisted Origin before CORSMiddleware returns a 200 preflight. The added add_vary_header("Origin") at mcp_server.py:113-114 supplies the Vary header on that rejection. Behavior depends on the pinned MCP SDK's transport-security implementation validating Origin on non-POST methods; worth confirming the SDK version in uv.lock preserves this, since a future SDK that skips Origin validation on OPTIONS would let disallowed preflights fall through to CORS as a 200 without ACAO.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

await self._app(scope, receive, send)
Expand All @@ -118,6 +123,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):
Expand Down Expand Up @@ -267,14 +293,39 @@ 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."""
_validate_mcp_allowed_origins(settings.mcp_allowed_origins)
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",
"WWW-Authenticate",
],
allow_credentials=False,
max_age=600,
)
bounded_app = BoundedRequestBodyApp(
Comment on lines +320 to +324

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Body-admission errors bypass CORS, so browsers can't read 400/413 error bodies

The middleware layering is PreAuthTransportSecurityApp → BoundedRequestBodyApp → CORSMiddleware → sdk_app (mcp_server.py). Because BoundedRequestBodyApp sits OUTSIDE CORSMiddleware, its admission error responses (_send_error in mcp_admission.py) are emitted directly through the raw send and never pass through the CORS layer. Consequently a cross-origin browser POST that hits mcp_request_too_large (413) or mcp_invalid_content_length (400) receives a response with no Access-Control-Allow-Origin/Vary: Origin header, so browser JS sees an opaque network error and cannot read the documented error_code body (MCP.md presents these codes as client-actionable). This is consistent with ADR 0119 which deliberately places bounded body admission before CORS (item 6), and the OAuth 401 discovery path is inside CORS so it remains browser-readable. Flagging for confirmation that admission-error unreadability for browser clients is acceptable.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

cors_app,
maximum_bytes=settings.mcp_max_request_bytes,
)
return PreAuthTransportSecurityApp(bounded_app, transport_security)


_settings = load_settings()
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,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:-http://orchestrator:8000}
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-${CONTEXTUAL_ORCHESTRATOR_TOKEN:-lineageweave-orchestrator-dev-only}}
SEARXNG_BASE_URL: http://searxng:8080
Expand Down
Loading
Loading