From fa5a423cf9397f90ab8a8c4d94fb209e4701141c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 02:35:02 +0900 Subject: [PATCH 1/2] feat: expose durable Global Ask through authenticated MCP --- .env.example | 10 +- .../2.18.1-current-contract-mcp-global-ask.md | 6 + README.md | 14 +- backend/app/auth.py | 32 +- backend/app/config.py | 78 ++++- backend/app/global_ask_service.py | 92 ++++++ backend/app/main.py | 91 ++---- backend/app/mcp_admission.py | 125 ++++++++ backend/app/mcp_auth.py | 65 ++++ backend/app/mcp_rate_limit.py | 69 ++++ backend/app/mcp_server.py | 296 ++++++++++++++++++ docker-compose.yml | 48 +++ docker/keycloak/realm-export.json | 10 + .../0218-current-contract-mcp-global-ask.md | 90 ++++++ docs/adr/README.md | 1 + docs/doctoring/MCP_GLOBAL_ASK_REFERENCES.md | 31 ++ docs/product-requirements.md | 15 + docs/product-technical-gap-baseline.md | 1 + pyproject.toml | 2 + tests/test_global_ask_service.py | 177 +++++++++++ tests/test_mcp_admission.py | 124 ++++++++ tests/test_mcp_current_contract.py | 78 +++++ tests/test_mcp_rate_limit.py | 77 +++++ uv.lock | 279 +++++++++++++++++ 24 files changed, 1721 insertions(+), 90 deletions(-) create mode 100644 CHANGELOG.d/2.18.1-current-contract-mcp-global-ask.md create mode 100644 backend/app/global_ask_service.py create mode 100644 backend/app/mcp_admission.py create mode 100644 backend/app/mcp_auth.py create mode 100644 backend/app/mcp_rate_limit.py create mode 100644 backend/app/mcp_server.py create mode 100644 docs/adr/0218-current-contract-mcp-global-ask.md create mode 100644 docs/doctoring/MCP_GLOBAL_ASK_REFERENCES.md create mode 100644 tests/test_global_ask_service.py create mode 100644 tests/test_mcp_admission.py create mode 100644 tests/test_mcp_current_contract.py create mode 100644 tests/test_mcp_rate_limit.py diff --git a/.env.example b/.env.example index 06cb82d91..f3912236f 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,8 @@ # Copy to .env to override. Every value below already has a working # default baked into docker-compose.yml (see ${VAR:-default} references) -- # `docker compose up` succeeds from a clean checkout with no .env file at -# all. These defaults are throwaway local-dev-only credentials, not +# all for the default profile. The optional MCP profile requires measured +# quota inputs below. Other defaults are throwaway local-dev-only credentials, not # production secrets; see docs/adr/0001-demo-identity-and-data-boundary.md. # Host ports deliberately avoid each service's own default (5432, 6379, @@ -27,6 +28,13 @@ OIDC_AUDIENCE=lineageweave-api BACKEND_PORT=18420 +# Optional authenticated MCP profile. The quota pair is mandatory when the +# profile is enabled and must come from that deployment's k6 capacity evidence. +MCP_PORT=18001 +MCP_ALLOWED_ORIGINS= +MCP_RATE_LIMIT_REQUESTS= +MCP_RATE_LIMIT_WINDOW_SECONDS= + # 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. diff --git a/CHANGELOG.d/2.18.1-current-contract-mcp-global-ask.md b/CHANGELOG.d/2.18.1-current-contract-mcp-global-ask.md new file mode 100644 index 000000000..54200561a --- /dev/null +++ b/CHANGELOG.d/2.18.1-current-contract-mcp-global-ask.md @@ -0,0 +1,6 @@ +### Added + +- Added an authenticated Streamable HTTP MCP adapter that queues and reads the + same durable Global Ask jobs as REST, with exact-resource OAuth, bounded + pre-auth request admission, owner/affiliation scope preservation, and a + fail-closed distributed quota whose capacity inputs are deployment evidence. diff --git a/README.md b/README.md index 22633f79d..5f1480b40 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ carrying `corp_code` / `pu_code` as token claims -- these are throwaway local-dev credentials in a locally-run realm, never the org's real Keyverse tenant (see ADR 0001 for why). -Host ports (15432, 16379, 18080, 18420) deliberately avoid each service's +Host ports (15432, 16379, 18080, 18001, 18420) deliberately avoid each service's own default -- a dev machine commonly already runs its own Postgres/Redis/local server on those. Override via `.env` (copy `.env.example`) or inline if even those collide, e.g. @@ -156,6 +156,18 @@ make seed # scripts/seed_demo_data.py: inserts synthetic corp/account/post curl http://localhost:18420/healthz ``` +The optional authenticated MCP resource server submits and reads the same +durable Global Ask jobs as REST. Enable it only with quota values established +by the deployment's k6 capacity evidence; the service intentionally has no +guessed request/window defaults: + +```bash +MCP_RATE_LIMIT_REQUESTS= \ +MCP_RATE_LIMIT_WINDOW_SECONDS= \ +docker compose --profile mcp up mcp +# Streamable HTTP resource: http://localhost:18001/mcp +``` + `GET /api/posts`, `GET /api/posts/{post_id}`, `GET /api/posts/{post_id}/keymen`, `GET /api/keymen/{person_id}/related`, `GET /api/posts/{post_id}/affiliate-tree`, diff --git a/backend/app/auth.py b/backend/app/auth.py index e34c4c668..adcf71949 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -125,8 +125,10 @@ def has_permission(self, permission_code: str) -> bool: return permission_code in self.permission_codes -def _decode_access_token(token: str, settings: Settings) -> dict: - """Validate signature, issuer, resource audience, time claims, and subject.""" +def decode_access_token( + token: str, settings: Settings, *, audience: str | None = None +) -> dict: + """Validate a token for the REST or an explicit resource audience.""" required_claims = ["exp", "sub"] if settings.keyverse_claim_binding_required: required_claims.insert(1, "iat") @@ -136,7 +138,7 @@ def _decode_access_token(token: str, settings: Settings) -> dict: key=_signing_key(settings, token), algorithms=["RS256"], issuer=settings.oidc_issuer, - audience=settings.oidc_audience, + audience=audience or settings.oidc_audience, leeway=settings.oidc_clock_skew_seconds, options={"require": required_claims}, ) @@ -150,6 +152,11 @@ def _decode_access_token(token: str, settings: Settings) -> dict: return claims +def _decode_access_token(token: str, settings: Settings) -> dict: + """Validate a REST bearer token against the configured API audience.""" + return decode_access_token(token, settings) + + def _keyverse_account_claims(claims: dict) -> tuple[str, str, list[str]]: """Return Keyverse's atomic account scope, rejecting ambiguous wire shapes.""" organization = claims.get("org") @@ -177,13 +184,10 @@ def _keyverse_account_claims(claims: dict) -> tuple[str, str, list[str]]: return organization, workspace, [role.strip() for role in roles] -async def get_current_account( - credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme), - pool: asyncpg.Pool = Depends(get_pool), +async def resolve_current_account( + pool: asyncpg.Pool, claims: dict, settings: Settings ) -> CurrentAccount: - """Resolve the bearer token to a provisioned ``user_account`` row.""" - settings = load_settings() - claims = _decode_access_token(credentials.credentials, settings) + """Resolve verified claims to database-owned scope and permissions.""" subject = claims["sub"] keyverse_scope = ( _keyverse_account_claims(claims) @@ -269,3 +273,13 @@ async def get_current_account( process_unit_ids=frozenset(str(row["process_unit_id"]) for row in process_rows), permission_codes=frozenset(row["permission_code"] for row in permission_rows), ) + + +async def get_current_account( + credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme), + pool: asyncpg.Pool = Depends(get_pool), +) -> CurrentAccount: + """Resolve the bearer token to a provisioned ``user_account`` row.""" + settings = load_settings() + claims = _decode_access_token(credentials.credentials, settings) + return await resolve_current_account(pool, claims, settings) diff --git a/backend/app/config.py b/backend/app/config.py index 827441648..a49bd5390 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -6,7 +6,7 @@ import math import os -from dataclasses import dataclass +from dataclasses import dataclass, field # Hard ceiling on one Global Ask job's answer computation, shared with the # worker in global_ask_queue.py so config validation and execution can never @@ -65,6 +65,16 @@ class Settings: naruon_calendar_service_token: str rankweave_disabled: bool ontology_source_cursor_secret: str + mcp_resource_url: str = "http://localhost:18001/mcp" + mcp_audience: str = "http://localhost:18001/mcp" + mcp_required_scopes: list[str] = field(default_factory=list) + mcp_allowed_hosts: list[str] = field( + default_factory=lambda: ["localhost:*", "127.0.0.1:*", "mcp:8001"] + ) + mcp_allowed_origins: list[str] = field(default_factory=list) + mcp_max_request_bytes: int = 65_536 + mcp_rate_limit_requests: int | None = None + mcp_rate_limit_window_seconds: int | None = None @property def keycloak_jwks_uri(self) -> str: @@ -83,7 +93,9 @@ def _validated_answer_timeout(raw: str) -> float: try: value = float(raw) except ValueError as exc: - raise ValueError("ORCHESTRATOR_ANSWER_TIMEOUT_SECONDS must be a number") from exc + raise ValueError( + "ORCHESTRATOR_ANSWER_TIMEOUT_SECONDS must be a number" + ) from exc if not math.isfinite(value) or not 0 < value < GLOBAL_ASK_JOB_DEADLINE_SECONDS: raise ValueError( "ORCHESTRATOR_ANSWER_TIMEOUT_SECONDS must be a finite number greater" @@ -92,6 +104,20 @@ def _validated_answer_timeout(raw: str) -> float: return value +def _optional_positive_int(name: str) -> int | None: + """Parse an optional positive deployment integer without inventing a default.""" + raw = os.environ.get(name, "").strip() + if not raw: + return None + try: + value = int(raw, 10) + except ValueError as exc: + raise ValueError(f"{name} must be a base-10 integer") from exc + if value <= 0: + raise ValueError(f"{name} must be positive") + return value + + def load_settings() -> Settings: """Read Settings from the environment, with local-dev defaults only.""" keycloak_base_url = os.environ.get("KEYCLOAK_BASE_URL", "http://localhost:18080") @@ -103,7 +129,9 @@ def load_settings() -> Settings: keyverse_issuer = os.environ.get("KEYVERSE_ISSUER", "").strip() generic_oidc_issuer = os.environ.get("OIDC_ISSUER", "").strip() external_oidc = bool(keyverse_issuer or generic_oidc_issuer) - oidc_issuer = (keyverse_issuer or generic_oidc_issuer or keycloak_issuer).rstrip("/") + oidc_issuer = (keyverse_issuer or generic_oidc_issuer or keycloak_issuer).rstrip( + "/" + ) oidc_client_id = ( os.environ.get("KEYVERSE_CLIENT_ID", "").strip() or os.environ.get("OIDC_CLIENT_ID", "").strip() @@ -119,9 +147,13 @@ def load_settings() -> Settings: "do not infer a resource-server audience from the browser client id" ) oidc_audience = configured_audience or "lineageweave-api" - oidc_discovery_uri = os.environ.get("KEYVERSE_DISCOVERY_URI", "").strip() or os.environ.get( - "OIDC_DISCOVERY_URI", "" + mcp_resource_url = os.environ.get( + "MCP_RESOURCE_URL", "http://localhost:18001/mcp" ).strip() + oidc_discovery_uri = ( + os.environ.get("KEYVERSE_DISCOVERY_URI", "").strip() + or os.environ.get("OIDC_DISCOVERY_URI", "").strip() + ) if not oidc_discovery_uri: discovery_base = oidc_issuer if external_oidc else keycloak_base_url oidc_discovery_uri = ( @@ -161,7 +193,9 @@ def load_settings() -> Settings: keyverse_claim_binding_required=bool(keyverse_issuer), frontend_origins=[ origin.strip() - for origin in os.environ.get("FRONTEND_ORIGINS", "http://localhost:5173").split(",") + for origin in os.environ.get( + "FRONTEND_ORIGINS", "http://localhost:5173" + ).split(",") if origin.strip() ], orchestrator_base_url=os.environ.get("ORCHESTRATOR_BASE_URL", ""), @@ -178,9 +212,33 @@ def load_settings() -> Settings: naruon_calendar_service_token=os.environ.get( "NARUON_CALENDAR_SERVICE_TOKEN", "" ).strip(), - rankweave_disabled=os.environ.get("RANKWEAVE_DISABLED", "") - .strip() - .lower() + rankweave_disabled=os.environ.get("RANKWEAVE_DISABLED", "").strip().lower() in {"1", "true", "yes", "on"}, - ontology_source_cursor_secret=os.environ.get("ONTOLOGY_SOURCE_CURSOR_SECRET", "").strip(), + ontology_source_cursor_secret=os.environ.get( + "ONTOLOGY_SOURCE_CURSOR_SECRET", "" + ).strip(), + mcp_resource_url=mcp_resource_url, + mcp_audience=os.environ.get("MCP_AUDIENCE", mcp_resource_url).strip(), + mcp_required_scopes=[ + item.strip() + for item in os.environ.get("MCP_REQUIRED_SCOPES", "").split(",") + if item.strip() + ], + mcp_allowed_hosts=[ + item.strip() + for item in os.environ.get( + "MCP_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*,mcp:8001" + ).split(",") + if item.strip() + ], + mcp_allowed_origins=[ + item.strip() + for item in os.environ.get("MCP_ALLOWED_ORIGINS", "").split(",") + if item.strip() + ], + mcp_max_request_bytes=_optional_positive_int("MCP_MAX_REQUEST_BYTES") or 65_536, + mcp_rate_limit_requests=_optional_positive_int("MCP_RATE_LIMIT_REQUESTS"), + mcp_rate_limit_window_seconds=_optional_positive_int( + "MCP_RATE_LIMIT_WINDOW_SECONDS" + ), ) diff --git a/backend/app/global_ask_service.py b/backend/app/global_ask_service.py new file mode 100644 index 000000000..a8f398a7c --- /dev/null +++ b/backend/app/global_ask_service.py @@ -0,0 +1,92 @@ +"""Shared durable Global Ask application service for REST and MCP.""" + +from __future__ import annotations + +import json +from typing import Any +from uuid import UUID + +import asyncpg +import redis.asyncio as redis +from fastapi import HTTPException, status + +from backend.app.auth import CurrentAccount +from backend.app.global_ask_queue import enqueue_global_ask_job +from backend.app.source_post_revision import parse_as_of_clock + + +async def submit_global_ask( + *, + pool: asyncpg.Pool, + valkey: redis.Redis, + account: CurrentAccount, + question: str, + verify_external: bool, + knowledge_cutoff: str | None, + service_available: bool, +) -> dict[str, Any]: + """Validate and enqueue one durable owner-scoped Global Ask job.""" + if not account.has_permission("post_read"): + raise HTTPException(status.HTTP_403_FORBIDDEN, "post_read permission required") + normalized_question = question.strip() + if not normalized_question: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, "question is required" + ) + cutoff = None + if knowledge_cutoff is not None: + try: + cutoff = parse_as_of_clock(knowledge_cutoff) + except ValueError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "knowledge_cutoff must be an ISO-8601 timestamp", + ) from exc + async with pool.acquire() as conn: + if cutoff is not None and cutoff > await conn.fetchval("select now()"): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_CONTENT, + "knowledge_cutoff must be at or before the database clock", + ) + if not service_available: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Ask Agent is unavailable. Ask an administrator to configure the analysis service, then retry.", + ) + job_id = await enqueue_global_ask_job( + conn, + valkey, + requesting_account_id=account.user_account_id, + question_text=normalized_question, + verify_external_requested=verify_external, + knowledge_cutoff=cutoff, + corporate_entity_ids=account.corporate_entity_ids, + process_unit_ids=account.process_unit_ids, + ) + return {"ask_job_id": job_id, "job_status_code": "queued"} + + +async def read_global_ask_job( + *, pool: asyncpg.Pool, account: CurrentAccount, ask_job_id: UUID +) -> dict[str, Any]: + """Read one owner's durable Global Ask status and persisted result.""" + if not account.has_permission("post_read"): + raise HTTPException(status.HTTP_403_FORBIDDEN, "post_read permission required") + async with pool.acquire() as conn: + row = await conn.fetchrow( + "select requesting_account_id, job_status_code, answer_payload," + " failure_detail from global_ask_job where global_ask_job_id = $1", + ask_job_id, + ) + if row is None or str(row["requesting_account_id"]) != account.user_account_id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "ask job not found") + body: dict[str, Any] = { + "ask_job_id": str(ask_job_id), + "job_status_code": row["job_status_code"], + } + if row["job_status_code"] == "succeeded" and row["answer_payload"] is not None: + payload = row["answer_payload"] + body["answer"] = json.loads(payload) if isinstance(payload, str) else payload + if row["job_status_code"] == "failed": + body["failure_detail"] = row["failure_detail"] + return body diff --git a/backend/app/main.py b/backend/app/main.py index 85823604e..6457bbde1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -86,9 +86,9 @@ ) from backend.app.five_w1h_ingestion import load_five_w1h_slots from backend.app.global_ask_queue import ( - enqueue_global_ask_job, run_global_ask_worker, ) +from backend.app.global_ask_service import read_global_ask_job, submit_global_ask from backend.app.issue_ticket_ingestion import ( create_ticket, fetch_ticket_post_id, @@ -1267,7 +1267,7 @@ async def resolve_customer_master_hint( ) from exc if resolution is None: raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, + status.HTTP_422_UNPROCESSABLE_CONTENT, "this hint could not be resolved to a corroborated organization name", ) return resolution @@ -1628,7 +1628,7 @@ async def read_post( as_of_clock = parse_as_of_clock(as_of) except ValueError as exc: raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, + status.HTTP_422_UNPROCESSABLE_CONTENT, "as_of must be an ISO-8601 timestamp. Use the run cutoff, " "then compare the known body with the live body.", ) from exc @@ -2252,7 +2252,7 @@ async def read_ontology_neighborhood( try: cutoff_clock = parse_as_of_clock(knowledge_cutoff) except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc try: async with pool.acquire() as conn: neighborhood = await visible_ontology_neighborhood( @@ -2665,7 +2665,7 @@ async def compare_period_groupings( try: parse_period_code(period_code) except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc async with pool.acquire() as conn: rows = await fetch_period_comparison(conn, period_code) demo_entity_ids: set[str] = set() @@ -2721,7 +2721,7 @@ async def list_period_reports( """Available calibrated periods for one grouping kind (FIPC trend).""" _require_post_read(account) if grouping_kind not in GROUPING_KINDS: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "unknown grouping_kind") + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") async with pool.acquire() as conn: summaries = await list_period_report_summaries(conn, grouping_kind) demo_entity_ids: set[str] = set() @@ -2751,11 +2751,11 @@ async def read_period_reports( """Calibrated IRT scores for one grouping kind and calendar period.""" _require_post_read(account) if grouping_kind not in GROUPING_KINDS: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "unknown grouping_kind") + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") try: parse_period_code(period_code) except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc async with pool.acquire() as conn: reports = await fetch_period_reports(conn, grouping_kind, period_code) demo_entity_ids: set[str] = set() @@ -2828,11 +2828,11 @@ async def rebuild_period_report_endpoint( """Refit or FIPC-score every group in the period. post_admin only.""" _require_post_admin(account) if grouping_kind not in GROUPING_KINDS: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "unknown grouping_kind") + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "unknown grouping_kind") try: parse_period_code(period_code) except ValueError as exc: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc async with pool.acquire() as conn: async with conn.transaction(): reports = await rebuild_period_reports(conn, grouping_kind, period_code) @@ -3039,7 +3039,7 @@ async def chat_about_post( question = request.question.strip() if not question: raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, "question is required" + status.HTTP_422_UNPROCESSABLE_CONTENT, "question is required" ) post = await _load_visible_post(post_id, account, pool) post_metadata = build_post_llm_metadata(post_id, post) @@ -3139,44 +3139,15 @@ async def ask_agent( still fails fast on the states that cannot ever succeed (blank question, missing permission, unconfigured orchestrator). """ - question = request.question.strip() - if not question: - raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "question is required") - _require_post_read(account) - knowledge_cutoff = None - if request.knowledge_cutoff is not None: - try: - knowledge_cutoff = parse_as_of_clock(request.knowledge_cutoff) - except ValueError as exc: - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_CONTENT, - "knowledge_cutoff must be an ISO-8601 timestamp", - ) from exc - async with pool.acquire() as conn: - if knowledge_cutoff is not None and knowledge_cutoff > await conn.fetchval( - "select now()" - ): - raise HTTPException( - status.HTTP_422_UNPROCESSABLE_CONTENT, - "knowledge_cutoff must be at or before the database clock", - ) - if not _post_chat_client().available: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - "Ask Agent is unavailable. Ask an administrator to configure the analysis service, " - "then retry.", - ) - job_id = await enqueue_global_ask_job( - conn, - valkey, - requesting_account_id=account.user_account_id, - question_text=question, - verify_external_requested=request.verify_external, - knowledge_cutoff=knowledge_cutoff, - corporate_entity_ids=account.corporate_entity_ids, - process_unit_ids=account.process_unit_ids, - ) - return {"ask_job_id": job_id, "job_status_code": "queued"} + return await submit_global_ask( + pool=pool, + valkey=valkey, + account=account, + question=request.question, + verify_external=request.verify_external, + knowledge_cutoff=request.knowledge_cutoff, + service_available=_post_chat_client().available, + ) @app.get("/api/ask/jobs/{ask_job_id}") @@ -3190,25 +3161,7 @@ async def read_ask_job( Owner-scoped: another account's job id reads as absent (404, not 403) so job ids do not leak their existence across accounts. """ - _require_post_read(account) - async with pool.acquire() as conn: - row = await conn.fetchrow( - "select requesting_account_id, job_status_code, answer_payload," - " failure_detail from global_ask_job where global_ask_job_id = $1", - ask_job_id, - ) - if row is None or str(row["requesting_account_id"]) != account.user_account_id: - raise HTTPException(status.HTTP_404_NOT_FOUND, "ask job not found") - body: dict[str, Any] = { - "ask_job_id": str(ask_job_id), - "job_status_code": row["job_status_code"], - } - if row["job_status_code"] == "succeeded" and row["answer_payload"] is not None: - payload = row["answer_payload"] - body["answer"] = json.loads(payload) if isinstance(payload, str) else payload - if row["job_status_code"] == "failed": - body["failure_detail"] = row["failure_detail"] - return body + return await read_global_ask_job(pool=pool, account=account, ask_job_id=ask_job_id) class PostBookmarkRequest(BaseModel): @@ -3655,7 +3608,7 @@ async def read_calendar( _require_post_read(account) if (window_start is None) ^ (window_end is None): raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, + status.HTTP_422_UNPROCESSABLE_CONTENT, "window_start and window_end must be supplied together", ) settings = load_settings() diff --git a/backend/app/mcp_admission.py b/backend/app/mcp_admission.py new file mode 100644 index 000000000..93bafe7d3 --- /dev/null +++ b/backend/app/mcp_admission.py @@ -0,0 +1,125 @@ +"""Bound MCP request bodies before OAuth and JSON decoding.""" + +from __future__ import annotations + +import json +from collections.abc import Sequence + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + + +class BoundedRequestBodyApp: + """Reject ambiguous or oversized MCP POST bodies before parsing.""" + + def __init__(self, app: ASGIApp, *, maximum_bytes: int) -> None: + """Wrap ``app`` with one positive finite body-size limit.""" + if maximum_bytes <= 0: + raise ValueError("maximum_bytes must be positive") + self._app = app + self._maximum_bytes = maximum_bytes + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Apply admission to HTTP POST and pass other traffic unchanged.""" + if scope["type"] != "http" or scope.get("method") != "POST": + await self._app(scope, receive, send) + return + + content_lengths = _header_values(scope, b"content-length") + transfer_encodings = _header_values(scope, b"transfer-encoding") + declared_length = _parse_content_length(content_lengths, transfer_encodings) + if declared_length is _INVALID_LENGTH: + await _send_error(send, 400, "mcp_invalid_content_length") + return + if isinstance(declared_length, int) and declared_length > self._maximum_bytes: + await _send_error(send, 413, "mcp_request_too_large") + return + + body = bytearray() + while True: + message = await receive() + message_type = message.get("type") + if message_type == "http.disconnect": + await _send_error(send, 400, "mcp_request_disconnected") + return + if message_type != "http.request": + await _send_error(send, 400, "mcp_invalid_request_body") + return + chunk = message.get("body", b"") + if not isinstance(chunk, bytes): + await _send_error(send, 400, "mcp_invalid_request_body") + return + if len(body) + len(chunk) > self._maximum_bytes: + await _send_error(send, 413, "mcp_request_too_large") + return + body.extend(chunk) + if not message.get("more_body", False): + break + + if isinstance(declared_length, int) and declared_length != len(body): + await _send_error(send, 400, "mcp_content_length_mismatch") + return + + replayed = False + + async def replay_receive() -> Message: + """Replay the admitted body once, then report disconnect.""" + nonlocal replayed + if replayed: + return {"type": "http.disconnect"} + replayed = True + return {"type": "http.request", "body": bytes(body), "more_body": False} + + await self._app(scope, replay_receive, send) + + +class _InvalidLength: + """Sentinel distinguishing an invalid length from an absent one.""" + + +_INVALID_LENGTH = _InvalidLength() + + +def _header_values(scope: Scope, name: bytes) -> tuple[bytes, ...]: + """Return every raw value for one case-insensitive request header.""" + return tuple( + value + for header_name, value in scope.get("headers", []) + if header_name.lower() == name + ) + + +def _parse_content_length( + content_lengths: Sequence[bytes], + transfer_encodings: Sequence[bytes], +) -> int | None | _InvalidLength: + """Return an unambiguous nonnegative length, absence, or invalid sentinel.""" + if len(content_lengths) > 1 or (content_lengths and transfer_encodings): + return _INVALID_LENGTH + if not content_lengths: + return None + try: + decoded = content_lengths[0].decode("ascii") + except UnicodeDecodeError: + return _INVALID_LENGTH + if not decoded or not decoded.isdecimal(): + return _INVALID_LENGTH + try: + return int(decoded, 10) + except ValueError: + return _INVALID_LENGTH + + +async def _send_error(send: Send, status_code: int, error_code: str) -> None: + """Send one bounded payload-safe admission error response.""" + body = json.dumps( + {"error_code": error_code}, ensure_ascii=True, separators=(",", ":") + ).encode("ascii") + headers = [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode("ascii")), + (b"cache-control", b"no-store"), + ] + await send( + {"type": "http.response.start", "status": status_code, "headers": headers} + ) + await send({"type": "http.response.body", "body": body}) diff --git a/backend/app/mcp_auth.py b/backend/app/mcp_auth.py new file mode 100644 index 000000000..cb37ea8a7 --- /dev/null +++ b/backend/app/mcp_auth.py @@ -0,0 +1,65 @@ +"""OAuth resource-server token verification for LineageWeave MCP.""" + +from __future__ import annotations + +import asyncio +from functools import partial +from typing import Any + +from fastapi import HTTPException +from mcp.server.auth.provider import AccessToken, TokenVerifier + +from backend.app.auth import decode_access_token +from backend.app.config import Settings + + +def _scopes_from_claim(claim: Any) -> list[str]: + """Normalize string or array scope claims without inventing scopes.""" + if isinstance(claim, str): + return [scope for scope in claim.split() if scope] + if isinstance(claim, list): + return [scope for scope in claim if isinstance(scope, str) and scope] + return [] + + +class KeyverseMcpTokenVerifier(TokenVerifier): + """Validate a JWT for the exact configured MCP resource audience.""" + + def __init__(self, settings: Settings) -> None: + """Retain immutable identity and MCP audience settings.""" + self._settings = settings + + async def verify_token(self, token: str) -> AccessToken | None: + """Return MCP access metadata for a valid token; otherwise fail closed.""" + try: + claims = await asyncio.to_thread( + partial( + decode_access_token, + token, + self._settings, + audience=self._settings.mcp_audience, + ) + ) + except HTTPException: + return None + subject = claims.get("sub") + client_id = claims.get("azp") or claims.get("client_id") + expires_at = claims.get("exp") + if ( + not isinstance(subject, str) + or not subject + or not isinstance(client_id, str) + or not client_id + ): + return None + return AccessToken( + token=token, + client_id=client_id, + scopes=_scopes_from_claim(claims.get("scope")), + expires_at=int(expires_at) + if isinstance(expires_at, (int, float)) + else None, + resource=self._settings.mcp_audience, + subject=subject, + claims=claims, + ) diff --git a/backend/app/mcp_rate_limit.py b/backend/app/mcp_rate_limit.py new file mode 100644 index 000000000..fd4e60e49 --- /dev/null +++ b/backend/app/mcp_rate_limit.py @@ -0,0 +1,69 @@ +"""Valkey-backed quota for authenticated, provisioned MCP accounts.""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from backend.app.activity_stream import create_valkey_client + +_SCRIPT = """ +local count = redis.call('INCR', KEYS[1]) +if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end +local ttl = redis.call('TTL', KEYS[1]) +return {count, ttl} +""" + + +class McpRateLimitExceeded(Exception): + """The account exhausted its current shared window.""" + + def __init__(self, retry_after_seconds: int) -> None: + super().__init__("MCP account rate limit exceeded") + self.retry_after_seconds = retry_after_seconds + + +class McpRateLimiterUnavailable(Exception): + """The shared limiter could not make an authoritative decision.""" + + +class ValkeyMcpRateLimiter: + """Consume one atomic fixed-window quota entry in shared Valkey.""" + + def __init__(self, client: Any, *, request_limit: int, window_seconds: int) -> None: + self._client = client + self._request_limit = request_limit + self._window_seconds = window_seconds + + async def consume(self, user_account_id: str) -> None: + """Consume one provisioned account request or fail closed.""" + digest = hashlib.sha256(user_account_id.encode("utf-8")).hexdigest() + key = f"lineageweave:mcp-rate-limit:v1:{digest}" + try: + result = await self._client.eval(_SCRIPT, 1, key, self._window_seconds) + count, ttl = int(result[0]), int(result[1]) + except Exception as exc: + raise McpRateLimiterUnavailable( + "shared MCP rate limiter unavailable" + ) from exc + if count < 1 or ttl < 0: + raise McpRateLimiterUnavailable( + "shared MCP rate limiter returned invalid state" + ) + if count > self._request_limit: + raise McpRateLimitExceeded(max(1, min(ttl, self._window_seconds))) + + async def close(self) -> None: + """Close the underlying Valkey client.""" + await self._client.aclose() + + +def build_mcp_rate_limiter( + valkey_url: str, request_limit: int, window_seconds: int +) -> ValkeyMcpRateLimiter: + """Build the limiter from validated deployment settings.""" + return ValkeyMcpRateLimiter( + create_valkey_client(valkey_url), + request_limit=request_limit, + window_seconds=window_seconds, + ) diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py new file mode 100644 index 000000000..10c69d39b --- /dev/null +++ b/backend/app/mcp_server.py @@ -0,0 +1,296 @@ +"""Authenticated Streamable HTTP MCP adapter for durable Global Ask.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlsplit +from uuid import UUID + +from mcp.server import MCPServer +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.settings import AuthSettings +from mcp.server.mcpserver import Context +from mcp.server.transport_security import ( + TransportSecurityMiddleware, + TransportSecuritySettings, +) +from mcp.shared.exceptions import MCPError +from mcp.types import ToolAnnotations +from pydantic import AnyHttpUrl +from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from backend.app.activity_stream import create_valkey_client +from backend.app.auth import CurrentAccount, resolve_current_account +from backend.app.config import Settings, load_settings +from backend.app.db import create_pool +from backend.app.global_ask_service import ( + read_global_ask_job as read_global_ask_job_service, +) +from backend.app.global_ask_service import ( + submit_global_ask as submit_global_ask_service, +) +from backend.app.mcp_admission import BoundedRequestBodyApp +from backend.app.mcp_auth import KeyverseMcpTokenVerifier +from backend.app.mcp_rate_limit import ( + McpRateLimiterUnavailable, + McpRateLimitExceeded, + ValkeyMcpRateLimiter, +) +from lineageweave.post_chat import ( + ContextualOrchestratorPostChatClient, + NullPostChatClient, +) + +_RETRY_AFTER_STATE_KEY = "lineageweave.mcp_retry_after_seconds" + + +@dataclass +class McpAppContext: + """Long-lived dependencies shared by MCP tool calls.""" + + pool: Any + valkey: Any + limiter: ValkeyMcpRateLimiter + service_available: bool + settings: Settings + + +class PreAuthTransportSecurityApp: + """Reject hostile Host and Origin metadata before OAuth processing.""" + + def __init__(self, app: ASGIApp, settings: TransportSecuritySettings) -> None: + """Wrap an ASGI app with the SDK transport validator.""" + self._app = app + self._security = TransportSecurityMiddleware(settings) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Validate HTTP transport metadata and pass non-HTTP traffic through.""" + if scope["type"] != "http": + await self._app(scope, receive, send) + return + request = Request(scope, receive=receive) + rejection = await self._security.validate_request( + request, is_post=request.method == "POST" + ) + if rejection is not None: + if request.headers.get("origin") is not None: + rejection.headers.add_vary_header("Origin") + await rejection(scope, receive, send) + return + await self._app(scope, receive, send) + + +class McpRetryAfterHeaderApp: + """Expose a bounded retry delay only for exhausted authenticated quota.""" + + def __init__(self, app: ASGIApp) -> None: + """Wrap the SDK response serializer.""" + self._app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Add Retry-After when a tool call marked the current request.""" + + async def send_with_retry(message: Message) -> None: + """Attach the request-scoped quota delay to the response start.""" + retry_after = scope.get("state", {}).get(_RETRY_AFTER_STATE_KEY) + if message.get("type") == "http.response.start" and isinstance( + retry_after, int + ): + headers = [ + (name, value) + for name, value in message.get("headers", []) + if name.lower() != b"retry-after" + ] + headers.append((b"retry-after", str(retry_after).encode("ascii"))) + message = {**message, "headers": headers} + await send(message) + + await self._app(scope, receive, send_with_retry) + + +def _validate_mcp_settings(settings: Settings) -> tuple[int, int]: + """Require exact origins and measured deployment quota parameters.""" + for origin in settings.mcp_allowed_origins: + parsed = urlsplit(origin) + if ( + origin in {"*", "null"} + or parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + ): + raise ValueError( + "MCP_ALLOWED_ORIGINS entries must be exact HTTP(S) origins" + ) + if ( + settings.mcp_rate_limit_requests is None + or settings.mcp_rate_limit_window_seconds is None + ): + raise ValueError( + "MCP_RATE_LIMIT_REQUESTS and MCP_RATE_LIMIT_WINDOW_SECONDS must be set from measured capacity" + ) + return settings.mcp_rate_limit_requests, settings.mcp_rate_limit_window_seconds + + +async def _account(ctx: Context[McpAppContext, Any]) -> CurrentAccount: + """Resolve the authenticated token to one provisioned database account.""" + token = get_access_token() + if token is None or not token.subject or not isinstance(token.claims, dict): + raise PermissionError("authenticated MCP principal is unavailable") + dependencies = ctx.request_context.lifespan_context + account = await resolve_current_account( + dependencies.pool, token.claims, dependencies.settings + ) + if not account.has_permission("post_read"): + raise PermissionError("post_read permission required") + try: + await dependencies.limiter.consume(account.user_account_id) + except McpRateLimitExceeded as exc: + request = ctx.request_context.request + if isinstance(request, Request): + request.scope.setdefault("state", {})[_RETRY_AFTER_STATE_KEY] = ( + exc.retry_after_seconds + ) + raise MCPError( + -31929, + "mcp_rate_limit_exceeded", + {"retry_after_seconds": exc.retry_after_seconds}, + ) from exc + except McpRateLimiterUnavailable as exc: + raise MCPError(-31930, "mcp_rate_limiter_unavailable") from exc + return account + + +def build_mcp_server(settings: Settings | None = None) -> MCPServer[McpAppContext]: + """Build the authenticated MCP server over the current durable Ask contract.""" + resolved = settings or load_settings() + request_limit, window_seconds = _validate_mcp_settings(resolved) + + @asynccontextmanager + async def lifespan(_: MCPServer) -> AsyncIterator[McpAppContext]: + """Open and close process-wide database and quota clients.""" + pool = await create_pool(resolved.database_url) + valkey = create_valkey_client(resolved.valkey_url) + limiter = ValkeyMcpRateLimiter( + valkey, request_limit=request_limit, window_seconds=window_seconds + ) + chat_client = ( + ContextualOrchestratorPostChatClient( + base_url=resolved.orchestrator_base_url, + api_key=resolved.orchestrator_api_key, + ) + if resolved.orchestrator_base_url and resolved.orchestrator_api_key + else NullPostChatClient() + ) + try: + yield McpAppContext(pool, valkey, limiter, chat_client.available, resolved) + finally: + await limiter.close() + await pool.close() + + server = MCPServer( + "lineageweave", + title="LineageWeave", + description="Authenticated provenance-bearing lineage intelligence.", + version="2.18.0", + lifespan=lifespan, + token_verifier=KeyverseMcpTokenVerifier(resolved), + auth=AuthSettings( + issuer_url=AnyHttpUrl(resolved.oidc_issuer), + resource_server_url=AnyHttpUrl(resolved.mcp_resource_url), + required_scopes=resolved.mcp_required_scopes, + ), + ) + + @server.tool( + title="Submit Global Ask", + description="Queue a question against the caller's authorized LineageWeave evidence.", + annotations=ToolAnnotations( + read_only_hint=False, idempotent_hint=False, open_world_hint=True + ), + ) + async def submit_global_ask( + question: str, + ctx: Context[McpAppContext, Any], + verify_external: bool = False, + knowledge_cutoff: str | None = None, + ) -> dict[str, Any]: + """Queue one current-contract Global Ask job without blocking transport.""" + dependencies = ctx.request_context.lifespan_context + account = await _account(ctx) + return await submit_global_ask_service( + pool=dependencies.pool, + valkey=dependencies.valkey, + account=account, + question=question, + verify_external=verify_external, + knowledge_cutoff=knowledge_cutoff, + service_available=dependencies.service_available, + ) + + @server.tool( + title="Read Global Ask Job", + description="Read a queued Global Ask job owned by the authenticated caller.", + annotations=ToolAnnotations( + read_only_hint=True, idempotent_hint=True, open_world_hint=False + ), + ) + async def read_global_ask_job( + ask_job_id: str, ctx: Context[McpAppContext, Any] + ) -> dict[str, Any]: + """Read one current-contract Global Ask job and its persisted answer.""" + account = await _account(ctx) + try: + parsed_job_id = UUID(ask_job_id) + except ValueError as exc: + raise ValueError("ask_job_id must be a UUID") from exc + return await read_global_ask_job_service( + pool=ctx.request_context.lifespan_context.pool, + account=account, + ask_job_id=parsed_job_id, + ) + + return server + + +def build_mcp_http_app(server: MCPServer[McpAppContext], settings: Settings) -> ASGIApp: + """Build exact-origin, byte-bounded Streamable HTTP outside OAuth.""" + _validate_mcp_settings(settings) + security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=settings.mcp_allowed_hosts, + allowed_origins=settings.mcp_allowed_origins, + ) + sdk_app = server.streamable_http_app(transport_security=security) + cors_app = CORSMiddleware( + McpRetryAfterHeaderApp(sdk_app), + allow_origins=settings.mcp_allowed_origins, + allow_methods=["GET", "POST", "DELETE"], + allow_headers=[ + "Accept", + "Authorization", + "Content-Type", + "Last-Event-ID", + "MCP-Protocol-Version", + "Mcp-Session-Id", + ], + expose_headers=["MCP-Protocol-Version", "Mcp-Session-Id", "WWW-Authenticate"], + allow_credentials=False, + ) + return PreAuthTransportSecurityApp( + BoundedRequestBodyApp(cors_app, maximum_bytes=settings.mcp_max_request_bytes), + security, + ) + + +_settings = load_settings() +mcp = build_mcp_server(_settings) +app = build_mcp_http_app(mcp, _settings) diff --git a/docker-compose.yml b/docker-compose.yml index 195fb0e72..df0ccb618 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -199,6 +199,54 @@ services: searxng: condition: service_healthy + mcp: + profiles: ["mcp"] + build: + context: . + dockerfile: backend/Dockerfile + command: ["uvicorn", "backend.app.mcp_server:app", "--host", "0.0.0.0", "--port", "8001"] + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-lineageweave}:${POSTGRES_PASSWORD:-lineageweave_dev_only}@postgres:5432/${POSTGRES_DB:-lineageweave} + KEYCLOAK_BASE_URL: http://keycloak:8080 + KEYCLOAK_ISSUER: http://localhost:${KEYCLOAK_PORT:-18080}/realms/lineageweave-demo + KEYCLOAK_REALM: lineageweave-demo + KEYVERSE_ISSUER: ${KEYVERSE_ISSUER:-} + KEYVERSE_CLIENT_ID: ${KEYVERSE_CLIENT_ID:-} + KEYVERSE_AUDIENCE: ${KEYVERSE_AUDIENCE:-} + KEYVERSE_DISCOVERY_URI: ${KEYVERSE_DISCOVERY_URI:-} + KEYVERSE_JWKS_URI: ${KEYVERSE_JWKS_URI:-} + OIDC_ISSUER: ${OIDC_ISSUER:-} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_AUDIENCE: lineageweave-api + OIDC_DISCOVERY_URI: ${OIDC_DISCOVERY_URI:-} + OIDC_JWKS_URI: ${OIDC_JWKS_URI:-} + OIDC_CLOCK_SKEW_SECONDS: ${OIDC_CLOCK_SKEW_SECONDS:-5} + VALKEY_URL: redis://valkey:6379/0 + ORCHESTRATOR_BASE_URL: ${ORCHESTRATOR_BASE_URL:-http://orchestrator:8000} + ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY:-${CONTEXTUAL_ORCHESTRATOR_TOKEN:-lineageweave-orchestrator-dev-only}} + MCP_RESOURCE_URL: http://localhost:${MCP_PORT:-18001}/mcp + MCP_AUDIENCE: http://localhost:${MCP_PORT:-18001}/mcp + MCP_ALLOWED_HOSTS: localhost:*,127.0.0.1:*,mcp:8001 + MCP_ALLOWED_ORIGINS: ${MCP_ALLOWED_ORIGINS:-} + MCP_MAX_REQUEST_BYTES: ${MCP_MAX_REQUEST_BYTES:-65536} + # No guessed quota: operators must supply values justified by the k6 + # capacity artifact for their deployment before enabling this profile. + MCP_RATE_LIMIT_REQUESTS: ${MCP_RATE_LIMIT_REQUESTS:-} + MCP_RATE_LIMIT_WINDOW_SECONDS: ${MCP_RATE_LIMIT_WINDOW_SECONDS:-} + ports: + - "${MCP_PORT:-18001}:8001" + depends_on: + postgres: + condition: service_healthy + database_migration: + condition: service_completed_successfully + orchestrator: + condition: service_healthy + keycloak: + condition: service_started + valkey: + condition: service_healthy + frontend: build: context: ./frontend diff --git a/docker/keycloak/realm-export.json b/docker/keycloak/realm-export.json index be9826ea4..98d04881b 100644 --- a/docker/keycloak/realm-export.json +++ b/docker/keycloak/realm-export.json @@ -33,6 +33,16 @@ "access.token.claim": "true" } }, + { + "name": "lineageweave-mcp-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.custom.audience": "http://localhost:18001/mcp", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, { "name": "corp-code", "protocol": "openid-connect", diff --git a/docs/adr/0218-current-contract-mcp-global-ask.md b/docs/adr/0218-current-contract-mcp-global-ask.md new file mode 100644 index 000000000..2b2e15f60 --- /dev/null +++ b/docs/adr/0218-current-contract-mcp-global-ask.md @@ -0,0 +1,90 @@ +# ADR 0218: MCP Global Ask submits and reads the durable current Ask contract + +## Status + +Accepted + +## Context + +The protected product exposes Global Ask as a durable asynchronous job. Its +current contract includes account and process-unit scope snapshots, revocation +intersection, evidence-constrained semantic rewriting, explicit public-claim +verification opt-in, retained revisions at a knowledge cutoff, limitations, +and provenance-bearing citations. Historical MCP work implemented a separate +synchronous Ask pipeline on a non-default stack. Reintroducing that pipeline +would let REST and MCP disagree about authorization, time, retrieval, and +verification. + +Remote MCP clients also cross a distinct Streamable HTTP trust boundary. The +MCP transport specification requires Origin validation to prevent DNS +rebinding. OAuth protected-resource metadata and audience-restricted tokens +prevent a token issued for one resource from becoming authority at another. +Browser preflight and request-body admission must therefore happen before +OAuth, JSON parsing, database acquisition, quota consumption, or tool +invocation. + +## Decision + +1. LineageWeave exposes two MCP tools over Streamable HTTP: + `submit_global_ask` queues the same durable job as `POST /api/ask`, and + `read_global_ask_job` returns the same owner-scoped state and settled payload + as `GET /api/ask/jobs/{id}`. MCP does not recreate answer computation. +2. Submission accepts `question`, `verify_external`, and optional + `knowledge_cutoff`. Shared application-service functions own blank-question, + permission, orchestrator-availability, ISO-8601, database-clock, scope + snapshot, and enqueue behavior for both transports. +3. Reading preserves the stored answer payload without a second semantic, + citation, public-verification, or cutoff interpretation. Another account's + job remains indistinguishable from an absent job. +4. Keyverse issues an MCP-resource audience. The resource server validates + issuer, signature, expiry, audience, required scope, and the existing + provisioned LineageWeave account/affiliation contract before a tool runs. + OAuth protected-resource metadata follows RFC 9728 and advertises this exact + resource identifier. +5. An outer admission boundary validates Host and every present Origin, + answers only exact configured browser preflights, rejects ambiguous or + oversized framing while streaming, and replays admitted bytes once. It + exposes browser-readable MCP session/protocol and `WWW-Authenticate` + headers. No-Origin non-browser clients remain supported. +6. A shared Valkey counter consumes one quota unit only after token and + provisioned-account resolution. The key contains a SHA-256 account digest, + never a bearer token or display identifier. Limiter failure is explicitly + unavailable; it never falls back to a process-local counter. The request + limit and window are mandatory positive deployment inputs established by + measured capacity policy, not library defaults. Exhaustion returns the + actual bounded window remainder in structured MCP data and `Retry-After`. +7. MCP runs as a dedicated Compose service and reuses the existing PostgreSQL, + Valkey, Keyverse, contextual-orchestrator, semantic retrieval, and worker + boundaries. It does not add a database, provider call, model selector, or + LineageWeave-local scheduler. + +## Consequences + +- REST, MCP, UI polling, reports, and alerts read one persisted answer contract. +- MCP submission remains responsive while multi-minute orchestration stays in + the existing worker. +- Preflight, hostile transport metadata, malformed framing, and oversized + bodies cannot consume authentication, database, worker, or quota capacity. +- Deployment must supply an evidence-backed quota policy; missing policy fails + startup instead of silently choosing a rule of thumb. +- The historical MCP stacks remain reusable implementation evidence, not + protected-main delivery or a second product contract. + +## 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., Hunt, P., & Parecki, A. (2025). *OAuth 2.0 protected resource +metadata* (RFC 9728). Internet Engineering Task Force. +https://doi.org/10.17487/RFC9728 + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current +practice for OAuth 2.0 security* (RFC 9700). Internet Engineering Task Force. +https://doi.org/10.17487/RFC9700 + +Model Context Protocol. (2025). *Transports: Streamable HTTP* (Specification +2025-06-18). +https://modelcontextprotocol.io/specification/2025-06-18/basic/transports + diff --git a/docs/adr/README.md b/docs/adr/README.md index 708558372..174c4941b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -21,6 +21,7 @@ decision from them. | [`GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md`](../doctoring/GLOBAL_ASK_PUBLIC_VERIFICATION_REFERENCES.md) | [0215](0215-global-ask-public-claim-verification.md) | | [`GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md`](../doctoring/GLOBAL_ASK_KNOWLEDGE_CUTOFF_REFERENCES.md) | [0216](0216-global-ask-knowledge-cutoff.md) | | [`GLOBAL_ASK_QUERY_REWRITE_REFERENCES.md`](../doctoring/GLOBAL_ASK_QUERY_REWRITE_REFERENCES.md) | [0217](0217-evidence-constrained-semantic-query-rewrite.md) | +| [`MCP_GLOBAL_ASK_REFERENCES.md`](../doctoring/MCP_GLOBAL_ASK_REFERENCES.md) | [0218](0218-current-contract-mcp-global-ask.md) | | [`operability/http-concurrency-evidence.md`](../operability/http-concurrency-evidence.md) | [0204](0204-analysis-run-short-transaction-delivery.md), [0212](0212-single-query-authorized-post-filter-options.md), [0213](0213-global-ask-embedding-pool-release.md) | | Evidence operations Dashboard (`/`) | [0206](0206-evidence-operations-dashboard.md) | | [`temporal-topic-context-influence-research.md`](../temporal-topic-context-influence-research.md) | [0210](0210-temporal-topic-context-influence-dashboard.md) | diff --git a/docs/doctoring/MCP_GLOBAL_ASK_REFERENCES.md b/docs/doctoring/MCP_GLOBAL_ASK_REFERENCES.md new file mode 100644 index 000000000..9d51e8408 --- /dev/null +++ b/docs/doctoring/MCP_GLOBAL_ASK_REFERENCES.md @@ -0,0 +1,31 @@ +# MCP Global Ask standards register + +Supporting research for [ADR 0218](../adr/0218-current-contract-mcp-global-ask.md). +The ADR is normative; this register records why each external standard is in +scope. + +| Source | Adopted contract | +|---|---| +| MCP Streamable HTTP 2025-06-18 | Validate every present Origin, authenticate remote connections, and carry each JSON-RPC message in a new POST request. | +| RFC 9728 | Publish protected-resource metadata at the resource-derived well-known location and keep the advertised resource identifier exact. | +| RFC 8707 | Bind the Keyverse access token audience to the MCP resource identifier. | +| RFC 9700 | Apply current OAuth security best practice rather than treating bearer possession as cross-resource authority. | + +## References โ€” APA 7th + +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., Hunt, P., & Parecki, A. (2025). *OAuth 2.0 protected resource +metadata* (RFC 9728). Internet Engineering Task Force. +https://doi.org/10.17487/RFC9728 + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current +practice for OAuth 2.0 security* (RFC 9700). Internet Engineering Task Force. +https://doi.org/10.17487/RFC9700 + +Model Context Protocol. (2025). *Transports: Streamable HTTP* (Specification +2025-06-18). +https://modelcontextprotocol.io/specification/2025-06-18/basic/transports + diff --git a/docs/product-requirements.md b/docs/product-requirements.md index 74b9e8ef5..8b2505d15 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -125,6 +125,21 @@ Acceptance: a later rewrite never appears in a cutoff answer; an uncovered revision is explicitly unavailable; and API and rendered citations identify the retained revision and full/partial grounding state. +### PRD-FR-5C โ€” Authenticated MCP Global Ask + +- Expose asynchronous submission and owner-scoped job reading over MCP while + reusing the REST application service and persisted answer payload. +- Validate the exact MCP resource audience, provisioned account, permission, + affiliation scope, Host, Origin, and bounded request body before a tool runs. +- Consume one distributed quota unit only for an admitted authenticated tool + call; preflight and rejected admission consume none. +- Require deployment-supplied, load-evidence-backed quota parameters and fail + closed when shared Valkey cannot decide. + +Acceptance: MCP and REST produce the same scope snapshot, verification opt-in, +knowledge cutoff, status, citations, and limitations; cross-account reads are +404-equivalent; and exhaustion returns the bounded actual retry interval. + ### PRD-FR-6 โ€” Measurement boundary - Consume TEPP accepted/completed wire contracts and fast-mlsirm outputs; do diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 99a6f38a5..f68461ed1 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -397,6 +397,7 @@ this file per ยง3.5 of the prior snapshot). | Semantic/KG candidate nomination | PR #637 is merged into exact-head #632 (`6b99489e`): normalized project, R&R, Keyman, Knowledge Graph endpoint/edge, and canonical ontology-IRI evidence now nominate candidates through replay-safe indexes, with parameter-free RankWeave RRF and evidence-only operation when embeddings are unavailable. Live PostgreSQL tests cover project-only, endpoint-only, and ontology-IRI-only retrieval; issue #272 remains open for its separate external-verification slice | Exact-head checks must prove ABAC/eligibility/event-time filters run before each channel limit and again at hydration, duplicate hits deduplicate, hidden endpoint labels do not leak, missing RankWeave drops only the added channel, and protected `main` contains #632's merge SHA before the candidate-nomination gap is marked delivered | | Public semantic/KG claim verification | This candidate persists an explicit opt-in, restricts external nomination to cited public semantic/KG facts, uses bounded SearXNG retrieval plus contextual-orchestrator `verify` adjudication, and renders FEVER-style supported/refuted/not-enough-information states separately from internal citations. Synthetic Storybook desktop/mobile inspection and backend/API tests cover private, uncited, unavailable, and three-way states | Land the candidate and its #632 provenance base through protected `main`; then perform aggregate authenticated acceptance showing that opt-out/private/uncited inputs emit zero external queries and that external URLs never replace internal evidence | | Global Ask knowledge cutoff | The current stacked candidate persists one optional cutoff on the async job, resolves retained `source_post_revision` intervals, excludes later/current-only semantic channels, and renders revision identity, later-live-change status, and full/partial grounding. Focused backend tests and synthetic desktop/mobile Storybook scenes cover retained and missing history; browser/API live behavior remains unchanged when omitted | Land the candidate and #632 provenance base through protected `main`; add the same shared contract to authenticated MCP delivery (#269), then perform aggregate authorized-runtime acceptance proving that later rewrites and after-cutoff facts never enter answers | +| Authenticated MCP Global Ask | The ADR 0218 candidate reuses the current durable REST application service, exact-audience OAuth, pre-auth Host/Origin/body admission, fail-closed Valkey quota, a dedicated opt-in Compose service, and `submit_global_ask`/`read_global_ask_job`. The full 1,435-test suite plus focused trust-boundary tests passed locally; no protected-main or live MCP/k6 evidence is claimed | Land the candidate through exact-head review and protected `main`; then supply deployment-specific quota inputs from a synthetic application-ready k6 run and prove preflight/rejected admission consumes no quota, both tools consume exactly one unit, owner/ABAC/revocation/cutoff parity holds, and persisted citations/public verification match REST | | Natural-language semantic nomination | ADR 0217 and the current candidate add a contextual-orchestrator structured rewrite before database acquisition. Only exact question substrings are accepted; each becomes an independent parameterized PostgreSQL text query, while authorization, eligibility, event-time, cutoff, channel bounds, and parameter-free RankWeave fusion remain unchanged. Invalid or unavailable output retains the full question without invented terms or weights | Land through the #632 stack and protected `main`; then record aggregate authenticated multilingual recall evidence showing a conversational question retrieves a synthetic persisted fact while hidden evidence remains absent | | Knowledge Graph readability | PR #654 merged into #632 as `6c0c4370`: the ontology explorer uses defined light/dark tokens, keyboard-selectable nodes/edges, an exact-value table, native browser label wrapping, a token-backed edge halo, and a synthetic long-label Storybook scene. Focused tests, lint, production/Storybook builds, and light/dark rendered inspection passed; protected-main delivery remains open | Land #632 through protected `main`, then perform authenticated desktop/mobile keyboard and evidence-table acceptance on the protected merge head | | Source-code lookup UX | Source state/detail codes remain evidence-bearing machine values and current detail presentation is dense | Catalog-backed display labels with raw-code provenance, compact 5W1H/source-detail hierarchy, keyboard access, and no unsupported customer/project binding | diff --git a/pyproject.toml b/pyproject.toml index e3b7a5b18..825e64e80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,8 @@ backend = [ # Speaks RESP; works against Valkey (a Redis-protocol-compatible fork) # as well as real Redis. Used for the post-activity event stream. "redis>=5.0.0", + # Authenticated Streamable HTTP resource server (ADR 0218). + "mcp==2.0.0", # LLM-as-a-Judge -> IRT -> Fixed-Item Parameter Calibration for the # weekly/monthly PU/team/project reports (ADR 0003). No PyPI release # yet; pinned to a specific commit, same pattern as rankweave. Ships a diff --git a/tests/test_global_ask_service.py b/tests/test_global_ask_service.py new file mode 100644 index 000000000..7903a7263 --- /dev/null +++ b/tests/test_global_ask_service.py @@ -0,0 +1,177 @@ +"""Transport-parity tests for the durable Global Ask application service.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID + +import pytest +from fastapi import HTTPException + +from backend.app import global_ask_service +from backend.app.auth import CurrentAccount + + +class Acquire: + """Async context manager for a fake database connection.""" + + def __init__(self, connection) -> None: + self.connection = connection + + async def __aenter__(self): + """Return the configured connection.""" + return self.connection + + async def __aexit__(self, *_args): + """Leave without suppressing exceptions.""" + return False + + +class Pool: + """Expose one deterministic connection through acquire().""" + + def __init__(self, connection) -> None: + self.connection = connection + + def acquire(self): + """Return the async connection context.""" + return Acquire(self.connection) + + +class Connection: + """Return a fixed database clock and optional job row.""" + + def __init__(self, row=None) -> None: + self.row = row + + async def fetchval(self, _sql): + """Return a stable UTC database clock.""" + return datetime(2026, 8, 26, tzinfo=UTC) + + async def fetchrow(self, _sql, _job_id): + """Return the configured job row.""" + return self.row + + +def account(*, permitted=True) -> CurrentAccount: + """Build one synthetic provisioned account.""" + return CurrentAccount( + "account-1", + "subject-1", + "Analyst", + None, + frozenset({"entity-1"}), + frozenset({"unit-1"}), + frozenset({"post_read"}) if permitted else frozenset(), + ) + + +@pytest.mark.anyio +async def test_submit_normalizes_and_forwards_current_contract(monkeypatch) -> None: + """Submission passes the exact current scope, cutoff, and opt-in once.""" + calls = [] + + async def enqueue(*args, **kwargs): + calls.append((args, kwargs)) + return UUID("00000000-0000-0000-0000-000000000123") + + monkeypatch.setattr(global_ask_service, "enqueue_global_ask_job", enqueue) + result = await global_ask_service.submit_global_ask( + pool=Pool(Connection()), + valkey=object(), + account=account(), + question=" What changed? ", + verify_external=True, + knowledge_cutoff="2026-08-25T00:00:00Z", + service_available=True, + ) + assert result["job_status_code"] == "queued" + assert calls[0][1]["question_text"] == "What changed?" + assert calls[0][1]["corporate_entity_ids"] == frozenset({"entity-1"}) + assert calls[0][1]["process_unit_ids"] == frozenset({"unit-1"}) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("kwargs", "status_code"), + [ + ({"question": " "}, 422), + ({"question": "x", "knowledge_cutoff": "bad"}, 422), + ({"question": "x", "knowledge_cutoff": "2026-08-27T00:00:00Z"}, 422), + ({"question": "x", "service_available": False}, 503), + ], +) +async def test_submit_fails_before_enqueue(monkeypatch, kwargs, status_code) -> None: + """Invalid or unavailable submission states never enqueue work.""" + + async def forbidden(*_args, **_kwargs): + raise AssertionError("enqueue must not run") + + monkeypatch.setattr(global_ask_service, "enqueue_global_ask_job", forbidden) + values = { + "question": "x", + "verify_external": False, + "knowledge_cutoff": None, + "service_available": True, + } + values.update(kwargs) + with pytest.raises(HTTPException) as caught: + await global_ask_service.submit_global_ask( + pool=Pool(Connection()), valkey=object(), account=account(), **values + ) + assert caught.value.status_code == status_code + + +@pytest.mark.anyio +async def test_permission_and_owner_scope_fail_closed() -> None: + """Both operations enforce permission and non-enumerable ownership.""" + with pytest.raises(HTTPException) as denied: + await global_ask_service.read_global_ask_job( + pool=Pool(Connection()), + account=account(permitted=False), + ask_job_id=UUID(int=1), + ) + assert denied.value.status_code == 403 + row = { + "requesting_account_id": "other", + "job_status_code": "queued", + "answer_payload": None, + "failure_detail": None, + } + with pytest.raises(HTTPException) as absent: + await global_ask_service.read_global_ask_job( + pool=Pool(Connection(row)), account=account(), ask_job_id=UUID(int=1) + ) + assert absent.value.status_code == 404 + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("row", "expected"), + [ + ( + { + "requesting_account_id": "account-1", + "job_status_code": "succeeded", + "answer_payload": '{"answer_text":"ok"}', + "failure_detail": None, + }, + {"answer": {"answer_text": "ok"}}, + ), + ( + { + "requesting_account_id": "account-1", + "job_status_code": "failed", + "answer_payload": None, + "failure_detail": "unavailable", + }, + {"failure_detail": "unavailable"}, + ), + ], +) +async def test_read_preserves_persisted_terminal_payload(row, expected) -> None: + """Reading adds no second semantic interpretation.""" + result = await global_ask_service.read_global_ask_job( + pool=Pool(Connection(row)), account=account(), ask_job_id=UUID(int=1) + ) + assert result | expected == result diff --git a/tests/test_mcp_admission.py b/tests/test_mcp_admission.py new file mode 100644 index 000000000..cab560e32 --- /dev/null +++ b/tests/test_mcp_admission.py @@ -0,0 +1,124 @@ +"""Trust-boundary tests for bounded MCP request admission.""" + +from __future__ import annotations + +import json + +import pytest + +from backend.app.mcp_admission import BoundedRequestBodyApp + + +class Recorder: + """Capture the single replayed body.""" + + def __init__(self) -> None: + self.body = None + + async def __call__(self, _scope, receive, send) -> None: + """Read once and return an empty success response.""" + self.body = await receive() + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + +async def invoke(headers, messages, *, method="POST", scope_type="http"): + """Invoke one ASGI request and return status, payload, and downstream.""" + downstream = Recorder() + app = BoundedRequestBodyApp(downstream, maximum_bytes=8) + queue = list(messages) + sent = [] + + async def receive(): + """Return the next supplied ASGI message.""" + return queue.pop(0) + + async def send(message): + """Capture an ASGI response message.""" + sent.append(message) + + await app({"type": scope_type, "method": method, "headers": headers}, receive, send) + status = next( + item["status"] for item in sent if item["type"] == "http.response.start" + ) + body = b"".join( + item.get("body", b"") for item in sent if item["type"] == "http.response.body" + ) + return status, json.loads(body) if body else {}, downstream + + +def test_nonpositive_limit_fails_closed() -> None: + """A nonpositive envelope cannot be constructed.""" + with pytest.raises(ValueError): + BoundedRequestBodyApp(Recorder(), maximum_bytes=0) + + +@pytest.mark.anyio +async def test_bounded_body_replays_exact_bytes() -> None: + """An admitted chunked body reaches the SDK once and byte-exact.""" + status, payload, downstream = await invoke( + [], + [ + {"type": "http.request", "body": b"123", "more_body": True}, + {"type": "http.request", "body": b"45", "more_body": False}, + ], + ) + assert (status, payload) == (204, {}) + assert downstream.body == { + "type": "http.request", + "body": b"12345", + "more_body": False, + } + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("headers", "messages", "status", "code"), + [ + ([(b"content-length", b"9")], [], 413, "mcp_request_too_large"), + ( + [(b"content-length", b"3"), (b"content-length", b"3")], + [], + 400, + "mcp_invalid_content_length", + ), + ([(b"content-length", b"-1")], [], 400, "mcp_invalid_content_length"), + ( + [(b"content-length", b"4")], + [{"type": "http.request", "body": b"123", "more_body": False}], + 400, + "mcp_content_length_mismatch", + ), + ( + [], + [{"type": "http.request", "body": b"123456789", "more_body": False}], + 413, + "mcp_request_too_large", + ), + ([], [{"type": "http.disconnect"}], 400, "mcp_request_disconnected"), + ([], [{"type": "unexpected"}], 400, "mcp_invalid_request_body"), + ( + [], + [{"type": "http.request", "body": "bad", "more_body": False}], + 400, + "mcp_invalid_request_body", + ), + ], +) +async def test_invalid_or_oversized_body_never_reaches_sdk( + headers, messages, status, code +) -> None: + """Ambiguous, malformed, and oversized inputs fail before parsing.""" + actual_status, payload, downstream = await invoke(headers, messages) + assert (actual_status, payload) == (status, {"error_code": code}) + assert downstream.body is None + + +@pytest.mark.anyio +async def test_non_post_traffic_passes_through() -> None: + """Admission buffering applies only to POST requests.""" + status, _, downstream = await invoke( + [], [{"type": "http.request", "body": b"", "more_body": False}], method="GET" + ) + assert status == 204 + assert downstream.body["body"] == b"" diff --git a/tests/test_mcp_current_contract.py b/tests/test_mcp_current_contract.py new file mode 100644 index 000000000..57fd160ae --- /dev/null +++ b/tests/test_mcp_current_contract.py @@ -0,0 +1,78 @@ +"""Current-contract MCP surface and deployment-policy tests.""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from backend.app.config import load_settings + + +def test_mcp_quota_has_no_library_default(monkeypatch) -> None: + """Generic backend settings never invent deployment capacity.""" + monkeypatch.delenv("MCP_RATE_LIMIT_REQUESTS", raising=False) + monkeypatch.delenv("MCP_RATE_LIMIT_WINDOW_SECONDS", raising=False) + settings = load_settings() + assert settings.mcp_rate_limit_requests is None + assert settings.mcp_rate_limit_window_seconds is None + + +def test_mcp_server_requires_measured_quota_and_exact_origins(monkeypatch) -> None: + """The dedicated resource server fails closed on missing policy or wildcard Origin.""" + monkeypatch.setenv("MCP_RATE_LIMIT_REQUESTS", "10") + monkeypatch.setenv("MCP_RATE_LIMIT_WINDOW_SECONDS", "60") + from backend.app import mcp_server + + configured = load_settings() + assert { + tool.name + for tool in mcp_server.build_mcp_server(configured)._tool_manager.list_tools() + } == { + "submit_global_ask", + "read_global_ask_job", + } + with pytest.raises(ValueError, match="measured capacity"): + mcp_server.build_mcp_server(replace(configured, mcp_rate_limit_requests=None)) + with pytest.raises(ValueError, match="exact HTTP"): + mcp_server.build_mcp_http_app( + mcp_server.build_mcp_server(configured), + replace(configured, mcp_allowed_origins=["*"]), + ) + + +@pytest.mark.anyio +async def test_mcp_verifier_uses_exact_resource_audience(monkeypatch) -> None: + """MCP authentication asks the shared decoder for the MCP audience.""" + monkeypatch.setenv("MCP_RATE_LIMIT_REQUESTS", "10") + monkeypatch.setenv("MCP_RATE_LIMIT_WINDOW_SECONDS", "60") + from backend.app import mcp_auth + + settings = load_settings() + observed = [] + + def decode(token, candidate_settings, *, audience): + observed.append((token, candidate_settings, audience)) + return { + "sub": "subject-1", + "azp": "client-1", + "exp": 2_000_000_000, + "scope": "lineageweave:ask", + "aud": audience, + } + + monkeypatch.setattr(mcp_auth, "decode_access_token", decode) + verified = await mcp_auth.KeyverseMcpTokenVerifier(settings).verify_token("token") + assert verified is not None + assert verified.resource == settings.mcp_audience + assert observed == [("token", settings, settings.mcp_audience)] + + +@pytest.mark.parametrize( + "name", ["MCP_RATE_LIMIT_REQUESTS", "MCP_RATE_LIMIT_WINDOW_SECONDS"] +) +def test_mcp_quota_inputs_must_be_positive_integers(monkeypatch, name) -> None: + """Malformed deployment policy is rejected during configuration.""" + monkeypatch.setenv(name, "0") + with pytest.raises(ValueError, match="positive"): + load_settings() diff --git a/tests/test_mcp_rate_limit.py b/tests/test_mcp_rate_limit.py new file mode 100644 index 000000000..fefa18b24 --- /dev/null +++ b/tests/test_mcp_rate_limit.py @@ -0,0 +1,77 @@ +"""Shared MCP quota regressions.""" + +from __future__ import annotations + +import hashlib + +import pytest + +from backend.app.mcp_rate_limit import ( + McpRateLimiterUnavailable, + McpRateLimitExceeded, + ValkeyMcpRateLimiter, +) + + +class FakeValkey: + """Return a configured Lua result without a real Valkey server.""" + + def __init__(self, result=None, error: Exception | None = None) -> None: + self.result = result + self.error = error + self.call = None + self.closed = False + + async def eval(self, *args): + """Record and answer one script invocation.""" + self.call = args + if self.error: + raise self.error + return self.result + + async def aclose(self) -> None: + """Record closure.""" + self.closed = True + + +@pytest.mark.anyio +async def test_limiter_uses_opaque_account_key_and_atomic_window() -> None: + client = FakeValkey([1, 60]) + limiter = ValkeyMcpRateLimiter(client, request_limit=2, window_seconds=60) + await limiter.consume("customer-account") + assert client.call[1:] == ( + 1, + "lineageweave:mcp-rate-limit:v1:" + + hashlib.sha256(b"customer-account").hexdigest(), + 60, + ) + + +@pytest.mark.anyio +async def test_limiter_returns_bounded_retry_after_and_closes() -> None: + client = FakeValkey([3, 900]) + limiter = ValkeyMcpRateLimiter(client, request_limit=2, window_seconds=60) + with pytest.raises(McpRateLimitExceeded) as caught: + await limiter.consume("account") + assert caught.value.retry_after_seconds == 60 + await limiter.close() + assert client.closed + + +@pytest.mark.anyio +@pytest.mark.parametrize("result", [None, [1], [0, 60], [1, -1]]) +async def test_limiter_fails_closed_for_invalid_state(result) -> None: + limiter = ValkeyMcpRateLimiter( + FakeValkey(result), request_limit=2, window_seconds=60 + ) + with pytest.raises(McpRateLimiterUnavailable): + await limiter.consume("account") + + +@pytest.mark.anyio +async def test_limiter_fails_closed_for_valkey_error() -> None: + limiter = ValkeyMcpRateLimiter( + FakeValkey(error=RuntimeError("offline")), request_limit=2, window_seconds=60 + ) + with pytest.raises(McpRateLimiterUnavailable): + await limiter.consume("account") diff --git a/uv.lock b/uv.lock index 6190681ba..2d49c3312 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "annotated-doc" @@ -73,6 +77,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -535,6 +548,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -586,6 +612,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -604,6 +656,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "lineageweave" version = "2.18.0" @@ -625,6 +704,7 @@ backend = [ { name = "asyncpg" }, { name = "fast-mlsirm" }, { name = "fastapi" }, + { name = "mcp" }, { name = "pyjwt", extra = ["crypto"] }, { name = "redis" }, { name = "uvicorn", extra = ["standard"] }, @@ -647,6 +727,7 @@ requires-dist = [ { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=d025b7d237d8db7ca97a5611606c6285d5870895" }, { name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.115.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "mcp", marker = "extra == 'backend'", specifier = "==2.0.0" }, { name = "opentelemetry-api", specifier = ">=1.30.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.30.0" }, { name = "opentelemetry-sdk", specifier = ">=1.30.0" }, @@ -664,6 +745,44 @@ requires-dist = [ ] provides-extras = ["dev", "backend"] +[[package]] +name = "mcp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, +] + [[package]] name = "numpy" version = "2.5.2" @@ -1158,6 +1277,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1235,6 +1382,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb", size = 560618, upload-time = "2026-07-30T08:50:58.497Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1250,6 +1411,115 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" }, +] + [[package]] name = "starlette" version = "1.6.0" @@ -1272,6 +1542,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/e0/ffbc0d61d68304602120998a5d660c8108464064bdedc814dc4be8410425/threadweave-0.1.0-py3-none-any.whl", hash = "sha256:03c31fa21873a9493687d81eab4ec067bf169dade7cff077b80df46fd0db3aaf", size = 14967, upload-time = "2026-07-12T03:59:57.088Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" From c86bf29711cd6a4ba7c20b6c60d991878d33b22c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 02:35:34 +0900 Subject: [PATCH 2/2] docs: refresh MCP stack parent evidence --- docs/product-technical-gap-baseline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c697abdd6..ed3b90b2d 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -93,7 +93,7 @@ context only. | #640 | `06ddcc10` | dashboard case metrics, project journeys, restored TEPP API-key setting, and semantic-label spacing; checks and independent review remain required | | #639 | `aee02dca` | Running action and Compose contract repair; checks and independent review remain required | | #636 | `f7b9a65f` | calibrated external lineage contract; checks and independent review remain required | -| #632 | `22ad71dd` | Global Ask provenance, public verification, knowledge cutoff, evidence-constrained query rewriting, shared ABAC/rewrite-failure review repair, and #654 ontology-label readability; checks and independent review remain required | +| #632 | `a0d4eb71` | Global Ask provenance, public verification, knowledge cutoff, evidence-constrained query rewriting, shared ABAC/rewrite-failure review repair, #654 ontology-label readability, and exact-call Semgrep static-SQL suppression; checks and independent review remain required | | #631 | `c0022c97` | current-main ADR stack decomposition; checks and independent review remain required | | #629 | `4b4d6707` | provider pool release and bounded landing reads; checks and independent review remain required | | #579 | `689a21b6` | leftover interaction-map persistence; checks and independent review remain required |