From dbd08814b1738507840c098e8c2ee917edb19094 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:03:59 -0700 Subject: [PATCH 01/17] feat(mcp): expose authenticated Global Ask tool --- backend/app/mcp_server.py | 510 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 backend/app/mcp_server.py diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py new file mode 100644 index 00000000..e8324e61 --- /dev/null +++ b/backend/app/mcp_server.py @@ -0,0 +1,510 @@ +"""Authenticated Model Context Protocol endpoint for LineageWeave Global Ask. + +This module is deliberately separate from the browser-facing FastAPI app. +It exposes one read-only MCP tool, ``global_ask``, over Streamable HTTP and +reuses LineageWeave's persisted authorization and evidence contracts instead +of forwarding the caller's bearer token to another service. + +The HTTP authorization boundary follows MCP protocol revision 2025-06-18: +resource metadata advertises the authorization server, access tokens must be +bound to this MCP resource, and browser ``Origin`` values are allow-listed. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import time +from collections import defaultdict, deque +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Any + +import asyncpg +import jwt +from fastapi import FastAPI, HTTPException, Request, Response, status +from fastapi.responses import JSONResponse +from jwt.algorithms import RSAAlgorithm + +from backend.app.auth import CurrentAccount +from backend.app.config import Settings, load_settings +from backend.app.db import create_pool +from backend.app.post_chat_ingestion import gather_global_chat_sources +from lineageweave.http_client import HttpClientError, get_json +from lineageweave.post_chat import ( + ContextualOrchestratorPostChatClient, + cited_post_evidence, + cited_post_summaries, +) + +_PROTOCOL_VERSION = "2025-06-18" +_SERVER_NAME = "lineageweave" +_SERVER_VERSION = "2.18.0" +_TOOL_NAME = "global_ask" +_LOG = logging.getLogger("lineageweave.mcp") +_JWKS_CACHE: dict[str, dict[str, Any]] = {} +_RATE_WINDOWS: dict[str, deque[float]] = defaultdict(deque) +_RATE_LOCK = asyncio.Lock() + +_GLOBAL_ASK_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "answer_text": {"type": "string"}, + "cited_post_ids": {"type": "array", "items": {"type": "string"}}, + "cited_posts": {"type": "array", "items": {"type": "object"}}, + "cited_post_evidence": {"type": "array", "items": {"type": "object"}}, + "source_post_ids": {"type": "array", "items": {"type": "string"}}, + "next_action": {"type": ["string", "null"]}, + }, + "required": [ + "answer_text", + "cited_post_ids", + "cited_posts", + "cited_post_evidence", + "source_post_ids", + "next_action", + ], + "additionalProperties": False, +} + +_GLOBAL_ASK_TOOL: dict[str, Any] = { + "name": _TOOL_NAME, + "title": "LineageWeave Global Ask", + "description": ( + "Ask a question over only the LineageWeave source posts and persisted " + "business evidence the authenticated account is authorized to read. " + "Returns source-grounded citations and never fabricates unavailable evidence." + ), + "inputSchema": { + "type": "object", + "properties": { + "question": { + "type": "string", + "minLength": 1, + "maxLength": 4000, + "description": "Question to answer from authorized LineageWeave evidence.", + } + }, + "required": ["question"], + "additionalProperties": False, + }, + "outputSchema": _GLOBAL_ASK_OUTPUT_SCHEMA, + "annotations": { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, +} + + +@dataclass(frozen=True) +class McpRuntimeSettings: + """MCP-specific settings layered on the shared LineageWeave settings.""" + + resource_uri: str + allowed_origins: frozenset[str] + requests_per_minute: int + + +def load_mcp_settings() -> McpRuntimeSettings: + """Load the canonical MCP resource and bounded transport policy.""" + resource_uri = os.environ.get( + "LINEAGEWEAVE_MCP_RESOURCE_URI", "http://localhost:18421/mcp" + ).strip() + if not resource_uri.startswith(("http://", "https://")) or "#" in resource_uri: + raise ValueError("LINEAGEWEAVE_MCP_RESOURCE_URI must be an absolute http(s) URI without a fragment") + allowed_origins = frozenset( + origin.strip() + for origin in os.environ.get("LINEAGEWEAVE_MCP_ALLOWED_ORIGINS", "").split(",") + if origin.strip() + ) + try: + requests_per_minute = int(os.environ.get("LINEAGEWEAVE_MCP_REQUESTS_PER_MINUTE", "30")) + except ValueError as exc: + raise ValueError("LINEAGEWEAVE_MCP_REQUESTS_PER_MINUTE must be an integer") from exc + if not 1 <= requests_per_minute <= 600: + raise ValueError("LINEAGEWEAVE_MCP_REQUESTS_PER_MINUTE must be between 1 and 600") + return McpRuntimeSettings( + resource_uri=resource_uri, + allowed_origins=allowed_origins, + requests_per_minute=requests_per_minute, + ) + + +def _resource_metadata_url(request: Request) -> str: + """Return the RFC 9728 metadata URL advertised in 401 challenges.""" + return str(request.base_url).rstrip("/") + "/.well-known/oauth-protected-resource/mcp" + + +def _validate_origin(request: Request, mcp_settings: McpRuntimeSettings) -> None: + """Reject browser origins not explicitly authorized for this MCP server.""" + origin = request.headers.get("origin") + if origin is None: + return + if origin not in mcp_settings.allowed_origins: + raise HTTPException(status.HTTP_403_FORBIDDEN, "MCP Origin is not allowed") + + +def _mcp_jwks(settings: Settings) -> dict[str, Any]: + """Fetch and cache the configured OIDC provider's JWKS.""" + cache_key = settings.oidc_issuer + cached = _JWKS_CACHE.get(cache_key) + if cached is not None: + return cached + try: + if settings.oidc_jwks_uri_override: + jwks_uri = settings.oidc_jwks_uri_override + else: + metadata = get_json(settings.oidc_discovery_uri, timeout=10) + jwks_uri = metadata.get("jwks_uri") + if not isinstance(jwks_uri, str) or not jwks_uri.strip(): + raise ValueError("OIDC discovery document has no jwks_uri") + cached = get_json(jwks_uri, timeout=10) + except (HttpClientError, OSError, ValueError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "could not fetch the configured OIDC signing keys", + ) from exc + if not isinstance(cached, dict) or not isinstance(cached.get("keys"), list): + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "OIDC JWKS has no key set") + _JWKS_CACHE[cache_key] = cached + return cached + + +def _mcp_signing_key(jwks: dict[str, Any], token: str): + """Require a non-empty JWT ``kid`` and an exact RSA signing-key match.""" + try: + header = jwt.get_unverified_header(token) + except jwt.PyJWTError as exc: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid access-token header") from exc + kid = header.get("kid") + if not isinstance(kid, str) or not kid.strip(): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "access token must include a non-empty kid") + for key in jwks.get("keys", []): + if not isinstance(key, dict) or key.get("kid") != kid: + continue + if key.get("kty") not in (None, "RSA") or key.get("alg") not in (None, "RS256"): + continue + try: + return RSAAlgorithm.from_jwk(json.dumps(key)) + except (KeyError, TypeError, ValueError) as exc: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "matching JWKS key is invalid") from exc + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "no JWKS key matched the access-token kid") + + +def _decode_mcp_access_token( + token: str, + settings: Settings, + mcp_settings: McpRuntimeSettings, +) -> dict[str, Any]: + """Validate signature, issuer, expiry and this MCP resource audience.""" + try: + signing_key = _mcp_signing_key(_mcp_jwks(settings), token) + claims = jwt.decode( + token, + key=signing_key, + algorithms=["RS256"], + issuer=settings.oidc_issuer, + audience=mcp_settings.resource_uri, + leeway=settings.oidc_clock_skew_seconds, + ) + except HTTPException: + raise + except jwt.PyJWTError as exc: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid MCP access token") from exc + subject = claims.get("sub") + if not isinstance(subject, str) or not subject.strip(): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "MCP access token has no subject") + return claims + + +async def _resolve_account(pool: asyncpg.Pool, subject: str) -> CurrentAccount: + """Resolve authorization from LineageWeave DB state, never token attributes.""" + async with pool.acquire() as conn: + account_row = await conn.fetchrow( + "select user_account_id, display_name, preferred_locale " + "from user_account where external_subject_id = $1", + subject, + ) + if account_row is None: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "token is valid but no LineageWeave account is provisioned for this subject", + ) + entity_rows = await conn.fetch( + "select corporate_entity_id from account_affiliation where user_account_id = $1", + account_row["user_account_id"], + ) + permission_rows = await conn.fetch( + """ + select distinct rp.permission_code + from account_role_assignment ara + join role_permission rp on rp.access_role_id = ara.access_role_id + where ara.user_account_id = $1 + """, + account_row["user_account_id"], + ) + account = CurrentAccount( + user_account_id=str(account_row["user_account_id"]), + external_subject_id=subject, + display_name=account_row["display_name"], + preferred_locale=account_row["preferred_locale"], + corporate_entity_ids=frozenset(str(row["corporate_entity_id"]) for row in entity_rows), + permission_codes=frozenset(str(row["permission_code"]) for row in permission_rows), + ) + if not account.has_permission("post_read"): + raise HTTPException(status.HTTP_403_FORBIDDEN, "account lacks the post_read permission") + return account + + +async def _authenticate(request: Request, pool: asyncpg.Pool) -> CurrentAccount: + """Authenticate one MCP HTTP request and resolve its persisted account policy.""" + authorization = request.headers.get("authorization", "") + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token.strip(): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Bearer access token required") + claims = _decode_mcp_access_token(token.strip(), load_settings(), load_mcp_settings()) + return await _resolve_account(pool, str(claims["sub"])) + + +async def _check_rate_limit(account_id: str, limit: int) -> None: + """Bound MCP tool execution per process without storing question text.""" + now = time.monotonic() + cutoff = now - 60.0 + async with _RATE_LOCK: + window = _RATE_WINDOWS[account_id] + while window and window[0] <= cutoff: + window.popleft() + if len(window) >= limit: + raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "MCP tool rate limit exceeded") + window.append(now) + + +def _can_see_post(account: CurrentAccount, post: Any) -> bool: + """Match the product ABAC rule used by the browser Global Ask flow.""" + if post["visibility_code"] == "public": + return True + return str(post["corporate_entity_id"]) in account.corporate_entity_ids + + +def _post_chat_client(settings: Settings): + """Build the same contextual-orchestrator-only chat channel as the product.""" + if not (settings.orchestrator_base_url and settings.orchestrator_api_key): + return None + return ContextualOrchestratorPostChatClient( + base_url=settings.orchestrator_base_url, + api_key=settings.orchestrator_api_key, + ) + + +async def _global_ask( + pool: asyncpg.Pool, + account: CurrentAccount, + question: str, +) -> dict[str, Any]: + """Run Global Ask against only evidence visible to ``account``.""" + normalized_question = question.strip() + if not normalized_question or len(normalized_question) > 4000: + raise ValueError("question must contain between 1 and 4000 characters") + client = _post_chat_client(load_settings()) + if client is None: + raise RuntimeError("Global Ask is unavailable because contextual-orchestrator is not configured") + async with pool.acquire() as conn: + sources = await gather_global_chat_sources( + conn, + lambda row: _can_see_post(account, row), + account.corporate_entity_ids, + question=normalized_question, + ) + if not sources: + return { + "answer_text": "", + "cited_post_ids": [], + "cited_posts": [], + "cited_post_evidence": [], + "source_post_ids": [], + "next_action": "No authorized source posts are available for this question.", + } + try: + answer = await asyncio.to_thread(client.answer, normalized_question, sources) + except (HttpClientError, KeyError, OSError, ValueError) as exc: + raise RuntimeError("contextual-orchestrator returned no complete evidence object") from exc + cited_ids = list(answer.cited_post_ids) + result = { + "answer_text": answer.answer_text, + "cited_post_ids": cited_ids, + "cited_posts": cited_post_summaries(sources, cited_ids), + "cited_post_evidence": cited_post_evidence(sources, cited_ids), + "source_post_ids": [source.post_id for source in sources], + "next_action": None, + } + _LOG.info( + "mcp_global_ask account=%s sources=%d citations=%d question_chars=%d", + account.user_account_id, + len(result["source_post_ids"]), + len(cited_ids), + len(normalized_question), + ) + return result + + +def _jsonrpc_result(request_id: Any, result: dict[str, Any]) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + +def _jsonrpc_error(request_id: Any, code: int, message: str) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}} + + +async def _dispatch_message( + message: Any, + pool: asyncpg.Pool, + account: CurrentAccount, + mcp_settings: McpRuntimeSettings, +) -> dict[str, Any] | None: + """Dispatch one non-batched JSON-RPC 2.0 MCP message.""" + if not isinstance(message, dict) or message.get("jsonrpc") != "2.0": + return _jsonrpc_error(message.get("id") if isinstance(message, dict) else None, -32600, "Invalid Request") + if isinstance(message, list): + return _jsonrpc_error(None, -32600, "JSON-RPC batching is not supported") + method = message.get("method") + request_id = message.get("id") + if request_id is None: + if method == "notifications/initialized": + return None + return None + if method == "initialize": + params = message.get("params") or {} + requested_version = params.get("protocolVersion") if isinstance(params, dict) else None + if requested_version != _PROTOCOL_VERSION: + return _jsonrpc_error(request_id, -32602, f"Unsupported protocolVersion: {requested_version!r}") + return _jsonrpc_result( + request_id, + { + "protocolVersion": _PROTOCOL_VERSION, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": _SERVER_NAME, "version": _SERVER_VERSION}, + "instructions": "Use global_ask only for source-grounded LineageWeave questions.", + }, + ) + if method == "ping": + return _jsonrpc_result(request_id, {}) + if method == "tools/list": + return _jsonrpc_result(request_id, {"tools": [_GLOBAL_ASK_TOOL]}) + if method == "tools/call": + params = message.get("params") + if not isinstance(params, dict) or params.get("name") != _TOOL_NAME: + return _jsonrpc_error(request_id, -32602, "Unknown tool") + arguments = params.get("arguments") + if not isinstance(arguments, dict) or set(arguments) != {"question"}: + return _jsonrpc_error(request_id, -32602, "global_ask requires only the question argument") + question = arguments.get("question") + if not isinstance(question, str) or not question.strip() or len(question.strip()) > 4000: + return _jsonrpc_error(request_id, -32602, "question must contain between 1 and 4000 characters") + await _check_rate_limit(account.user_account_id, mcp_settings.requests_per_minute) + try: + structured = await _global_ask(pool, account, question) + except RuntimeError as exc: + return _jsonrpc_result( + request_id, + { + "content": [{"type": "text", "text": str(exc)}], + "isError": True, + }, + ) + text = json.dumps(structured, ensure_ascii=False, separators=(",", ":")) + return _jsonrpc_result( + request_id, + { + "content": [{"type": "text", "text": text}], + "structuredContent": structured, + "isError": False, + }, + ) + return _jsonrpc_error(request_id, -32601, f"Method not found: {method}") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Create only the database pool needed by the MCP resource server.""" + app.state.pool = await create_pool(load_settings().database_url) + try: + yield + finally: + await app.state.pool.close() + + +mcp_app = FastAPI(title="LineageWeave MCP", lifespan=lifespan) + + +@mcp_app.get("/healthz") +async def healthz() -> dict[str, str]: + """Process liveness without leaking authentication or evidence state.""" + return {"status": "ok"} + + +@mcp_app.get("/.well-known/oauth-protected-resource") +@mcp_app.get("/.well-known/oauth-protected-resource/mcp") +async def protected_resource_metadata() -> dict[str, Any]: + """Publish RFC 9728 resource metadata for MCP client discovery.""" + settings = load_settings() + mcp_settings = load_mcp_settings() + return { + "resource": mcp_settings.resource_uri, + "authorization_servers": [settings.oidc_issuer], + "bearer_methods_supported": ["header"], + } + + +@mcp_app.get("/mcp") +async def mcp_get() -> Response: + """This first slice is non-streaming; GET explicitly declines SSE.""" + return Response(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, headers={"Allow": "POST"}) + + +@mcp_app.post("/mcp") +async def mcp_post(request: Request) -> Response: + """Handle one authenticated Streamable-HTTP MCP JSON-RPC message.""" + mcp_settings = load_mcp_settings() + _validate_origin(request, mcp_settings) + try: + account = await _authenticate(request, request.app.state.pool) + except HTTPException as exc: + if exc.status_code == status.HTTP_401_UNAUTHORIZED: + return JSONResponse( + {"detail": exc.detail}, + status_code=exc.status_code, + headers={ + "WWW-Authenticate": ( + 'Bearer resource_metadata="' + _resource_metadata_url(request) + '"' + ) + }, + ) + raise + protocol_header = request.headers.get("mcp-protocol-version") + if protocol_header is not None and protocol_header != _PROTOCOL_VERSION: + return JSONResponse( + {"detail": "Unsupported MCP-Protocol-Version"}, + status_code=status.HTTP_400_BAD_REQUEST, + ) + try: + message = await request.json() + except (json.JSONDecodeError, UnicodeDecodeError, ValueError): + return JSONResponse(_jsonrpc_error(None, -32700, "Parse error"), status_code=200) + if isinstance(message, list): + return JSONResponse(_jsonrpc_error(None, -32600, "JSON-RPC batching is not supported"), status_code=200) + response_message = await _dispatch_message( + message, + request.app.state.pool, + account, + mcp_settings, + ) + if response_message is None: + return Response(status_code=status.HTTP_202_ACCEPTED) + return JSONResponse(response_message) + + +app = mcp_app From 48cddcbf2f107fc02b5b680248e8815c6eb4b3c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:04:53 -0700 Subject: [PATCH 02/17] test(mcp): lock authenticated Global Ask protocol --- tests/test_mcp_server.py | 357 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 tests/test_mcp_server.py diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 00000000..b7c834d5 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +import asyncio +import base64 +import json + +import pytest +from starlette.requests import Request + +import backend.app.mcp_server as mcp +from backend.app.auth import CurrentAccount + + +def _segment(value: dict) -> str: + raw = json.dumps(value, separators=(",", ":")).encode() + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def _unsigned_token(header: dict) -> str: + return f"{_segment(header)}.{_segment({'sub': 'subject'})}.signature" + + +def _account() -> CurrentAccount: + return CurrentAccount( + user_account_id="account-1", + external_subject_id="subject-1", + display_name="Demo Analyst", + preferred_locale="ko-KR", + corporate_entity_ids=frozenset({"corp-demo"}), + permission_codes=frozenset({"post_read"}), + ) + + +def _request(origin: str | None = None) -> Request: + headers = [] if origin is None else [(b"origin", origin.encode())] + return Request( + { + "type": "http", + "http_version": "1.1", + "method": "POST", + "scheme": "https", + "path": "/mcp", + "raw_path": b"/mcp", + "query_string": b"", + "headers": headers, + "client": ("127.0.0.1", 1234), + "server": ("lineage.example", 443), + } + ) + + +def test_mcp_settings_validate_resource_and_rate(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LINEAGEWEAVE_MCP_RESOURCE_URI", "https://lineage.example/mcp") + monkeypatch.setenv("LINEAGEWEAVE_MCP_ALLOWED_ORIGINS", "https://codex.example, https://admin.example") + monkeypatch.setenv("LINEAGEWEAVE_MCP_REQUESTS_PER_MINUTE", "17") + settings = mcp.load_mcp_settings() + assert settings.resource_uri == "https://lineage.example/mcp" + assert settings.allowed_origins == frozenset({"https://codex.example", "https://admin.example"}) + assert settings.requests_per_minute == 17 + + monkeypatch.setenv("LINEAGEWEAVE_MCP_RESOURCE_URI", "file:///etc/passwd") + with pytest.raises(ValueError, match="absolute http"): + mcp.load_mcp_settings() + + monkeypatch.setenv("LINEAGEWEAVE_MCP_RESOURCE_URI", "https://lineage.example/mcp") + monkeypatch.setenv("LINEAGEWEAVE_MCP_REQUESTS_PER_MINUTE", "0") + with pytest.raises(ValueError, match="between 1 and 600"): + mcp.load_mcp_settings() + + monkeypatch.setenv("LINEAGEWEAVE_MCP_REQUESTS_PER_MINUTE", "not-a-number") + with pytest.raises(ValueError, match="must be an integer"): + mcp.load_mcp_settings() + + +def test_origin_is_fail_closed_only_when_browser_origin_is_present() -> None: + settings = mcp.McpRuntimeSettings( + resource_uri="https://lineage.example/mcp", + allowed_origins=frozenset({"https://codex.example"}), + requests_per_minute=30, + ) + mcp._validate_origin(_request(), settings) + mcp._validate_origin(_request("https://codex.example"), settings) + with pytest.raises(Exception) as error: + mcp._validate_origin(_request("https://evil.example"), settings) + assert getattr(error.value, "status_code", None) == 403 + + +def test_signing_key_requires_nonempty_exact_kid(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(mcp.RSAAlgorithm, "from_jwk", lambda value: ("key", value)) + jwks = { + "keys": [ + {"kid": "first", "kty": "RSA", "alg": "RS256", "n": "x", "e": "AQAB"}, + {"kid": "wanted", "kty": "RSA", "alg": "RS256", "n": "y", "e": "AQAB"}, + ] + } + key = mcp._mcp_signing_key(jwks, _unsigned_token({"alg": "RS256", "kid": "wanted"})) + assert key[0] == "key" + assert '"kid": "wanted"' in key[1] + + with pytest.raises(Exception) as missing: + mcp._mcp_signing_key(jwks, _unsigned_token({"alg": "RS256"})) + assert getattr(missing.value, "status_code", None) == 401 + + with pytest.raises(Exception) as unknown: + mcp._mcp_signing_key(jwks, _unsigned_token({"alg": "RS256", "kid": "unknown"})) + assert getattr(unknown.value, "status_code", None) == 401 + + +def test_access_token_decode_binds_audience(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + monkeypatch.setattr(mcp, "_mcp_jwks", lambda settings: {"keys": []}) + monkeypatch.setattr(mcp, "_mcp_signing_key", lambda jwks, token: "signing-key") + + def fake_decode(token, **kwargs): + captured.update(kwargs) + return {"sub": "subject-1"} + + monkeypatch.setattr(mcp.jwt, "decode", fake_decode) + shared = type( + "SettingsStub", + (), + {"oidc_issuer": "https://id.example", "oidc_clock_skew_seconds": 5}, + )() + runtime = mcp.McpRuntimeSettings( + resource_uri="https://lineage.example/mcp", + allowed_origins=frozenset(), + requests_per_minute=30, + ) + claims = mcp._decode_mcp_access_token("token", shared, runtime) + assert claims["sub"] == "subject-1" + assert captured["issuer"] == "https://id.example" + assert captured["audience"] == "https://lineage.example/mcp" + assert captured["algorithms"] == ["RS256"] + + +def test_access_token_requires_subject(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(mcp, "_mcp_jwks", lambda settings: {"keys": []}) + monkeypatch.setattr(mcp, "_mcp_signing_key", lambda jwks, token: "signing-key") + monkeypatch.setattr(mcp.jwt, "decode", lambda *args, **kwargs: {}) + shared = type( + "SettingsStub", + (), + {"oidc_issuer": "https://id.example", "oidc_clock_skew_seconds": 5}, + )() + runtime = mcp.McpRuntimeSettings( + resource_uri="https://lineage.example/mcp", + allowed_origins=frozenset(), + requests_per_minute=30, + ) + with pytest.raises(Exception) as error: + mcp._decode_mcp_access_token("token", shared, runtime) + assert getattr(error.value, "status_code", None) == 401 + + +def test_initialize_and_tool_catalog_are_current_protocol() -> None: + runtime = mcp.McpRuntimeSettings( + resource_uri="https://lineage.example/mcp", + allowed_origins=frozenset(), + requests_per_minute=30, + ) + initialize = asyncio.run( + mcp._dispatch_message( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18"}, + }, + object(), + _account(), + runtime, + ) + ) + assert initialize["result"]["protocolVersion"] == "2025-06-18" + assert initialize["result"]["capabilities"] == {"tools": {"listChanged": False}} + + tools = asyncio.run( + mcp._dispatch_message( + {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + object(), + _account(), + runtime, + ) + ) + tool = tools["result"]["tools"][0] + assert tool["name"] == "global_ask" + assert tool["annotations"]["readOnlyHint"] is True + assert tool["annotations"]["openWorldHint"] is False + assert tool["outputSchema"] == mcp._GLOBAL_ASK_OUTPUT_SCHEMA + + +def test_initialize_rejects_other_protocol_and_initialized_notification_is_silent() -> None: + runtime = mcp.McpRuntimeSettings( + resource_uri="https://lineage.example/mcp", + allowed_origins=frozenset(), + requests_per_minute=30, + ) + rejected = asyncio.run( + mcp._dispatch_message( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-03-26"}, + }, + object(), + _account(), + runtime, + ) + ) + assert rejected["error"]["code"] == -32602 + notification = asyncio.run( + mcp._dispatch_message( + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + object(), + _account(), + runtime, + ) + ) + assert notification is None + + +def test_global_ask_tool_returns_structured_and_text_content(monkeypatch: pytest.MonkeyPatch) -> None: + runtime = mcp.McpRuntimeSettings( + resource_uri="https://lineage.example/mcp", + allowed_origins=frozenset(), + requests_per_minute=30, + ) + expected = { + "answer_text": "Grounded answer", + "cited_post_ids": ["post-1"], + "cited_posts": [{"post_id": "post-1", "post_title": "Evidence"}], + "cited_post_evidence": [{"post_id": "post-1", "facts": ["source record"]}], + "source_post_ids": ["post-1"], + "next_action": None, + } + + async def fake_ask(pool, account, question): + assert account.user_account_id == "account-1" + assert question == "What changed?" + return expected + + async def no_limit(account_id, limit): + assert account_id == "account-1" + assert limit == 30 + + monkeypatch.setattr(mcp, "_global_ask", fake_ask) + monkeypatch.setattr(mcp, "_check_rate_limit", no_limit) + response = asyncio.run( + mcp._dispatch_message( + { + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": {"name": "global_ask", "arguments": {"question": "What changed?"}}, + }, + object(), + _account(), + runtime, + ) + ) + result = response["result"] + assert result["structuredContent"] == expected + assert json.loads(result["content"][0]["text"]) == expected + assert result["isError"] is False + + +def test_tool_validation_and_execution_failure_are_distinct(monkeypatch: pytest.MonkeyPatch) -> None: + runtime = mcp.McpRuntimeSettings( + resource_uri="https://lineage.example/mcp", + allowed_origins=frozenset(), + requests_per_minute=30, + ) + unknown = asyncio.run( + mcp._dispatch_message( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "write_everything", "arguments": {}}, + }, + object(), + _account(), + runtime, + ) + ) + assert unknown["error"]["code"] == -32602 + + invalid = asyncio.run( + mcp._dispatch_message( + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "global_ask", "arguments": {"question": " ", "extra": 1}}, + }, + object(), + _account(), + runtime, + ) + ) + assert invalid["error"]["code"] == -32602 + + async def no_limit(account_id, limit): + return None + + async def unavailable(pool, account, question): + raise RuntimeError("orchestrator unavailable") + + monkeypatch.setattr(mcp, "_check_rate_limit", no_limit) + monkeypatch.setattr(mcp, "_global_ask", unavailable) + failed = asyncio.run( + mcp._dispatch_message( + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "global_ask", "arguments": {"question": "Question"}}, + }, + object(), + _account(), + runtime, + ) + ) + assert "error" not in failed + assert failed["result"]["isError"] is True + assert failed["result"]["content"][0]["text"] == "orchestrator unavailable" + + +def test_rate_limit_is_bounded(monkeypatch: pytest.MonkeyPatch) -> None: + mcp._RATE_WINDOWS.clear() + times = iter([10.0, 10.1, 10.2]) + monkeypatch.setattr(mcp.time, "monotonic", lambda: next(times)) + asyncio.run(mcp._check_rate_limit("account", 2)) + asyncio.run(mcp._check_rate_limit("account", 2)) + with pytest.raises(Exception) as error: + asyncio.run(mcp._check_rate_limit("account", 2)) + assert getattr(error.value, "status_code", None) == 429 + + +def test_invalid_jsonrpc_and_unknown_method_return_protocol_errors() -> None: + runtime = mcp.McpRuntimeSettings( + resource_uri="https://lineage.example/mcp", + allowed_origins=frozenset(), + requests_per_minute=30, + ) + invalid = asyncio.run(mcp._dispatch_message({"id": 1}, object(), _account(), runtime)) + assert invalid["error"]["code"] == -32600 + missing = asyncio.run( + mcp._dispatch_message( + {"jsonrpc": "2.0", "id": 2, "method": "resources/list"}, + object(), + _account(), + runtime, + ) + ) + assert missing["error"]["code"] == -32601 From c033704289d3a51fca02c158902e48d13aab5b31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:05:47 -0700 Subject: [PATCH 03/17] docs(mcp): record authenticated Global Ask boundary --- docs/adr/0090-authenticated-global-ask-mcp.md | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/adr/0090-authenticated-global-ask-mcp.md diff --git a/docs/adr/0090-authenticated-global-ask-mcp.md b/docs/adr/0090-authenticated-global-ask-mcp.md new file mode 100644 index 00000000..76ea1f48 --- /dev/null +++ b/docs/adr/0090-authenticated-global-ask-mcp.md @@ -0,0 +1,92 @@ +# ADR 0090: Authenticated Global Ask MCP resource server + +- Status: Accepted +- Date: 2026-08-20 + +## Context + +LineageWeave already has an authenticated buyer-facing Global Ask flow (`POST /api/ask`) that assembles a bounded evidence set only after the caller's `post_read` RBAC and per-row corporate-entity ABAC checks. The answer is produced only through `contextual-orchestrator`, and citations identify the source posts used by the answer (ADR 0039). + +External agent clients such as Codex need the same capability without receiving database credentials or a privileged service token and without bypassing the existing evidence boundary. A remote MCP server also creates a distinct OAuth protected-resource boundary: the access token must be intended for the MCP server itself, browser origins must not be able to reach a local/remote endpoint through DNS rebinding, and an inbound bearer token must not be forwarded to another API. + +The currently accumulated Buyer stack also contains two independent blockers that this ADR does not hide or override: PR #258 has an unresolved static-analysis review thread, and PR #264 has an unresolved review finding that DAG navigation can discard analysis-run cutoff context. This MCP slice is stacked on #264 and must not merge ahead of its prerequisite chain. + +## Decision + +LineageWeave exposes a **separate FastAPI MCP resource server** at `backend.app.mcp_server:app` rather than adding MCP transport behavior to the browser-facing application. + +The first MCP surface contains exactly one tool: + +```text +global_ask(question) +``` + +The tool is read-only and returns: + +- grounded answer text; +- cited post identifiers and display summaries; +- citation evidence; +- the bounded set of source post identifiers considered; +- an explicit next action when no authorized evidence exists. + +The MCP server reuses the same Global Ask source assembler and the same `ContextualOrchestratorPostChatClient`. It does **not** forward the MCP access token to contextual-orchestrator or call the browser REST endpoint with that token. + +### Protocol profile + +The initial server implements MCP protocol revision `2025-06-18` over non-streaming Streamable HTTP: + +- JSON-RPC 2.0; +- `initialize` and `notifications/initialized` lifecycle messages; +- `ping`; +- `tools/list`; +- `tools/call`; +- one POST endpoint at `/mcp`; +- GET `/mcp` returns 405 because this slice does not offer SSE; +- JSON-RPC batching is rejected; +- tool output includes both `structuredContent` and a JSON text content block; +- the `global_ask` tool advertises `readOnlyHint=true`, `destructiveHint=false`, and `openWorldHint=false`. + +### OAuth protected-resource boundary + +The MCP process is an OAuth protected resource, not an authorization server. + +It publishes RFC 9728 protected-resource metadata and advertises the configured LineageWeave/Keyverse OIDC issuer as its authorization server. The canonical resource identifier is configured by `LINEAGEWEAVE_MCP_RESOURCE_URI` and must identify the `/mcp` resource. + +Every protected request must satisfy all of the following before evidence is touched: + +1. Bearer authentication is present. +2. JWT `kid` is non-empty and exactly selects one acceptable RSA/RS256 JWKS key; no first-key fallback is allowed. +3. Signature, issuer, expiry/not-before semantics, and configured clock skew validate. +4. JWT audience includes the exact MCP resource URI. +5. `sub` resolves to a provisioned `user_account`. +6. roles stored in LineageWeave grant `post_read`. +7. corporate-entity affiliations are loaded from LineageWeave, not trusted from arbitrary token claims. +8. each source post still passes the product's per-row ABAC predicate before normalization or LLM context assembly. + +A 401 challenge includes the RFC 9728 `resource_metadata` location. The MCP access token is never passed through to downstream APIs. + +### Origin and transport policy + +When an HTTP `Origin` header is present it must exactly match `LINEAGEWEAVE_MCP_ALLOWED_ORIGINS`; an unknown browser origin fails closed. Non-browser MCP clients may omit `Origin`. + +Production deployment must use an HTTPS canonical resource. Plain HTTP is reserved for loopback development only. The MCP service is independently deployable so its ingress, OAuth audience, rate policy, network policy, and telemetry can be isolated from the Buyer web application. + +### Bounds and audit + +- Questions are limited to 4,000 characters. +- The existing Global Ask bounded-source contract remains in force. +- Tool calls are rate-limited per authenticated account. The first implementation is process-local; a later horizontally scaled slice must replace this with a shared counter before multiple MCP replicas are enabled. +- Audit logging records only opaque account id, question length, number of considered sources, and citation count. Question text, answer text, bearer token, and raw post bodies are not emitted to the MCP audit log. + +## Consequences + +- Codex and other conforming MCP clients can authenticate to a dedicated LineageWeave resource and invoke Global Ask without obtaining database or orchestrator credentials. +- The tool cannot expand the caller's evidence visibility beyond the existing `post_read` + ABAC contract. +- A browser login token minted only for the frontend is not automatically valid for MCP: Keyverse must issue a resource-bound access token whose audience includes the configured MCP resource URI. +- No write tools, arbitrary SQL, raw graph traversal, admin actions, ticket changes, analysis-run starts, or unrestricted post-body resources are exposed in this slice. +- Horizontal MCP scaling is blocked until distributed rate limiting is implemented. +- This ADR does not cure the browser application's separate audience-validation weakness or the #264 temporal-cutoff navigation defect. Those remain prerequisite-stack work and must not be treated as satisfied by the MCP-specific verifier. + +## References + +See `docs/doctoring/MCP_REFERENCES.md` for the normative protocol and OAuth references in APA 7th format. From a79c3edf893bdcab35567813dc3e1844904588c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:06:00 -0700 Subject: [PATCH 04/17] docs(mcp): add standards traceability references --- docs/doctoring/MCP_REFERENCES.md | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/doctoring/MCP_REFERENCES.md diff --git a/docs/doctoring/MCP_REFERENCES.md b/docs/doctoring/MCP_REFERENCES.md new file mode 100644 index 00000000..92bef251 --- /dev/null +++ b/docs/doctoring/MCP_REFERENCES.md @@ -0,0 +1,34 @@ +# MCP standards references + +This bibliography supports ADR 0090 and the authenticated LineageWeave MCP resource server. Product claims must be traceable to the exact protocol revision or RFC below rather than to remembered MCP behavior. + +## Product decision crosswalk + +| Product decision | Source | +|---|---| +| MCP protocol revision, JSON-RPC lifecycle | Model Context Protocol 2025-06-18 base protocol | +| Streamable HTTP `/mcp`, POST/GET contract, Origin validation | Model Context Protocol 2025-06-18 transport specification | +| `tools/list`, `tools/call`, structured output, tool annotations | Model Context Protocol 2025-06-18 tools specification | +| MCP HTTP server as OAuth resource server | Model Context Protocol 2025-06-18 authorization specification | +| Protected-resource metadata and `WWW-Authenticate` discovery | RFC 9728 | +| Resource-bound token request/audience | RFC 8707 | +| Authorization-server discovery metadata | RFC 8414 | +| Codex may store MCP OAuth credentials in an OS keyring | OpenAI Codex operational security guidance | + +## APA 7th references + +Campbell, B., Bradley, J., & Tschofenig, H. (2020). *Resource indicators for OAuth 2.0* (RFC 8707). Internet Engineering Task Force. https://doi.org/10.17487/RFC8707 + +Jones, M. B., Hunt, P., & Parecki, A. (2025). *OAuth 2.0 protected resource metadata* (RFC 9728). Internet Engineering Task Force. https://doi.org/10.17487/RFC9728 + +Jones, M., Sakimura, N., & Bradley, J. (2018). *OAuth 2.0 authorization server metadata* (RFC 8414). Internet Engineering Task Force. https://doi.org/10.17487/RFC8414 + +Model Context Protocol. (2025, June 18). *Authorization*. https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization + +Model Context Protocol. (2025, June 18). *Base protocol overview*. https://modelcontextprotocol.io/specification/2025-06-18/basic + +Model Context Protocol. (2025, June 18). *Tools*. https://modelcontextprotocol.io/specification/2025-06-18/server/tools + +Model Context Protocol. (2025, June 18). *Transports*. https://modelcontextprotocol.io/specification/2025-06-18/basic/transports + +OpenAI. (2026). *Running Codex safely at OpenAI*. https://openai.com/index/running-codex-safely/ From 1ab32d6483d31a7991a689fc1dfd4e1cb9c7f1eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:06:17 -0700 Subject: [PATCH 05/17] docs(mcp): document resource-server settings --- .env.example | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.env.example b/.env.example index eb159466..8445bac4 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,16 @@ OIDC_CLOCK_SKEW_SECONDS=5 BACKEND_PORT=18420 +# Optional dedicated MCP resource server. The localhost URI is development +# only; production must publish the externally reachable HTTPS /mcp URI and +# configure the authorization server to issue tokens for that exact resource. +LINEAGEWEAVE_MCP_PORT=18421 +LINEAGEWEAVE_MCP_RESOURCE_URI=http://localhost:18421/mcp +# Browser Origins are denied unless explicitly listed. Non-browser MCP clients +# may omit Origin. Comma-separated exact origins; never use `*`. +LINEAGEWEAVE_MCP_ALLOWED_ORIGINS= +LINEAGEWEAVE_MCP_REQUESTS_PER_MINUTE=30 + # 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. From 0e1db8fb50cc847afe128bcbd4cc3d2859b664b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:07:00 -0700 Subject: [PATCH 06/17] docs(mcp): record v2.18.0 feature slice --- .../2.18.0-authenticated-global-ask-mcp.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 CHANGELOG.d/2.18.0-authenticated-global-ask-mcp.md diff --git a/CHANGELOG.d/2.18.0-authenticated-global-ask-mcp.md b/CHANGELOG.d/2.18.0-authenticated-global-ask-mcp.md new file mode 100644 index 00000000..3922cdeb --- /dev/null +++ b/CHANGELOG.d/2.18.0-authenticated-global-ask-mcp.md @@ -0,0 +1,21 @@ +# 2.18.0 — Authenticated Global Ask MCP + +## Added + +- Added a separately deployable MCP 2025-06-18 Streamable HTTP resource server with one read-only `global_ask` tool. +- Added RFC 9728 protected-resource metadata, exact MCP resource audience validation, non-empty exact JWKS `kid` selection, and browser Origin allow-listing. +- Reused the existing Global Ask authorization/evidence assembler and contextual-orchestrator channel without bearer-token passthrough. +- Added structured tool output containing answer text, citations, provenance-bearing citation evidence, bounded source ids, and explicit no-evidence next action. +- Added per-account bounded invocation rate limiting without logging question or answer text. + +## Security + +- MCP access is denied unless the OIDC subject maps to a provisioned LineageWeave account with `post_read`; per-row corporate-entity ABAC remains in force before source normalization. +- Inbound MCP bearer credentials are never forwarded to contextual-orchestrator or the browser-facing REST API. +- Unknown browser Origins fail closed and JSON-RPC batching is rejected. + +## Known prerequisite blockers + +- This change is stacked on PR #264 and must not merge before the #258 → #260 → #261 → #262 → #263 → #264 prerequisite chain is ready. +- PR #258 still has an unresolved review thread in the accumulated base. +- PR #264 still has an unresolved analysis-run cutoff propagation review finding. From da18d890fbea94ea22ce356b2ff13076f224dfc9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:07:20 -0700 Subject: [PATCH 07/17] chore(mcp): bump LineageWeave to 2.18.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a651b6b8..98d877ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.17.0" +version = "2.18.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } From d86c39b094ddb6d6a21c94f58ed90132fbd5c297 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:07:30 -0700 Subject: [PATCH 08/17] chore(mcp): align frontend package version --- frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 7a697d0c..f216081b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.17.0", + "version": "2.18.0", "type": "module", "scripts": { "dev": "vite", From d9e3b71085c5415ffcd0447012c8aaa14cdc74fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:08:08 -0700 Subject: [PATCH 09/17] docs(mcp): document authenticated server operation --- docs/MCP_SERVER.md | 105 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/MCP_SERVER.md diff --git a/docs/MCP_SERVER.md b/docs/MCP_SERVER.md new file mode 100644 index 00000000..ee0263f9 --- /dev/null +++ b/docs/MCP_SERVER.md @@ -0,0 +1,105 @@ +# LineageWeave MCP server + +LineageWeave exposes the Buyer Global Ask workflow as a separately deployable, authenticated MCP resource server. + +## Surface + +The first MCP release intentionally exposes one tool only: + +```text +global_ask(question) +``` + +It is read-only. It uses the same `post_read` permission, per-post corporate-entity ABAC predicate, bounded Global Ask source assembler, persisted evidence, and contextual-orchestrator answer channel as the Buyer product. It does not expose SQL, unrestricted post bodies, admin operations, tickets, analysis-run writes, or arbitrary graph queries. + +## Run locally + +Install the backend extra and start the MCP app independently from the Buyer API: + +```bash +uv sync --frozen --extra backend --extra dev +uv run uvicorn backend.app.mcp_server:app --host 127.0.0.1 --port 18421 +``` + +The local development defaults are: + +```text +MCP endpoint: http://localhost:18421/mcp +Protected-resource metadata: http://localhost:18421/.well-known/oauth-protected-resource/mcp +``` + +Production must publish an HTTPS MCP resource URI and configure the authorization server to issue access tokens whose audience includes that exact resource URI. + +## Configuration + +```text +LINEAGEWEAVE_MCP_RESOURCE_URI +- canonical OAuth resource identifier for the MCP endpoint +- local default: http://localhost:18421/mcp +- production: externally reachable HTTPS /mcp URI + +LINEAGEWEAVE_MCP_ALLOWED_ORIGINS +- comma-separated exact browser Origins permitted to reach /mcp +- empty means every request carrying an Origin header is rejected +- non-browser MCP clients may omit Origin + +LINEAGEWEAVE_MCP_REQUESTS_PER_MINUTE +- per-account bounded tool-call rate +- default 30, allowed range 1..600 +``` + +The existing LineageWeave OIDC variables select Keyverse or the explicit local Keycloak fallback. The MCP verifier additionally requires: + +- a non-empty JWT `kid` that exactly selects an acceptable RSA/RS256 JWKS key; +- matching issuer; +- matching MCP resource audience; +- normal JWT time validation with the configured bounded clock skew; +- a provisioned LineageWeave `user_account`; +- persisted `post_read` permission. + +Corporate affiliations and permissions are loaded from LineageWeave PostgreSQL. Untrusted token attributes cannot widen evidence access. + +## Authorization-server requirement + +The MCP server is an OAuth protected resource, not an authorization server. The configured Keyverse/OIDC authorization server must support the MCP client's authorization flow and issue a resource-bound access token for `LINEAGEWEAVE_MCP_RESOURCE_URI`. The MCP endpoint publishes RFC 9728 protected-resource metadata so a conforming client can discover the issuer. + +If Keyverse has not yet been configured to issue an access token for that resource, MCP authentication must fail rather than accepting the ordinary frontend token by disabling audience verification. + +## Codex + +Codex supports remote MCP use and MCP OAuth credentials can be stored in the operating-system keyring. Configure Codex to connect to the externally reachable LineageWeave `/mcp` URL and complete the OAuth flow offered through the protected-resource metadata discovery path. Do not place bearer tokens in this repository or in an `AGENTS.md` file. + +The exact Codex client-registration workflow depends on the authorization-server deployment. Keep client registration in Keyverse/identity configuration; do not add a Codex-specific authentication bypass to LineageWeave. + +## Result contract + +A successful `global_ask` tool call returns both MCP text content and `structuredContent`: + +```json +{ + "answer_text": "...", + "cited_post_ids": ["..."], + "cited_posts": [{"post_id": "...", "post_title": "..."}], + "cited_post_evidence": [{"post_id": "..."}], + "source_post_ids": ["..."], + "next_action": null +} +``` + +When no authorized source exists, the answer is empty and `next_action` explains that no authorized evidence is available. A missing or incomplete contextual-orchestrator result is an MCP tool execution error; LineageWeave does not manufacture an answer. + +## Security properties + +- No inbound MCP bearer token is passed to contextual-orchestrator or the Buyer REST API. +- Authorization happens before source normalization and model context assembly. +- Browser Origin validation prevents an unrelated web origin from reaching the MCP endpoint. +- Questions are bounded to 4,000 characters. +- The tool catalog is static and contains no write tool. +- The audit log records account id and counts only, not question text, answer text, source bodies, or tokens. +- This first rate limiter is process-local. Run a single MCP replica until a shared rate-limit backend is implemented. + +## Stack prerequisites + +This feature is stacked on PR #264. It is not permission to bypass existing review or merge gates. In particular, the accumulated Buyer stack must first resolve the #258 review blocker and #264 analysis-run cutoff propagation finding, then revalidate exact-head checks in stack order. + +See ADR 0090 and `docs/doctoring/MCP_REFERENCES.md` for the normative protocol and OAuth traceability. From c21f58e351de3993c8d92ea049e8da4152865498 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:12:59 -0700 Subject: [PATCH 10/17] fix(mcp): target stateless 2026-07-28 protocol --- backend/app/mcp_server.py | 332 +++++++++++++++++++++++++++----------- 1 file changed, 241 insertions(+), 91 deletions(-) diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index e8324e61..ede2401a 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -1,13 +1,9 @@ """Authenticated Model Context Protocol endpoint for LineageWeave Global Ask. -This module is deliberately separate from the browser-facing FastAPI app. -It exposes one read-only MCP tool, ``global_ask``, over Streamable HTTP and -reuses LineageWeave's persisted authorization and evidence contracts instead -of forwarding the caller's bearer token to another service. - -The HTTP authorization boundary follows MCP protocol revision 2025-06-18: -resource metadata advertises the authorization server, access tokens must be -bound to this MCP resource, and browser ``Origin`` values are allow-listed. +The server implements the stateless MCP protocol revision 2026-07-28 over +Streamable HTTP. It exposes one read-only ``global_ask`` tool and reuses +LineageWeave's persisted authorization and evidence contracts instead of +forwarding the caller's bearer token to another service. """ from __future__ import annotations @@ -17,17 +13,19 @@ import logging import os import time -from collections import defaultdict, deque from contextlib import asynccontextmanager from dataclasses import dataclass from typing import Any +from urllib.parse import urlsplit import asyncpg import jwt +import redis.asyncio as redis from fastapi import FastAPI, HTTPException, Request, Response, status from fastapi.responses import JSONResponse from jwt.algorithms import RSAAlgorithm +from backend.app.activity_stream import create_valkey_client from backend.app.auth import CurrentAccount from backend.app.config import Settings, load_settings from backend.app.db import create_pool @@ -39,14 +37,22 @@ cited_post_summaries, ) -_PROTOCOL_VERSION = "2025-06-18" +_PROTOCOL_VERSION = "2026-07-28" _SERVER_NAME = "lineageweave" _SERVER_VERSION = "2.18.0" _TOOL_NAME = "global_ask" _LOG = logging.getLogger("lineageweave.mcp") _JWKS_CACHE: dict[str, dict[str, Any]] = {} -_RATE_WINDOWS: dict[str, deque[float]] = defaultdict(deque) -_RATE_LOCK = asyncio.Lock() + +_META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion" +_META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo" +_META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities" +_META_SERVER_INFO = "io.modelcontextprotocol/serverInfo" + +_HEADER_MISMATCH = -32020 +_UNSUPPORTED_PROTOCOL_VERSION = -32022 + +_SERVER_INFO = {"name": _SERVER_NAME, "version": _SERVER_VERSION} _GLOBAL_ASK_OUTPUT_SCHEMA: dict[str, Any] = { "type": "object", @@ -102,7 +108,7 @@ @dataclass(frozen=True) class McpRuntimeSettings: - """MCP-specific settings layered on the shared LineageWeave settings.""" + """MCP-specific settings layered on shared LineageWeave settings.""" resource_uri: str allowed_origins: frozenset[str] @@ -114,8 +120,14 @@ def load_mcp_settings() -> McpRuntimeSettings: resource_uri = os.environ.get( "LINEAGEWEAVE_MCP_RESOURCE_URI", "http://localhost:18421/mcp" ).strip() - if not resource_uri.startswith(("http://", "https://")) or "#" in resource_uri: - raise ValueError("LINEAGEWEAVE_MCP_RESOURCE_URI must be an absolute http(s) URI without a fragment") + parsed = urlsplit(resource_uri) + if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.fragment or parsed.query: + raise ValueError( + "LINEAGEWEAVE_MCP_RESOURCE_URI must be an absolute http(s) URI without query or fragment" + ) + loopback = parsed.hostname in {"localhost", "127.0.0.1", "::1"} + if parsed.scheme != "https" and not loopback: + raise ValueError("LINEAGEWEAVE_MCP_RESOURCE_URI must use HTTPS outside loopback development") allowed_origins = frozenset( origin.strip() for origin in os.environ.get("LINEAGEWEAVE_MCP_ALLOWED_ORIGINS", "").split(",") @@ -270,17 +282,18 @@ async def _authenticate(request: Request, pool: asyncpg.Pool) -> CurrentAccount: return await _resolve_account(pool, str(claims["sub"])) -async def _check_rate_limit(account_id: str, limit: int) -> None: - """Bound MCP tool execution per process without storing question text.""" - now = time.monotonic() - cutoff = now - 60.0 - async with _RATE_LOCK: - window = _RATE_WINDOWS[account_id] - while window and window[0] <= cutoff: - window.popleft() - if len(window) >= limit: - raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "MCP tool rate limit exceeded") - window.append(now) +async def _check_rate_limit(client: redis.Redis, account_id: str, limit: int) -> None: + """Apply a distributed fixed-window MCP rate limit using existing Valkey.""" + bucket = int(time.time() // 60) + key = f"mcp:rate:{account_id}:{bucket}" + script = """ + local count = redis.call('INCR', KEYS[1]) + if count == 1 then redis.call('EXPIRE', KEYS[1], 120) end + return count + """ + count = int(await client.eval(script, 1, key)) + if count > limit: + raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "MCP tool rate limit exceeded") def _can_see_post(account: CurrentAccount, post: Any) -> bool: @@ -351,90 +364,224 @@ async def _global_ask( return result -def _jsonrpc_result(request_id: Any, result: dict[str, Any]) -> dict[str, Any]: - return {"jsonrpc": "2.0", "id": request_id, "result": result} +def _jsonrpc_result(request_id: Any, payload: dict[str, Any]) -> dict[str, Any]: + """Build a complete 2026-era result with server identity metadata.""" + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + **payload, + "resultType": "complete", + "_meta": {_META_SERVER_INFO: _SERVER_INFO}, + }, + } -def _jsonrpc_error(request_id: Any, code: int, message: str) -> dict[str, Any]: - return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}} +def _jsonrpc_error( + request_id: Any, + code: int, + message: str, + *, + data: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a JSON-RPC error response.""" + error: dict[str, Any] = {"code": code, "message": message} + if data is not None: + error["data"] = data + return {"jsonrpc": "2.0", "id": request_id, "error": error} + + +def _request_envelope(message: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """Validate the stateless per-request MCP metadata envelope.""" + params = message.get("params") + if params is None: + params = {} + if not isinstance(params, dict): + raise ValueError("params must be an object") + meta = params.get("_meta") + if not isinstance(meta, dict): + raise ValueError("params._meta is required") + protocol_version = meta.get(_META_PROTOCOL_VERSION) + if protocol_version != _PROTOCOL_VERSION: + raise RuntimeError(str(protocol_version)) + client_capabilities = meta.get(_META_CLIENT_CAPABILITIES) + if not isinstance(client_capabilities, dict): + raise ValueError("clientCapabilities must be an object") + client_info = meta.get(_META_CLIENT_INFO) + if client_info is not None: + if not isinstance(client_info, dict): + raise ValueError("clientInfo must be an object when present") + if not isinstance(client_info.get("name"), str) or not isinstance(client_info.get("version"), str): + raise ValueError("clientInfo name and version must be strings") + return params, meta + + +def _expected_mcp_name(method: Any, params: dict[str, Any]) -> str | None: + """Return the standardized Mcp-Name source for name-bearing methods.""" + if method == "tools/call": + name = params.get("name") + return name if isinstance(name, str) else None + if method in {"resources/read", "prompts/get"}: + uri_or_name = params.get("uri") if method == "resources/read" else params.get("name") + return uri_or_name if isinstance(uri_or_name, str) else None + return None + + +def _validate_transport_headers(request: Request, message: dict[str, Any]) -> JSONResponse | None: + """Validate 2026 protocol/version/method/name headers against the body.""" + request_id = message.get("id") + params = message.get("params") if isinstance(message.get("params"), dict) else {} + meta = params.get("_meta") if isinstance(params, dict) else None + body_version = meta.get(_META_PROTOCOL_VERSION) if isinstance(meta, dict) else None + header_version = request.headers.get("mcp-protocol-version") + if header_version != _PROTOCOL_VERSION or body_version != _PROTOCOL_VERSION: + return JSONResponse( + _jsonrpc_error( + request_id, + _UNSUPPORTED_PROTOCOL_VERSION, + "Unsupported protocol version", + data={"supportedVersions": [_PROTOCOL_VERSION]}, + ), + status_code=status.HTTP_400_BAD_REQUEST, + ) + if header_version != body_version: + return JSONResponse( + _jsonrpc_error(request_id, _HEADER_MISMATCH, "MCP-Protocol-Version header mismatch"), + status_code=status.HTTP_400_BAD_REQUEST, + ) + method = message.get("method") + method_header = request.headers.get("mcp-method") + if not isinstance(method, str) or method_header != method: + return JSONResponse( + _jsonrpc_error(request_id, _HEADER_MISMATCH, "Mcp-Method header mismatch"), + status_code=status.HTTP_400_BAD_REQUEST, + ) + expected_name = _expected_mcp_name(method, params) + name_header = request.headers.get("mcp-name") + if expected_name is None: + if name_header is not None: + return JSONResponse( + _jsonrpc_error(request_id, _HEADER_MISMATCH, "unexpected Mcp-Name header"), + status_code=status.HTTP_400_BAD_REQUEST, + ) + elif name_header != expected_name: + return JSONResponse( + _jsonrpc_error(request_id, _HEADER_MISMATCH, "Mcp-Name header mismatch"), + status_code=status.HTTP_400_BAD_REQUEST, + ) + return None async def _dispatch_message( message: Any, pool: asyncpg.Pool, + valkey: redis.Redis, account: CurrentAccount, mcp_settings: McpRuntimeSettings, -) -> dict[str, Any] | None: - """Dispatch one non-batched JSON-RPC 2.0 MCP message.""" +) -> tuple[dict[str, Any] | None, int]: + """Dispatch one stateless, non-batched JSON-RPC 2.0 MCP message.""" if not isinstance(message, dict) or message.get("jsonrpc") != "2.0": - return _jsonrpc_error(message.get("id") if isinstance(message, dict) else None, -32600, "Invalid Request") - if isinstance(message, list): - return _jsonrpc_error(None, -32600, "JSON-RPC batching is not supported") + request_id = message.get("id") if isinstance(message, dict) else None + return _jsonrpc_error(request_id, -32600, "Invalid Request"), status.HTTP_200_OK method = message.get("method") request_id = message.get("id") if request_id is None: - if method == "notifications/initialized": - return None - return None - if method == "initialize": - params = message.get("params") or {} - requested_version = params.get("protocolVersion") if isinstance(params, dict) else None - if requested_version != _PROTOCOL_VERSION: - return _jsonrpc_error(request_id, -32602, f"Unsupported protocolVersion: {requested_version!r}") - return _jsonrpc_result( - request_id, - { - "protocolVersion": _PROTOCOL_VERSION, - "capabilities": {"tools": {"listChanged": False}}, - "serverInfo": {"name": _SERVER_NAME, "version": _SERVER_VERSION}, - "instructions": "Use global_ask only for source-grounded LineageWeave questions.", - }, + return None, status.HTTP_202_ACCEPTED + try: + params, _meta = _request_envelope(message) + except RuntimeError: + return ( + _jsonrpc_error( + request_id, + _UNSUPPORTED_PROTOCOL_VERSION, + "Unsupported protocol version", + data={"supportedVersions": [_PROTOCOL_VERSION]}, + ), + status.HTTP_400_BAD_REQUEST, + ) + except ValueError as exc: + return _jsonrpc_error(request_id, -32602, str(exc)), status.HTTP_200_OK + + if method == "server/discover": + return ( + _jsonrpc_result( + request_id, + { + "supportedVersions": [_PROTOCOL_VERSION], + "capabilities": {"tools": {"listChanged": False}}, + "instructions": "Use global_ask only for source-grounded LineageWeave questions.", + "ttlMs": 0, + "cacheScope": "private", + }, + ), + status.HTTP_200_OK, ) if method == "ping": - return _jsonrpc_result(request_id, {}) + return _jsonrpc_result(request_id, {}), status.HTTP_200_OK if method == "tools/list": - return _jsonrpc_result(request_id, {"tools": [_GLOBAL_ASK_TOOL]}) + return ( + _jsonrpc_result( + request_id, + {"tools": [_GLOBAL_ASK_TOOL], "ttlMs": 0, "cacheScope": "private"}, + ), + status.HTTP_200_OK, + ) if method == "tools/call": - params = message.get("params") - if not isinstance(params, dict) or params.get("name") != _TOOL_NAME: - return _jsonrpc_error(request_id, -32602, "Unknown tool") + if params.get("name") != _TOOL_NAME: + return _jsonrpc_error(request_id, -32602, "Unknown tool"), status.HTTP_200_OK arguments = params.get("arguments") if not isinstance(arguments, dict) or set(arguments) != {"question"}: - return _jsonrpc_error(request_id, -32602, "global_ask requires only the question argument") + return ( + _jsonrpc_error(request_id, -32602, "global_ask requires only the question argument"), + status.HTTP_200_OK, + ) question = arguments.get("question") if not isinstance(question, str) or not question.strip() or len(question.strip()) > 4000: - return _jsonrpc_error(request_id, -32602, "question must contain between 1 and 4000 characters") - await _check_rate_limit(account.user_account_id, mcp_settings.requests_per_minute) + return ( + _jsonrpc_error( + request_id, + -32602, + "question must contain between 1 and 4000 characters", + ), + status.HTTP_200_OK, + ) + await _check_rate_limit(valkey, account.user_account_id, mcp_settings.requests_per_minute) try: structured = await _global_ask(pool, account, question) except RuntimeError as exc: - return _jsonrpc_result( + return ( + _jsonrpc_result( + request_id, + {"content": [{"type": "text", "text": str(exc)}], "isError": True}, + ), + status.HTTP_200_OK, + ) + text = json.dumps(structured, ensure_ascii=False, separators=(",", ":")) + return ( + _jsonrpc_result( request_id, { - "content": [{"type": "text", "text": str(exc)}], - "isError": True, + "content": [{"type": "text", "text": text}], + "structuredContent": structured, + "isError": False, }, - ) - text = json.dumps(structured, ensure_ascii=False, separators=(",", ":")) - return _jsonrpc_result( - request_id, - { - "content": [{"type": "text", "text": text}], - "structuredContent": structured, - "isError": False, - }, + ), + status.HTTP_200_OK, ) - return _jsonrpc_error(request_id, -32601, f"Method not found: {method}") + return _jsonrpc_error(request_id, -32601, f"Method not found: {method}"), status.HTTP_404_NOT_FOUND @asynccontextmanager async def lifespan(app: FastAPI): - """Create only the database pool needed by the MCP resource server.""" - app.state.pool = await create_pool(load_settings().database_url) + """Create shared DB and Valkey clients; MCP protocol state stays per request.""" + shared = load_settings() + app.state.pool = await create_pool(shared.database_url) + app.state.valkey = create_valkey_client(shared.valkey_url) try: yield finally: await app.state.pool.close() + await app.state.valkey.aclose() mcp_app = FastAPI(title="LineageWeave MCP", lifespan=lifespan) @@ -461,15 +608,29 @@ async def protected_resource_metadata() -> dict[str, Any]: @mcp_app.get("/mcp") async def mcp_get() -> Response: - """This first slice is non-streaming; GET explicitly declines SSE.""" + """No subscription stream is exposed in this read-only first slice.""" return Response(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, headers={"Allow": "POST"}) @mcp_app.post("/mcp") async def mcp_post(request: Request) -> Response: - """Handle one authenticated Streamable-HTTP MCP JSON-RPC message.""" + """Handle one authenticated MCP 2026-07-28 Streamable HTTP request.""" mcp_settings = load_mcp_settings() _validate_origin(request, mcp_settings) + try: + message = await request.json() + except (json.JSONDecodeError, UnicodeDecodeError, ValueError): + return JSONResponse(_jsonrpc_error(None, -32700, "Parse error"), status_code=200) + if isinstance(message, list): + return JSONResponse( + _jsonrpc_error(None, -32600, "JSON-RPC batching is not supported"), + status_code=200, + ) + if not isinstance(message, dict): + return JSONResponse(_jsonrpc_error(None, -32600, "Invalid Request"), status_code=200) + header_error = _validate_transport_headers(request, message) + if header_error is not None: + return header_error try: account = await _authenticate(request, request.app.state.pool) except HTTPException as exc: @@ -484,27 +645,16 @@ async def mcp_post(request: Request) -> Response: }, ) raise - protocol_header = request.headers.get("mcp-protocol-version") - if protocol_header is not None and protocol_header != _PROTOCOL_VERSION: - return JSONResponse( - {"detail": "Unsupported MCP-Protocol-Version"}, - status_code=status.HTTP_400_BAD_REQUEST, - ) - try: - message = await request.json() - except (json.JSONDecodeError, UnicodeDecodeError, ValueError): - return JSONResponse(_jsonrpc_error(None, -32700, "Parse error"), status_code=200) - if isinstance(message, list): - return JSONResponse(_jsonrpc_error(None, -32600, "JSON-RPC batching is not supported"), status_code=200) - response_message = await _dispatch_message( + response_message, http_status = await _dispatch_message( message, request.app.state.pool, + request.app.state.valkey, account, mcp_settings, ) if response_message is None: - return Response(status_code=status.HTTP_202_ACCEPTED) - return JSONResponse(response_message) + return Response(status_code=http_status) + return JSONResponse(response_message, status_code=http_status) app = mcp_app From 5c39fbc28a5797c20823eaa0f9e7d22f76b8b826 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:13:54 -0700 Subject: [PATCH 11/17] test(mcp): lock 2026-07-28 stateless contract --- tests/test_mcp_server.py | 283 +++++++++++++++++++++++---------------- 1 file changed, 168 insertions(+), 115 deletions(-) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index b7c834d5..0e50dc38 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -31,8 +31,12 @@ def _account() -> CurrentAccount: ) -def _request(origin: str | None = None) -> Request: - headers = [] if origin is None else [(b"origin", origin.encode())] +def _request(*, origin: str | None = None, headers: dict[str, str] | None = None) -> Request: + raw_headers: list[tuple[bytes, bytes]] = [] + if origin is not None: + raw_headers.append((b"origin", origin.encode())) + for name, value in (headers or {}).items(): + raw_headers.append((name.lower().encode(), value.encode())) return Request( { "type": "http", @@ -42,16 +46,45 @@ def _request(origin: str | None = None) -> Request: "path": "/mcp", "raw_path": b"/mcp", "query_string": b"", - "headers": headers, + "headers": raw_headers, "client": ("127.0.0.1", 1234), "server": ("lineage.example", 443), } ) +def _meta() -> dict: + return { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "test-client", "version": "1.0.0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + + +def _message(method: str, *, request_id: int = 1, **params) -> dict: + return { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": {**params, "_meta": _meta()}, + } + + +class FakeValkey: + def __init__(self, counts: list[int] | None = None) -> None: + self.counts = list(counts or [1]) + self.calls: list[tuple[str, int, str]] = [] + + async def eval(self, script: str, key_count: int, key: str) -> int: + self.calls.append((script, key_count, key)) + return self.counts.pop(0) + + def test_mcp_settings_validate_resource_and_rate(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("LINEAGEWEAVE_MCP_RESOURCE_URI", "https://lineage.example/mcp") - monkeypatch.setenv("LINEAGEWEAVE_MCP_ALLOWED_ORIGINS", "https://codex.example, https://admin.example") + monkeypatch.setenv( + "LINEAGEWEAVE_MCP_ALLOWED_ORIGINS", "https://codex.example, https://admin.example" + ) monkeypatch.setenv("LINEAGEWEAVE_MCP_REQUESTS_PER_MINUTE", "17") settings = mcp.load_mcp_settings() assert settings.resource_uri == "https://lineage.example/mcp" @@ -62,6 +95,13 @@ def test_mcp_settings_validate_resource_and_rate(monkeypatch: pytest.MonkeyPatch with pytest.raises(ValueError, match="absolute http"): mcp.load_mcp_settings() + monkeypatch.setenv("LINEAGEWEAVE_MCP_RESOURCE_URI", "http://lineage.example/mcp") + with pytest.raises(ValueError, match="HTTPS"): + mcp.load_mcp_settings() + + monkeypatch.setenv("LINEAGEWEAVE_MCP_RESOURCE_URI", "http://localhost:18421/mcp") + assert mcp.load_mcp_settings().resource_uri == "http://localhost:18421/mcp" + monkeypatch.setenv("LINEAGEWEAVE_MCP_RESOURCE_URI", "https://lineage.example/mcp") monkeypatch.setenv("LINEAGEWEAVE_MCP_REQUESTS_PER_MINUTE", "0") with pytest.raises(ValueError, match="between 1 and 600"): @@ -79,9 +119,9 @@ def test_origin_is_fail_closed_only_when_browser_origin_is_present() -> None: requests_per_minute=30, ) mcp._validate_origin(_request(), settings) - mcp._validate_origin(_request("https://codex.example"), settings) + mcp._validate_origin(_request(origin="https://codex.example"), settings) with pytest.raises(Exception) as error: - mcp._validate_origin(_request("https://evil.example"), settings) + mcp._validate_origin(_request(origin="https://evil.example"), settings) assert getattr(error.value, "status_code", None) == 403 @@ -152,72 +192,85 @@ def test_access_token_requires_subject(monkeypatch: pytest.MonkeyPatch) -> None: assert getattr(error.value, "status_code", None) == 401 -def test_initialize_and_tool_catalog_are_current_protocol() -> None: - runtime = mcp.McpRuntimeSettings( - resource_uri="https://lineage.example/mcp", - allowed_origins=frozenset(), - requests_per_minute=30, - ) - initialize = asyncio.run( - mcp._dispatch_message( - { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": {"protocolVersion": "2025-06-18"}, - }, - object(), - _account(), - runtime, - ) +def test_request_envelope_requires_current_protocol_and_capabilities() -> None: + params, meta = mcp._request_envelope(_message("tools/list")) + assert params["_meta"] is meta + assert meta["io.modelcontextprotocol/protocolVersion"] == "2026-07-28" + + missing = _message("tools/list") + del missing["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"] + with pytest.raises(ValueError, match="clientCapabilities"): + mcp._request_envelope(missing) + + old = _message("tools/list") + old["params"]["_meta"]["io.modelcontextprotocol/protocolVersion"] = "2025-06-18" + with pytest.raises(RuntimeError): + mcp._request_envelope(old) + + +def test_transport_headers_must_match_body() -> None: + message = _message("tools/call", name="global_ask", arguments={"question": "Q"}) + valid = _request( + headers={ + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "tools/call", + "Mcp-Name": "global_ask", + } ) - assert initialize["result"]["protocolVersion"] == "2025-06-18" - assert initialize["result"]["capabilities"] == {"tools": {"listChanged": False}} + assert mcp._validate_transport_headers(valid, message) is None - tools = asyncio.run( - mcp._dispatch_message( - {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, - object(), - _account(), - runtime, - ) + bad_method = _request( + headers={ + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "tools/list", + "Mcp-Name": "global_ask", + } ) - tool = tools["result"]["tools"][0] - assert tool["name"] == "global_ask" - assert tool["annotations"]["readOnlyHint"] is True - assert tool["annotations"]["openWorldHint"] is False - assert tool["outputSchema"] == mcp._GLOBAL_ASK_OUTPUT_SCHEMA + response = mcp._validate_transport_headers(bad_method, message) + assert response.status_code == 400 + assert b'"code":-32020' in response.body + + old_version = _request( + headers={ + "MCP-Protocol-Version": "2025-06-18", + "Mcp-Method": "tools/call", + "Mcp-Name": "global_ask", + } + ) + response = mcp._validate_transport_headers(old_version, message) + assert response.status_code == 400 + assert b'"code":-32022' in response.body -def test_initialize_rejects_other_protocol_and_initialized_notification_is_silent() -> None: +def test_discover_and_tool_catalog_are_stateless_current_protocol() -> None: runtime = mcp.McpRuntimeSettings( resource_uri="https://lineage.example/mcp", allowed_origins=frozenset(), requests_per_minute=30, ) - rejected = asyncio.run( + discover, discover_status = asyncio.run( mcp._dispatch_message( - { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": {"protocolVersion": "2025-03-26"}, - }, - object(), - _account(), - runtime, + _message("server/discover"), object(), FakeValkey(), _account(), runtime ) ) - assert rejected["error"]["code"] == -32602 - notification = asyncio.run( - mcp._dispatch_message( - {"jsonrpc": "2.0", "method": "notifications/initialized"}, - object(), - _account(), - runtime, - ) + assert discover_status == 200 + assert discover["result"]["supportedVersions"] == ["2026-07-28"] + assert discover["result"]["resultType"] == "complete" + assert discover["result"]["ttlMs"] == 0 + assert discover["result"]["cacheScope"] == "private" + assert discover["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"] == "lineageweave" + + tools, tools_status = asyncio.run( + mcp._dispatch_message(_message("tools/list"), object(), FakeValkey(), _account(), runtime) ) - assert notification is None + assert tools_status == 200 + tool = tools["result"]["tools"][0] + assert tool["name"] == "global_ask" + assert tool["annotations"]["readOnlyHint"] is True + assert tool["annotations"]["openWorldHint"] is False + assert tool["outputSchema"] == mcp._GLOBAL_ASK_OUTPUT_SCHEMA + assert tools["result"]["ttlMs"] == 0 + assert tools["result"]["cacheScope"] == "private" def test_global_ask_tool_returns_structured_and_text_content(monkeypatch: pytest.MonkeyPatch) -> None: @@ -240,68 +293,55 @@ async def fake_ask(pool, account, question): assert question == "What changed?" return expected - async def no_limit(account_id, limit): + async def no_limit(client, account_id, limit): assert account_id == "account-1" assert limit == 30 monkeypatch.setattr(mcp, "_global_ask", fake_ask) monkeypatch.setattr(mcp, "_check_rate_limit", no_limit) - response = asyncio.run( + response, http_status = asyncio.run( mcp._dispatch_message( - { - "jsonrpc": "2.0", - "id": 7, - "method": "tools/call", - "params": {"name": "global_ask", "arguments": {"question": "What changed?"}}, - }, + _message( + "tools/call", + request_id=7, + name="global_ask", + arguments={"question": "What changed?"}, + ), object(), + FakeValkey(), _account(), runtime, ) ) + assert http_status == 200 result = response["result"] assert result["structuredContent"] == expected assert json.loads(result["content"][0]["text"]) == expected assert result["isError"] is False + assert result["resultType"] == "complete" -def test_tool_validation_and_execution_failure_are_distinct(monkeypatch: pytest.MonkeyPatch) -> None: +def test_tool_validation_execution_failure_and_unknown_method_are_distinct( + monkeypatch: pytest.MonkeyPatch, +) -> None: runtime = mcp.McpRuntimeSettings( resource_uri="https://lineage.example/mcp", allowed_origins=frozenset(), requests_per_minute=30, ) - unknown = asyncio.run( + unknown_tool, unknown_status = asyncio.run( mcp._dispatch_message( - { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": {"name": "write_everything", "arguments": {}}, - }, + _message("tools/call", name="write_everything", arguments={}), object(), + FakeValkey(), _account(), runtime, ) ) - assert unknown["error"]["code"] == -32602 + assert unknown_status == 200 + assert unknown_tool["error"]["code"] == -32602 - invalid = asyncio.run( - mcp._dispatch_message( - { - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": {"name": "global_ask", "arguments": {"question": " ", "extra": 1}}, - }, - object(), - _account(), - runtime, - ) - ) - assert invalid["error"]["code"] == -32602 - - async def no_limit(account_id, limit): + async def no_limit(client, account_id, limit): return None async def unavailable(pool, account, question): @@ -309,49 +349,62 @@ async def unavailable(pool, account, question): monkeypatch.setattr(mcp, "_check_rate_limit", no_limit) monkeypatch.setattr(mcp, "_global_ask", unavailable) - failed = asyncio.run( + failed, failed_status = asyncio.run( mcp._dispatch_message( - { - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": {"name": "global_ask", "arguments": {"question": "Question"}}, - }, + _message( + "tools/call", + name="global_ask", + arguments={"question": "Question"}, + ), object(), + FakeValkey(), _account(), runtime, ) ) + assert failed_status == 200 assert "error" not in failed assert failed["result"]["isError"] is True assert failed["result"]["content"][0]["text"] == "orchestrator unavailable" + missing, missing_status = asyncio.run( + mcp._dispatch_message( + _message("resources/list"), object(), FakeValkey(), _account(), runtime + ) + ) + assert missing_status == 404 + assert missing["error"]["code"] == -32601 + -def test_rate_limit_is_bounded(monkeypatch: pytest.MonkeyPatch) -> None: - mcp._RATE_WINDOWS.clear() - times = iter([10.0, 10.1, 10.2]) - monkeypatch.setattr(mcp.time, "monotonic", lambda: next(times)) - asyncio.run(mcp._check_rate_limit("account", 2)) - asyncio.run(mcp._check_rate_limit("account", 2)) +def test_rate_limit_uses_valkey_and_rejects_over_limit(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(mcp.time, "time", lambda: 600.0) + allowed = FakeValkey([2]) + asyncio.run(mcp._check_rate_limit(allowed, "account", 2)) + assert allowed.calls[0][1] == 1 + assert allowed.calls[0][2] == "mcp:rate:account:10" + + denied = FakeValkey([3]) with pytest.raises(Exception) as error: - asyncio.run(mcp._check_rate_limit("account", 2)) + asyncio.run(mcp._check_rate_limit(denied, "account", 2)) assert getattr(error.value, "status_code", None) == 429 -def test_invalid_jsonrpc_and_unknown_method_return_protocol_errors() -> None: +def test_invalid_jsonrpc_and_notification_behavior() -> None: runtime = mcp.McpRuntimeSettings( resource_uri="https://lineage.example/mcp", allowed_origins=frozenset(), requests_per_minute=30, ) - invalid = asyncio.run(mcp._dispatch_message({"id": 1}, object(), _account(), runtime)) + invalid, invalid_status = asyncio.run( + mcp._dispatch_message({"id": 1}, object(), FakeValkey(), _account(), runtime) + ) + assert invalid_status == 200 assert invalid["error"]["code"] == -32600 - missing = asyncio.run( - mcp._dispatch_message( - {"jsonrpc": "2.0", "id": 2, "method": "resources/list"}, - object(), - _account(), - runtime, - ) + + notification = _message("notifications/custom") + notification.pop("id") + response, response_status = asyncio.run( + mcp._dispatch_message(notification, object(), FakeValkey(), _account(), runtime) ) - assert missing["error"]["code"] == -32601 + assert response is None + assert response_status == 202 From 24c5655e17420886562458ca9c3b6fca4b536823 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:14:23 -0700 Subject: [PATCH 12/17] docs(mcp): adopt 2026-07-28 stateless protocol --- docs/adr/0090-authenticated-global-ask-mcp.md | 78 +++++++------------ 1 file changed, 30 insertions(+), 48 deletions(-) diff --git a/docs/adr/0090-authenticated-global-ask-mcp.md b/docs/adr/0090-authenticated-global-ask-mcp.md index 76ea1f48..0a206f5c 100644 --- a/docs/adr/0090-authenticated-global-ask-mcp.md +++ b/docs/adr/0090-authenticated-global-ask-mcp.md @@ -7,9 +7,9 @@ LineageWeave already has an authenticated buyer-facing Global Ask flow (`POST /api/ask`) that assembles a bounded evidence set only after the caller's `post_read` RBAC and per-row corporate-entity ABAC checks. The answer is produced only through `contextual-orchestrator`, and citations identify the source posts used by the answer (ADR 0039). -External agent clients such as Codex need the same capability without receiving database credentials or a privileged service token and without bypassing the existing evidence boundary. A remote MCP server also creates a distinct OAuth protected-resource boundary: the access token must be intended for the MCP server itself, browser origins must not be able to reach a local/remote endpoint through DNS rebinding, and an inbound bearer token must not be forwarded to another API. +External agent clients such as Codex need the same capability without receiving database credentials or a privileged service token and without bypassing the existing evidence boundary. A remote MCP server creates a distinct OAuth protected-resource boundary: the access token must be intended for the MCP resource itself, browser origins must not be able to reach it through an unrelated origin, and an inbound bearer token must not be forwarded to another API. -The currently accumulated Buyer stack also contains two independent blockers that this ADR does not hide or override: PR #258 has an unresolved static-analysis review thread, and PR #264 has an unresolved review finding that DAG navigation can discard analysis-run cutoff context. This MCP slice is stacked on #264 and must not merge ahead of its prerequisite chain. +The accumulated Buyer stack contains independent blockers that this ADR does not hide or override: PR #258 has an unresolved static-analysis review thread, and PR #264 has an unresolved review finding that DAG navigation can discard analysis-run cutoff context. This MCP slice is stacked on #264 and must not merge ahead of its prerequisite chain. ## Decision @@ -21,72 +21,54 @@ The first MCP surface contains exactly one tool: global_ask(question) ``` -The tool is read-only and returns: - -- grounded answer text; -- cited post identifiers and display summaries; -- citation evidence; -- the bounded set of source post identifiers considered; -- an explicit next action when no authorized evidence exists. - -The MCP server reuses the same Global Ask source assembler and the same `ContextualOrchestratorPostChatClient`. It does **not** forward the MCP access token to contextual-orchestrator or call the browser REST endpoint with that token. +The tool is read-only and returns grounded answer text, cited post identifiers and summaries, citation evidence, the bounded source set considered, and an explicit next action when no authorized evidence exists. It reuses the same Global Ask source assembler and `ContextualOrchestratorPostChatClient`. It does **not** forward the MCP access token to contextual-orchestrator or call the Buyer REST endpoint with that token. ### Protocol profile -The initial server implements MCP protocol revision `2025-06-18` over non-streaming Streamable HTTP: +The server targets the current MCP protocol revision `2026-07-28` over stateless Streamable HTTP. -- JSON-RPC 2.0; -- `initialize` and `notifications/initialized` lifecycle messages; -- `ping`; -- `tools/list`; -- `tools/call`; -- one POST endpoint at `/mcp`; -- GET `/mcp` returns 405 because this slice does not offer SSE; -- JSON-RPC batching is rejected; -- tool output includes both `structuredContent` and a JSON text content block; -- the `global_ask` tool advertises `readOnlyHint=true`, `destructiveHint=false`, and `openWorldHint=false`. +- There is no `initialize`/`initialized` handshake and no `Mcp-Session-Id`. +- Each request carries the protocol revision and client capabilities in `params._meta`. +- Clients may call `server/discover` to learn the supported revision and capabilities. +- Every POST requires `MCP-Protocol-Version` and `Mcp-Method`; `tools/call` additionally requires `Mcp-Name` matching `params.name`. +- Header/body mismatches fail with `HeaderMismatch` (`-32020`); unsupported revisions fail with `UnsupportedProtocolVersion` (`-32022`). +- `tools/list` returns deterministic tool definitions with explicit `ttlMs=0` and `cacheScope=private`. +- Successful 2026-era results carry `resultType=complete` and `_meta.io.modelcontextprotocol/serverInfo`. +- `global_ask` advertises `readOnlyHint=true`, `destructiveHint=false`, `idempotentHint=true`, and `openWorldHint=false`. +- JSON-RPC batching is rejected. No subscription stream or MRTR flow is needed for this first read-only tool. ### OAuth protected-resource boundary -The MCP process is an OAuth protected resource, not an authorization server. - -It publishes RFC 9728 protected-resource metadata and advertises the configured LineageWeave/Keyverse OIDC issuer as its authorization server. The canonical resource identifier is configured by `LINEAGEWEAVE_MCP_RESOURCE_URI` and must identify the `/mcp` resource. +The MCP process is an OAuth protected resource, not an authorization server. It publishes RFC 9728 protected-resource metadata and advertises the configured LineageWeave/Keyverse OIDC issuer. `LINEAGEWEAVE_MCP_RESOURCE_URI` is the canonical resource identifier. Every protected request must satisfy all of the following before evidence is touched: 1. Bearer authentication is present. -2. JWT `kid` is non-empty and exactly selects one acceptable RSA/RS256 JWKS key; no first-key fallback is allowed. -3. Signature, issuer, expiry/not-before semantics, and configured clock skew validate. +2. JWT `kid` is non-empty and exactly selects one acceptable RSA/RS256 JWKS key; there is no first-key fallback. +3. Signature, issuer, expiry/not-before semantics, and bounded clock skew validate. 4. JWT audience includes the exact MCP resource URI. 5. `sub` resolves to a provisioned `user_account`. -6. roles stored in LineageWeave grant `post_read`. -7. corporate-entity affiliations are loaded from LineageWeave, not trusted from arbitrary token claims. -8. each source post still passes the product's per-row ABAC predicate before normalization or LLM context assembly. - -A 401 challenge includes the RFC 9728 `resource_metadata` location. The MCP access token is never passed through to downstream APIs. - -### Origin and transport policy +6. persisted roles grant `post_read`. +7. corporate-entity affiliations are loaded from LineageWeave, not trusted from token-side business claims. +8. each source still passes the product's row-level ABAC predicate before normalization or LLM context assembly. -When an HTTP `Origin` header is present it must exactly match `LINEAGEWEAVE_MCP_ALLOWED_ORIGINS`; an unknown browser origin fails closed. Non-browser MCP clients may omit `Origin`. +A 401 challenge includes the RFC 9728 `resource_metadata` location. The bearer token is never passed through to downstream APIs. The authorization client/issuer side must also follow the 2026-07-28 authorization hardening, including issuer validation and the migration away from Dynamic Client Registration toward Client ID Metadata Documents where supported. -Production deployment must use an HTTPS canonical resource. Plain HTTP is reserved for loopback development only. The MCP service is independently deployable so its ingress, OAuth audience, rate policy, network policy, and telemetry can be isolated from the Buyer web application. +### Origin, transport, bounds, and audit -### Bounds and audit +When an HTTP `Origin` header is present it must exactly match `LINEAGEWEAVE_MCP_ALLOWED_ORIGINS`; an unknown browser origin fails closed. Non-browser clients may omit `Origin`. Production resource identifiers must use HTTPS; plain HTTP is accepted only for loopback development. -- Questions are limited to 4,000 characters. -- The existing Global Ask bounded-source contract remains in force. -- Tool calls are rate-limited per authenticated account. The first implementation is process-local; a later horizontally scaled slice must replace this with a shared counter before multiple MCP replicas are enabled. -- Audit logging records only opaque account id, question length, number of considered sources, and citation count. Question text, answer text, bearer token, and raw post bodies are not emitted to the MCP audit log. +Questions are limited to 4,000 characters. Tool calls use the existing Valkey service for a distributed fixed-window per-account rate limit, preserving the stateless/horizontally scalable MCP request model. Audit logging records only opaque account id, question length, considered-source count, and citation count; question text, answer text, bearer tokens, and source bodies are not logged by the MCP audit path. ## Consequences -- Codex and other conforming MCP clients can authenticate to a dedicated LineageWeave resource and invoke Global Ask without obtaining database or orchestrator credentials. -- The tool cannot expand the caller's evidence visibility beyond the existing `post_read` + ABAC contract. -- A browser login token minted only for the frontend is not automatically valid for MCP: Keyverse must issue a resource-bound access token whose audience includes the configured MCP resource URI. -- No write tools, arbitrary SQL, raw graph traversal, admin actions, ticket changes, analysis-run starts, or unrestricted post-body resources are exposed in this slice. -- Horizontal MCP scaling is blocked until distributed rate limiting is implemented. -- This ADR does not cure the browser application's separate audience-validation weakness or the #264 temporal-cutoff navigation defect. Those remain prerequisite-stack work and must not be treated as satisfied by the MCP-specific verifier. +- Codex and other conforming MCP clients can authenticate to a dedicated LineageWeave resource and invoke Global Ask without database or orchestrator credentials. +- The tool cannot expand evidence visibility beyond `post_read` plus the existing ABAC contract. +- A frontend login token is not automatically an MCP token: the authorization server must mint a token for the exact MCP resource audience. +- No write tools, arbitrary SQL, unrestricted graph traversal, admin actions, ticket changes, analysis-run starts, or unrestricted post-body resources are exposed. +- The MCP request path is stateless across replicas; only authorization/evidence data in PostgreSQL and bounded rate counters in Valkey are shared. +- This ADR does not cure the Buyer application's separate audience-validation weakness or #264 temporal-cutoff navigation defect. Those remain prerequisite-stack work. ## References -See `docs/doctoring/MCP_REFERENCES.md` for the normative protocol and OAuth references in APA 7th format. +See `docs/doctoring/MCP_REFERENCES.md` for normative protocol and OAuth references in APA 7th format. From c1403c61702c011b6d8919aa58803467924177da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:14:47 -0700 Subject: [PATCH 13/17] docs(mcp): trace current protocol references --- docs/doctoring/MCP_REFERENCES.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/doctoring/MCP_REFERENCES.md b/docs/doctoring/MCP_REFERENCES.md index 92bef251..425be202 100644 --- a/docs/doctoring/MCP_REFERENCES.md +++ b/docs/doctoring/MCP_REFERENCES.md @@ -1,19 +1,20 @@ # MCP standards references -This bibliography supports ADR 0090 and the authenticated LineageWeave MCP resource server. Product claims must be traceable to the exact protocol revision or RFC below rather than to remembered MCP behavior. +This bibliography supports ADR 0090 and the authenticated LineageWeave MCP resource server. Product claims are pinned to the current MCP revision or the referenced RFC rather than remembered protocol behavior. ## Product decision crosswalk | Product decision | Source | |---|---| -| MCP protocol revision, JSON-RPC lifecycle | Model Context Protocol 2025-06-18 base protocol | -| Streamable HTTP `/mcp`, POST/GET contract, Origin validation | Model Context Protocol 2025-06-18 transport specification | -| `tools/list`, `tools/call`, structured output, tool annotations | Model Context Protocol 2025-06-18 tools specification | -| MCP HTTP server as OAuth resource server | Model Context Protocol 2025-06-18 authorization specification | +| Stateless core, no `initialize`, optional `server/discover` | MCP 2026-07-28 specification release | +| Per-request `_meta`, server identity, `resultType` | MCP 2026-07-28 SDK migration guidance | +| Required `MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name` headers | MCP 2026-07-28 Streamable HTTP specification | +| Cache hints on `tools/list` | MCP 2026-07-28 specification release | +| OAuth issuer hardening and DCR deprecation direction | MCP 2026-07-28 authorization changes; RFC 9207 | | Protected-resource metadata and `WWW-Authenticate` discovery | RFC 9728 | -| Resource-bound token request/audience | RFC 8707 | +| Resource-bound access-token audience | RFC 8707 | | Authorization-server discovery metadata | RFC 8414 | -| Codex may store MCP OAuth credentials in an OS keyring | OpenAI Codex operational security guidance | +| Codex MCP OAuth credentials may be stored in an OS keyring | OpenAI Codex operational security guidance | ## APA 7th references @@ -23,12 +24,12 @@ Jones, M. B., Hunt, P., & Parecki, A. (2025). *OAuth 2.0 protected resource meta Jones, M., Sakimura, N., & Bradley, J. (2018). *OAuth 2.0 authorization server metadata* (RFC 8414). Internet Engineering Task Force. https://doi.org/10.17487/RFC8414 -Model Context Protocol. (2025, June 18). *Authorization*. https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization +Jones, M., Bradley, J., & Sakimura, N. (2022). *OAuth 2.0 authorization server issuer identification* (RFC 9207). Internet Engineering Task Force. https://doi.org/10.17487/RFC9207 -Model Context Protocol. (2025, June 18). *Base protocol overview*. https://modelcontextprotocol.io/specification/2025-06-18/basic +Model Context Protocol. (2026, July 28). *The 2026-07-28 specification*. Model Context Protocol Blog. https://blog.modelcontextprotocol.io/posts/2026-07-28/ -Model Context Protocol. (2025, June 18). *Tools*. https://modelcontextprotocol.io/specification/2025-06-18/server/tools +Model Context Protocol. (2026). *Streamable HTTP: Protocol revision 2026-07-28*. https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2026-07-28/basic/transports/streamable-http.mdx -Model Context Protocol. (2025, June 18). *Transports*. https://modelcontextprotocol.io/specification/2025-06-18/basic/transports +Model Context Protocol. (2026). *Supporting protocol revision 2026-07-28*. MCP TypeScript SDK. https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28 OpenAI. (2026). *Running Codex safely at OpenAI*. https://openai.com/index/running-codex-safely/ From cbf5ed37c8ea6c715f6c6349be0a15c4ff6b131c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:15:14 -0700 Subject: [PATCH 14/17] docs(mcp): document stateless current protocol --- docs/MCP_SERVER.md | 72 +++++++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/docs/MCP_SERVER.md b/docs/MCP_SERVER.md index ee0263f9..4f9f5432 100644 --- a/docs/MCP_SERVER.md +++ b/docs/MCP_SERVER.md @@ -4,24 +4,41 @@ LineageWeave exposes the Buyer Global Ask workflow as a separately deployable, a ## Surface -The first MCP release intentionally exposes one tool only: +The first release exposes one read-only tool: ```text global_ask(question) ``` -It is read-only. It uses the same `post_read` permission, per-post corporate-entity ABAC predicate, bounded Global Ask source assembler, persisted evidence, and contextual-orchestrator answer channel as the Buyer product. It does not expose SQL, unrestricted post bodies, admin operations, tickets, analysis-run writes, or arbitrary graph queries. +It uses the same persisted `post_read` permission, per-post corporate-entity ABAC predicate, bounded Global Ask source assembler, evidence, and contextual-orchestrator answer channel as the Buyer product. It does not expose SQL, unrestricted post bodies, admin operations, tickets, analysis-run writes, or arbitrary graph queries. -## Run locally +## Protocol + +The server targets **MCP 2026-07-28**. The protocol core is stateless: there is no `initialize` handshake or protocol session. Clients may probe `server/discover`; every request otherwise stands alone. + +Every POST must carry: + +```text +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: +``` + +`tools/call` also carries: -Install the backend extra and start the MCP app independently from the Buyer API: +```text +Mcp-Name: global_ask +``` + +The JSON body carries the same protocol version and client capabilities in `params._meta`. LineageWeave rejects header/body mismatches and unsupported protocol revisions. `tools/list` returns `ttlMs=0` and `cacheScope=private`; successful responses include `resultType=complete` and server identity in result `_meta`. + +## Run locally ```bash uv sync --frozen --extra backend --extra dev uv run uvicorn backend.app.mcp_server:app --host 127.0.0.1 --port 18421 ``` -The local development defaults are: +Local development endpoints: ```text MCP endpoint: http://localhost:18421/mcp @@ -34,46 +51,37 @@ Production must publish an HTTPS MCP resource URI and configure the authorizatio ```text LINEAGEWEAVE_MCP_RESOURCE_URI -- canonical OAuth resource identifier for the MCP endpoint +- canonical OAuth resource identifier - local default: http://localhost:18421/mcp -- production: externally reachable HTTPS /mcp URI +- HTTPS required outside loopback development LINEAGEWEAVE_MCP_ALLOWED_ORIGINS - comma-separated exact browser Origins permitted to reach /mcp -- empty means every request carrying an Origin header is rejected -- non-browser MCP clients may omit Origin +- empty means requests carrying Origin are rejected +- non-browser clients may omit Origin LINEAGEWEAVE_MCP_REQUESTS_PER_MINUTE -- per-account bounded tool-call rate +- distributed per-account tool-call limit stored in existing Valkey - default 30, allowed range 1..600 ``` -The existing LineageWeave OIDC variables select Keyverse or the explicit local Keycloak fallback. The MCP verifier additionally requires: - -- a non-empty JWT `kid` that exactly selects an acceptable RSA/RS256 JWKS key; -- matching issuer; -- matching MCP resource audience; -- normal JWT time validation with the configured bounded clock skew; -- a provisioned LineageWeave `user_account`; -- persisted `post_read` permission. - -Corporate affiliations and permissions are loaded from LineageWeave PostgreSQL. Untrusted token attributes cannot widen evidence access. +The MCP verifier additionally requires a non-empty exact RSA/RS256 JWT `kid`, matching issuer, matching MCP resource audience, normal JWT time validation, a provisioned LineageWeave account, and persisted `post_read`. Corporate affiliations and permissions are loaded from PostgreSQL; token-side business attributes cannot widen evidence access. ## Authorization-server requirement -The MCP server is an OAuth protected resource, not an authorization server. The configured Keyverse/OIDC authorization server must support the MCP client's authorization flow and issue a resource-bound access token for `LINEAGEWEAVE_MCP_RESOURCE_URI`. The MCP endpoint publishes RFC 9728 protected-resource metadata so a conforming client can discover the issuer. +The MCP process is an OAuth protected resource, not an authorization server. It publishes RFC 9728 protected-resource metadata. The configured Keyverse/OIDC authorization server must support the MCP client authorization flow and mint a resource-bound access token for `LINEAGEWEAVE_MCP_RESOURCE_URI`. -If Keyverse has not yet been configured to issue an access token for that resource, MCP authentication must fail rather than accepting the ordinary frontend token by disabling audience verification. +If the authorization server has not been configured for that resource, MCP authentication must fail rather than accepting a frontend token by disabling audience verification. Authorization-client configuration should follow the current MCP 2026-07-28 issuer-validation and client-registration guidance. ## Codex -Codex supports remote MCP use and MCP OAuth credentials can be stored in the operating-system keyring. Configure Codex to connect to the externally reachable LineageWeave `/mcp` URL and complete the OAuth flow offered through the protected-resource metadata discovery path. Do not place bearer tokens in this repository or in an `AGENTS.md` file. +Configure Codex to connect to the externally reachable LineageWeave `/mcp` endpoint and complete the OAuth flow discovered through the protected-resource metadata. OpenAI's operational guidance allows MCP OAuth credentials to be stored in the operating-system keyring. Do not commit bearer tokens or place them in `AGENTS.md`. -The exact Codex client-registration workflow depends on the authorization-server deployment. Keep client registration in Keyverse/identity configuration; do not add a Codex-specific authentication bypass to LineageWeave. +The exact client registration belongs in Keyverse/identity configuration; do not add a Codex-specific authentication bypass to LineageWeave. ## Result contract -A successful `global_ask` tool call returns both MCP text content and `structuredContent`: +A successful tool call returns MCP text content plus `structuredContent`: ```json { @@ -86,20 +94,20 @@ A successful `global_ask` tool call returns both MCP text content and `structure } ``` -When no authorized source exists, the answer is empty and `next_action` explains that no authorized evidence is available. A missing or incomplete contextual-orchestrator result is an MCP tool execution error; LineageWeave does not manufacture an answer. +When no authorized source exists, the answer is empty and `next_action` explains that no authorized evidence is available. A missing or incomplete contextual-orchestrator result is a tool execution error; LineageWeave does not manufacture an answer. ## Security properties - No inbound MCP bearer token is passed to contextual-orchestrator or the Buyer REST API. - Authorization happens before source normalization and model context assembly. -- Browser Origin validation prevents an unrelated web origin from reaching the MCP endpoint. +- Browser Origin validation fails closed. - Questions are bounded to 4,000 characters. -- The tool catalog is static and contains no write tool. -- The audit log records account id and counts only, not question text, answer text, source bodies, or tokens. -- This first rate limiter is process-local. Run a single MCP replica until a shared rate-limit backend is implemented. +- The tool catalog contains no write tool. +- Valkey rate limiting works across MCP replicas without introducing MCP protocol session state. +- Audit logging records opaque account id and counts only, not question text, answer text, source bodies, or credentials. ## Stack prerequisites -This feature is stacked on PR #264. It is not permission to bypass existing review or merge gates. In particular, the accumulated Buyer stack must first resolve the #258 review blocker and #264 analysis-run cutoff propagation finding, then revalidate exact-head checks in stack order. +This feature is stacked on PR #264. It does not permit bypassing existing review or merge gates. The accumulated Buyer stack must first resolve the #258 review blocker and #264 analysis-run cutoff propagation finding, then revalidate exact-head checks in stack order. -See ADR 0090 and `docs/doctoring/MCP_REFERENCES.md` for the normative protocol and OAuth traceability. +See ADR 0090 and `docs/doctoring/MCP_REFERENCES.md` for normative protocol and OAuth traceability. From d9ec9b0c1d377eb43abca9a26f15981ec9976f67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:15:31 -0700 Subject: [PATCH 15/17] docs(mcp): update release note to 2026 protocol --- CHANGELOG.d/2.18.0-authenticated-global-ask-mcp.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.d/2.18.0-authenticated-global-ask-mcp.md b/CHANGELOG.d/2.18.0-authenticated-global-ask-mcp.md index 3922cdeb..2e4d0bd0 100644 --- a/CHANGELOG.d/2.18.0-authenticated-global-ask-mcp.md +++ b/CHANGELOG.d/2.18.0-authenticated-global-ask-mcp.md @@ -2,17 +2,19 @@ ## Added -- Added a separately deployable MCP 2025-06-18 Streamable HTTP resource server with one read-only `global_ask` tool. +- Added a separately deployable, stateless MCP 2026-07-28 Streamable HTTP resource server with one read-only `global_ask` tool. +- Added `server/discover`, per-request MCP `_meta`, required routing headers, deterministic private/no-cache tool-list hints, `resultType=complete`, and server identity metadata. - Added RFC 9728 protected-resource metadata, exact MCP resource audience validation, non-empty exact JWKS `kid` selection, and browser Origin allow-listing. - Reused the existing Global Ask authorization/evidence assembler and contextual-orchestrator channel without bearer-token passthrough. - Added structured tool output containing answer text, citations, provenance-bearing citation evidence, bounded source ids, and explicit no-evidence next action. -- Added per-account bounded invocation rate limiting without logging question or answer text. +- Added distributed per-account invocation rate limiting through the existing Valkey service without logging question or answer text. ## Security - MCP access is denied unless the OIDC subject maps to a provisioned LineageWeave account with `post_read`; per-row corporate-entity ABAC remains in force before source normalization. - Inbound MCP bearer credentials are never forwarded to contextual-orchestrator or the browser-facing REST API. -- Unknown browser Origins fail closed and JSON-RPC batching is rejected. +- Unknown browser Origins fail closed; header/body protocol mismatches and unsupported revisions fail closed; JSON-RPC batching is rejected. +- Non-loopback MCP resource identifiers require HTTPS. ## Known prerequisite blockers From 6b1e5400f0b8f9aa4238c0fcdf876a90baa2fd1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:17:47 -0700 Subject: [PATCH 16/17] fix(mcp): conform version and header errors to final schema --- backend/app/mcp_server.py | 68 ++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index ede2401a..fa578443 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -51,7 +51,6 @@ _HEADER_MISMATCH = -32020 _UNSUPPORTED_PROTOCOL_VERSION = -32022 - _SERVER_INFO = {"name": _SERVER_NAME, "version": _SERVER_VERSION} _GLOBAL_ASK_OUTPUT_SCHEMA: dict[str, Any] = { @@ -146,18 +145,22 @@ def load_mcp_settings() -> McpRuntimeSettings: ) -def _resource_metadata_url(request: Request) -> str: - """Return the RFC 9728 metadata URL advertised in 401 challenges.""" - return str(request.base_url).rstrip("/") + "/.well-known/oauth-protected-resource/mcp" +def _resource_metadata_url(mcp_settings: McpRuntimeSettings) -> str: + """Return the canonical RFC 9728 metadata URL for the configured resource.""" + parsed = urlsplit(mcp_settings.resource_uri) + path = parsed.path if parsed.path.startswith("/") else f"/{parsed.path}" + return f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{path}" -def _validate_origin(request: Request, mcp_settings: McpRuntimeSettings) -> None: - """Reject browser origins not explicitly authorized for this MCP server.""" +def _validate_transport_target(request: Request, mcp_settings: McpRuntimeSettings) -> None: + """Validate browser Origin and canonical Host to bound DNS-rebinding surface.""" origin = request.headers.get("origin") - if origin is None: - return - if origin not in mcp_settings.allowed_origins: + if origin is not None and origin not in mcp_settings.allowed_origins: raise HTTPException(status.HTTP_403_FORBIDDEN, "MCP Origin is not allowed") + canonical_host = urlsplit(mcp_settings.resource_uri).netloc.lower() + request_host = request.headers.get("host", "").lower() + if request_host and request_host != canonical_host: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "MCP Host does not match the configured resource") def _mcp_jwks(settings: Settings) -> dict[str, Any]: @@ -391,6 +394,16 @@ def _jsonrpc_error( return {"jsonrpc": "2.0", "id": request_id, "error": error} +def _unsupported_version_error(request_id: Any, requested: Any) -> dict[str, Any]: + """Build the final 2026 UnsupportedProtocolVersion wire shape.""" + return _jsonrpc_error( + request_id, + _UNSUPPORTED_PROTOCOL_VERSION, + "Unsupported protocol version", + data={"supported": [_PROTOCOL_VERSION], "requested": str(requested or "")}, + ) + + def _request_envelope(message: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: """Validate the stateless per-request MCP metadata envelope.""" params = message.get("params") @@ -403,7 +416,7 @@ def _request_envelope(message: dict[str, Any]) -> tuple[dict[str, Any], dict[str raise ValueError("params._meta is required") protocol_version = meta.get(_META_PROTOCOL_VERSION) if protocol_version != _PROTOCOL_VERSION: - raise RuntimeError(str(protocol_version)) + raise RuntimeError(str(protocol_version or "")) client_capabilities = meta.get(_META_CLIENT_CAPABILITIES) if not isinstance(client_capabilities, dict): raise ValueError("clientCapabilities must be an object") @@ -422,8 +435,8 @@ def _expected_mcp_name(method: Any, params: dict[str, Any]) -> str | None: name = params.get("name") return name if isinstance(name, str) else None if method in {"resources/read", "prompts/get"}: - uri_or_name = params.get("uri") if method == "resources/read" else params.get("name") - return uri_or_name if isinstance(uri_or_name, str) else None + source = params.get("uri") if method == "resources/read" else params.get("name") + return source if isinstance(source, str) else None return None @@ -434,21 +447,18 @@ def _validate_transport_headers(request: Request, message: dict[str, Any]) -> JS meta = params.get("_meta") if isinstance(params, dict) else None body_version = meta.get(_META_PROTOCOL_VERSION) if isinstance(meta, dict) else None header_version = request.headers.get("mcp-protocol-version") - if header_version != _PROTOCOL_VERSION or body_version != _PROTOCOL_VERSION: + + if header_version != body_version: return JSONResponse( - _jsonrpc_error( - request_id, - _UNSUPPORTED_PROTOCOL_VERSION, - "Unsupported protocol version", - data={"supportedVersions": [_PROTOCOL_VERSION]}, - ), + _jsonrpc_error(request_id, _HEADER_MISMATCH, "MCP-Protocol-Version header mismatch"), status_code=status.HTTP_400_BAD_REQUEST, ) - if header_version != body_version: + if header_version != _PROTOCOL_VERSION: return JSONResponse( - _jsonrpc_error(request_id, _HEADER_MISMATCH, "MCP-Protocol-Version header mismatch"), + _unsupported_version_error(request_id, header_version), status_code=status.HTTP_400_BAD_REQUEST, ) + method = message.get("method") method_header = request.headers.get("mcp-method") if not isinstance(method, str) or method_header != method: @@ -489,16 +499,8 @@ async def _dispatch_message( return None, status.HTTP_202_ACCEPTED try: params, _meta = _request_envelope(message) - except RuntimeError: - return ( - _jsonrpc_error( - request_id, - _UNSUPPORTED_PROTOCOL_VERSION, - "Unsupported protocol version", - data={"supportedVersions": [_PROTOCOL_VERSION]}, - ), - status.HTTP_400_BAD_REQUEST, - ) + except RuntimeError as exc: + return _unsupported_version_error(request_id, str(exc)), status.HTTP_400_BAD_REQUEST except ValueError as exc: return _jsonrpc_error(request_id, -32602, str(exc)), status.HTTP_200_OK @@ -616,7 +618,7 @@ async def mcp_get() -> Response: async def mcp_post(request: Request) -> Response: """Handle one authenticated MCP 2026-07-28 Streamable HTTP request.""" mcp_settings = load_mcp_settings() - _validate_origin(request, mcp_settings) + _validate_transport_target(request, mcp_settings) try: message = await request.json() except (json.JSONDecodeError, UnicodeDecodeError, ValueError): @@ -640,7 +642,7 @@ async def mcp_post(request: Request) -> Response: status_code=exc.status_code, headers={ "WWW-Authenticate": ( - 'Bearer resource_metadata="' + _resource_metadata_url(request) + '"' + 'Bearer resource_metadata="' + _resource_metadata_url(mcp_settings) + '"' ) }, ) From 529b4c236413ff2d4cb8271febdf327bac27ac58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 15:18:31 -0700 Subject: [PATCH 17/17] test(mcp): cover final header precedence and host policy --- tests/test_mcp_server.py | 47 +++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 0e50dc38..eb40ebc1 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -112,17 +112,34 @@ def test_mcp_settings_validate_resource_and_rate(monkeypatch: pytest.MonkeyPatch mcp.load_mcp_settings() -def test_origin_is_fail_closed_only_when_browser_origin_is_present() -> None: +def test_transport_target_checks_origin_and_host() -> None: settings = mcp.McpRuntimeSettings( resource_uri="https://lineage.example/mcp", allowed_origins=frozenset({"https://codex.example"}), requests_per_minute=30, ) - mcp._validate_origin(_request(), settings) - mcp._validate_origin(_request(origin="https://codex.example"), settings) - with pytest.raises(Exception) as error: - mcp._validate_origin(_request(origin="https://evil.example"), settings) - assert getattr(error.value, "status_code", None) == 403 + mcp._validate_transport_target(_request(), settings) + mcp._validate_transport_target( + _request(origin="https://codex.example", headers={"Host": "lineage.example"}), settings + ) + with pytest.raises(Exception) as origin_error: + mcp._validate_transport_target(_request(origin="https://evil.example"), settings) + assert getattr(origin_error.value, "status_code", None) == 403 + with pytest.raises(Exception) as host_error: + mcp._validate_transport_target(_request(headers={"Host": "evil.example"}), settings) + assert getattr(host_error.value, "status_code", None) == 400 + + +def test_resource_metadata_url_uses_canonical_resource_not_request_host() -> None: + settings = mcp.McpRuntimeSettings( + resource_uri="https://lineage.example/public/mcp", + allowed_origins=frozenset(), + requests_per_minute=30, + ) + assert ( + mcp._resource_metadata_url(settings) + == "https://lineage.example/.well-known/oauth-protected-resource/public/mcp" + ) def test_signing_key_requires_nonempty_exact_kid(monkeypatch: pytest.MonkeyPatch) -> None: @@ -208,7 +225,7 @@ def test_request_envelope_requires_current_protocol_and_capabilities() -> None: mcp._request_envelope(old) -def test_transport_headers_must_match_body() -> None: +def test_transport_headers_follow_final_error_precedence() -> None: message = _message("tools/call", name="global_ask", arguments={"question": "Q"}) valid = _request( headers={ @@ -219,27 +236,31 @@ def test_transport_headers_must_match_body() -> None: ) assert mcp._validate_transport_headers(valid, message) is None - bad_method = _request( + mismatch = _request( headers={ - "MCP-Protocol-Version": "2026-07-28", - "Mcp-Method": "tools/list", + "MCP-Protocol-Version": "2025-06-18", + "Mcp-Method": "tools/call", "Mcp-Name": "global_ask", } ) - response = mcp._validate_transport_headers(bad_method, message) + response = mcp._validate_transport_headers(mismatch, message) assert response.status_code == 400 assert b'"code":-32020' in response.body - old_version = _request( + old_message = _message("tools/call", name="global_ask", arguments={"question": "Q"}) + old_message["params"]["_meta"]["io.modelcontextprotocol/protocolVersion"] = "2025-06-18" + old_header = _request( headers={ "MCP-Protocol-Version": "2025-06-18", "Mcp-Method": "tools/call", "Mcp-Name": "global_ask", } ) - response = mcp._validate_transport_headers(old_version, message) + response = mcp._validate_transport_headers(old_header, old_message) assert response.status_code == 400 assert b'"code":-32022' in response.body + assert b'"supported":["2026-07-28"]' in response.body + assert b'"requested":"2025-06-18"' in response.body def test_discover_and_tool_catalog_are_stateless_current_protocol() -> None: