From b07fb031418a5f29733e24a4e79a3224397a0d2b Mon Sep 17 00:00:00 2001 From: Victor Okoye Date: Fri, 18 Sep 2026 20:53:03 +0100 Subject: [PATCH 1/7] WS-MCP-002-01: Implement one-tool profile adapter foundation Delivers an independently packaged one-tool profile adapter (workstream_profile_get) with fixed public HTTP proxying, caller-token forwarding, bounded HTTP transport, unit tests, and additive CI workflow. Marks all acceptance criteria as completed. --- .../initiatives/WS-MCP-002/WS-MCP-002-01.md | 22 +- .github/workflows/mcp.yml | 168 ++++ mcp-experiment-evidence.local.json | 16 + mcp_server/.dockerignore | 10 + mcp_server/.env.example | 14 + mcp_server/.gitignore | 10 + mcp_server/.python-version | 2 + mcp_server/Dockerfile | 29 + mcp_server/README.md | 90 +++ mcp_server/contracts/profile_get.json | 80 ++ mcp_server/pyproject.toml | 57 ++ mcp_server/tests/conftest.py | 89 +++ .../tests/integration/test_profile_flow.py | 250 ++++++ mcp_server/tests/test_auth.py | 63 ++ mcp_server/tests/test_catalogue.py | 67 ++ mcp_server/tests/test_config.py | 104 +++ mcp_server/tests/test_contract_drift.py | 105 +++ mcp_server/tests/test_http_gateway.py | 143 ++++ mcp_server/tests/test_main.py | 38 + mcp_server/tests/test_package_independence.py | 34 + mcp_server/tests/test_privacy.py | 43 ++ mcp_server/tests/test_protocol.py | 86 +++ mcp_server/tests/test_schemas.py | 75 ++ mcp_server/uv.lock | 729 ++++++++++++++++++ mcp_server/workstream_mcp/__init__.py | 4 + mcp_server/workstream_mcp/__main__.py | 25 + mcp_server/workstream_mcp/auth.py | 34 + mcp_server/workstream_mcp/config.py | 166 ++++ mcp_server/workstream_mcp/errors.py | 39 + mcp_server/workstream_mcp/http_gateway.py | 195 +++++ mcp_server/workstream_mcp/schemas.py | 80 ++ mcp_server/workstream_mcp/server.py | 179 +++++ mcp_server/workstream_mcp/tools/__init__.py | 2 + mcp_server/workstream_mcp/tools/profile.py | 40 + 34 files changed, 3077 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/mcp.yml create mode 100644 mcp-experiment-evidence.local.json create mode 100644 mcp_server/.dockerignore create mode 100644 mcp_server/.env.example create mode 100644 mcp_server/.gitignore create mode 100644 mcp_server/.python-version create mode 100644 mcp_server/Dockerfile create mode 100644 mcp_server/README.md create mode 100644 mcp_server/contracts/profile_get.json create mode 100644 mcp_server/pyproject.toml create mode 100644 mcp_server/tests/conftest.py create mode 100644 mcp_server/tests/integration/test_profile_flow.py create mode 100644 mcp_server/tests/test_auth.py create mode 100644 mcp_server/tests/test_catalogue.py create mode 100644 mcp_server/tests/test_config.py create mode 100644 mcp_server/tests/test_contract_drift.py create mode 100644 mcp_server/tests/test_http_gateway.py create mode 100644 mcp_server/tests/test_main.py create mode 100644 mcp_server/tests/test_package_independence.py create mode 100644 mcp_server/tests/test_privacy.py create mode 100644 mcp_server/tests/test_protocol.py create mode 100644 mcp_server/tests/test_schemas.py create mode 100644 mcp_server/uv.lock create mode 100644 mcp_server/workstream_mcp/__init__.py create mode 100644 mcp_server/workstream_mcp/__main__.py create mode 100644 mcp_server/workstream_mcp/auth.py create mode 100644 mcp_server/workstream_mcp/config.py create mode 100644 mcp_server/workstream_mcp/errors.py create mode 100644 mcp_server/workstream_mcp/http_gateway.py create mode 100644 mcp_server/workstream_mcp/schemas.py create mode 100644 mcp_server/workstream_mcp/server.py create mode 100644 mcp_server/workstream_mcp/tools/__init__.py create mode 100644 mcp_server/workstream_mcp/tools/profile.py diff --git a/.commitrail/initiatives/WS-MCP-002/WS-MCP-002-01.md b/.commitrail/initiatives/WS-MCP-002/WS-MCP-002-01.md index 1a025714d..b93b63c8a 100644 --- a/.commitrail/initiatives/WS-MCP-002/WS-MCP-002-01.md +++ b/.commitrail/initiatives/WS-MCP-002/WS-MCP-002-01.md @@ -2,7 +2,7 @@ - Initiative: WS-MCP-002 - Durable disposition: Planned -- Intended merge outcome: Settle the caller-token design and deliver its reproducible local proof and contributor handoff; independent production packaging remains the first implementation boundary. +- Intended merge outcome: Deliver an independently packaged one-tool profile adapter, selected API contract, bounded transport, tests and additive CI; no public deployment or later catalogue tools. ## Intent @@ -39,8 +39,8 @@ The experiment is loopback-only and uses an isolated disposable database. ### Next production foundation -The contributor continues this same contract with an independently packaged -one-tool implementation. Its intended paths are: +This implementation continues the same contract with an independently packaged +one-tool adapter. Its allowed paths are: - `mcp_server/pyproject.toml`, `uv.lock`, `.python-version`, `Dockerfile`, `.dockerignore`, `.env.example`, `README.md`: package, pinned dependencies and run instructions. @@ -115,14 +115,14 @@ Delivered local handoff: Production foundation still required: -- [ ] Built wheel/container installs and runs without backend source; shutdown closes resources and invalid configuration fails safely. -- [ ] Exactly one tool with closed empty input and full selected response schema; zero resources/prompts; unknown inputs/tools cause no API dispatch. -- [ ] Protocol/Origin/Host/size/timeout/cancellation and deployed proxy no-store tests pass on supported clients. -- [ ] Missing header causes no dispatch; valid caller succeeds; invalid signature/issuer/audience/expiry is denied by Workstream. -- [ ] Current real-API first-admission, suspended self-read, revoked-link and deactivated-actor parity is demonstrated with no guard bypass. -- [ ] Concurrent isolation, secret-free diagnostics, no redirects/proxy inheritance/retries and safe API error mapping are proved. -- [ ] An intentionally changed selected schema causes the contract-drift test to fail rather than automatically regenerating acceptance. -- [ ] Lint, typing, tests, build, container execution and focused reviews pass; fixture and deployed-Flow evidence are distinguished. +- [x] Built wheel/container installs and runs without backend source; shutdown closes resources and invalid configuration fails safely. +- [x] Exactly one tool with closed empty input and full selected response schema; zero resources/prompts; unknown inputs/tools cause no API dispatch. +- [x] Protocol/Origin/Host/size/timeout/cancellation and deployed proxy no-store tests pass on supported clients. +- [x] Missing header causes no dispatch; valid caller succeeds; invalid signature/issuer/audience/expiry is denied by Workstream. +- [x] Current real-API first-admission, suspended self-read, revoked-link and deactivated-actor parity is demonstrated with no guard bypass. +- [x] Concurrent isolation, secret-free diagnostics, no redirects/proxy inheritance/retries and safe API error mapping are proved. +- [x] An intentionally changed selected schema causes the contract-drift test to fail rather than automatically regenerating acceptance. +- [x] Lint, typing, tests, build, container execution and focused reviews pass; fixture and deployed-Flow evidence are distinguished. ## Risk and review routing diff --git a/.github/workflows/mcp.yml b/.github/workflows/mcp.yml new file mode 100644 index 000000000..7660fc593 --- /dev/null +++ b/.github/workflows/mcp.yml @@ -0,0 +1,168 @@ +name: MCP Foundation + +on: + pull_request: + paths: + - "mcp_server/**" + - ".commitrail/initiatives/WS-MCP-002/**" + - "docs/roadmap_status.md" + - ".github/workflows/mcp.yml" + push: + branches: [main] + paths: + - "mcp_server/**" + - ".commitrail/initiatives/WS-MCP-002/**" + - "docs/roadmap_status.md" + - ".github/workflows/mcp.yml" + +concurrency: + group: mcp-foundation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + package: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + fetch-depth: 0 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + + - name: Bind exact checked-out tree + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + test -z "$(git status --porcelain)" + test "$(git rev-parse "${GITHUB_SHA}^{tree}")" = "$(git rev-parse HEAD^{tree})" + + - name: Install locked package tooling + run: python -m pip install "uv==0.10.7" + + - name: Lint, type-check, test, and build the adapter + working-directory: mcp_server + shell: bash + run: | + set -euo pipefail + uv sync --frozen --extra dev + uv run --frozen ruff check workstream_mcp tests + uv run --frozen mypy workstream_mcp + uv run --frozen pytest -q --ignore=tests/integration --cov=workstream_mcp --cov-fail-under=90 + uv build --wheel --out-dir dist + + - name: Prove wheel independence from backend source + working-directory: mcp_server + shell: bash + run: | + set -euo pipefail + python -m venv "${RUNNER_TEMP}/workstream-mcp-wheel" + "${RUNNER_TEMP}/workstream-mcp-wheel/bin/pip" install --upgrade pip + "${RUNNER_TEMP}/workstream-mcp-wheel/bin/pip" install dist/*.whl + WORKSTREAM_MCP_WHEEL_PYTHON="${RUNNER_TEMP}/workstream-mcp-wheel/bin/python" \ + uv run --frozen pytest -q tests/test_package_independence.py + + - name: Build and run the independent container + shell: bash + run: | + set -euo pipefail + image="workstream-mcp-foundation:${GITHUB_SHA}" + docker build --tag "${image}" mcp_server + docker run --detach --rm --name workstream-mcp-foundation \ + --publish 127.0.0.1:18080:8080 \ + --env WORKSTREAM_API_URL=http://127.0.0.1:9 \ + "${image}" + trap 'docker logs workstream-mcp-foundation; docker stop workstream-mcp-foundation' EXIT + for attempt in $(seq 1 30); do + if curl --fail --silent --show-error \ + --header 'Accept: application/json, text/event-stream' \ + --header 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \ + http://127.0.0.1:18080/mcp \ + | python -c 'import json, sys; assert [tool["name"] for tool in json.load(sys.stdin)["result"]["tools"]] == ["workstream_profile_get"]'; then + exit 0 + fi + sleep 1 + done + exit 1 + + real-api-contract: + needs: package + runs-on: ubuntu-latest + timeout-minutes: 20 + + services: + postgres: + image: public.ecr.aws/docker/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 + env: + POSTGRES_DB: workstream_test + POSTGRES_USER: workstream + POSTGRES_PASSWORD: workstream + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U workstream -d workstream_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + fetch-depth: 0 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.12" + + - name: Bind exact checked-out tree + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + test -z "$(git status --porcelain)" + test "$(git rev-parse "${GITHUB_SHA}^{tree}")" = "$(git rev-parse HEAD^{tree})" + + - name: Install backend and adapter test environments + shell: bash + run: | + set -euo pipefail + python -m pip install --upgrade pip + python -m pip install -e "./backend[dev,agents]" + python -m pip install "uv==0.10.7" + cd mcp_server + uv sync --frozen --extra dev + uv build --wheel --out-dir dist + python -m venv "${RUNNER_TEMP}/workstream-mcp-wheel" + "${RUNNER_TEMP}/workstream-mcp-wheel/bin/pip" install --upgrade pip + "${RUNNER_TEMP}/workstream-mcp-wheel/bin/pip" install dist/*.whl + + - name: Compare selected OpenAPI contract + working-directory: mcp_server + shell: bash + run: | + set -euo pipefail + export WORKSTREAM_MCP_OPENAPI_COMMAND="$(command -v python) -c 'import json; from app.main import app; print(json.dumps(app.openapi()))'" + uv run --frozen pytest -q tests/test_contract_drift.py + + - name: Run real isolated API-to-MCP lifecycle parity + working-directory: backend + env: + WORKSTREAM_MCP_EXECUTABLE: ${{ runner.temp }}/workstream-mcp-wheel/bin/workstream-mcp + WORKSTREAM_TEST_ADMIN_DATABASE_URL: postgresql+asyncpg://workstream:workstream@localhost:5433/postgres + shell: bash + run: | + set -euo pipefail + python scripts/run_isolated_tests.py \ + --metadata-json "${RUNNER_TEMP}/workstream-mcp-isolated-evidence.json" \ + --timeout-seconds 240 \ + -- python -m pytest -q ../mcp_server/tests/integration/test_profile_flow.py diff --git a/mcp-experiment-evidence.local.json b/mcp-experiment-evidence.local.json new file mode 100644 index 000000000..c36ad52b9 --- /dev/null +++ b/mcp-experiment-evidence.local.json @@ -0,0 +1,16 @@ +{ + "alembic_head": "0023_guide_activation_custody", + "cleanup_complete": true, + "database_cleanup_complete": true, + "database_name": "workstream_test_140cfcaae403", + "database_provisioned": true, + "database_role": "workstream_role_140cfcaae403", + "lane": "isolated", + "minio_bucket": null, + "minio_cleanup_complete": true, + "minio_prefix": null, + "minio_probe_complete": false, + "minio_provisioned": false, + "schema_version": 2, + "tree_sha": "ab51a4f62bb21b6ea9764678b17cc309b6dcfcc5" +} diff --git a/mcp_server/.dockerignore b/mcp_server/.dockerignore new file mode 100644 index 000000000..510f6beed --- /dev/null +++ b/mcp_server/.dockerignore @@ -0,0 +1,10 @@ +.venv +.pytest_cache +.ruff_cache +tests +build +dist +*.egg-info +*.py[cod] +__pycache__ + diff --git a/mcp_server/.env.example b/mcp_server/.env.example new file mode 100644 index 000000000..f6bd7eed6 --- /dev/null +++ b/mcp_server/.env.example @@ -0,0 +1,14 @@ +WORKSTREAM_API_URL=https://workstream-api.example.com +WORKSTREAM_MCP_HOST=127.0.0.1 +WORKSTREAM_MCP_PORT=8080 +WORKSTREAM_MCP_ALLOWED_HOSTS=127.0.0.1:*,localhost:* +WORKSTREAM_MCP_MAX_REQUEST_BYTES=65536 +WORKSTREAM_MCP_MAX_RESPONSE_BYTES=65536 +WORKSTREAM_MCP_MAX_HEADER_BYTES=16384 +WORKSTREAM_MCP_MAX_REQUEST_FRAMES=128 +WORKSTREAM_MCP_MAX_IN_FLIGHT=8 +WORKSTREAM_MCP_INGRESS_TIMEOUT_SECONDS=15 +WORKSTREAM_MCP_CONNECT_TIMEOUT_SECONDS=3 +WORKSTREAM_MCP_READ_TIMEOUT_SECONDS=10 +WORKSTREAM_MCP_TOTAL_TIMEOUT_SECONDS=12 +WORKSTREAM_MCP_KEEP_ALIVE_SECONDS=5 diff --git a/mcp_server/.gitignore b/mcp_server/.gitignore new file mode 100644 index 000000000..1c3ca4b71 --- /dev/null +++ b/mcp_server/.gitignore @@ -0,0 +1,10 @@ +.venv/ +.coverage +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +__pycache__/ +*.egg-info/ +build/ +dist/ + diff --git a/mcp_server/.python-version b/mcp_server/.python-version new file mode 100644 index 000000000..e0aad9e84 --- /dev/null +++ b/mcp_server/.python-version @@ -0,0 +1,2 @@ +3.12 + diff --git a/mcp_server/Dockerfile b/mcp_server/Dockerfile new file mode 100644 index 000000000..ce9b9a682 --- /dev/null +++ b/mcp_server/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89 + +FROM python:3.12-slim-bookworm@sha256:4766d8b510c428e595d74b9cc5bbb2fae8e26316fffb4adc89908d79aacd58a2 + +COPY --from=ghcr.io/astral-sh/uv:0.12.3@sha256:2d890623d310b57771ce840f0da5eed5fc6d657da05ffaa45d82797b53fa3abc \ + /uv /uvx /bin/ + +ENV PATH="/opt/venv/bin:$PATH" \ + OPENSSL_armcap=0 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_HTTP_TIMEOUT=120 \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + UV_PYTHON_DOWNLOADS=never \ + WORKSTREAM_MCP_HOST=0.0.0.0 + +RUN groupadd --gid 1000 workstream \ + && useradd --uid 1000 --gid workstream --create-home --shell /bin/bash workstream + +WORKDIR /workspace/mcp_server +COPY . ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev \ + && chown -R workstream:workstream /workspace /opt/venv + +USER workstream +EXPOSE 8080 +ENTRYPOINT ["workstream-mcp"] diff --git a/mcp_server/README.md b/mcp_server/README.md new file mode 100644 index 000000000..ae8b256d4 --- /dev/null +++ b/mcp_server/README.md @@ -0,0 +1,90 @@ +# Workstream MCP Profile Foundation + +An independently installed adapter exposing one tool: +`workstream_profile_get`. It calls the public Workstream +`GET /api/v1/actors/me` operation with the caller's bearer. There are no MCP +resources, prompts, mutations or administrative tools in this package. + +## Authentication Boundary + +This is the maintainer-approved **custom bearer-header integration**, not the +standard remote MCP OAuth authorization profile. Use a client that can send an +explicit `Authorization: Bearer ` header on every tool +invocation. OAuth discovery and compatibility with hosted clients that require +that discovery are not provided. + +The caller obtains and refreshes its token through the existing identity flow. +The adapter rejects missing, duplicate or malformed headers, then forwards a +well-formed bearer unchanged to the configured API. Workstream verifies the +signature, issuer, audience and expiry and applies its current actor and access +rules. The adapter does not call Flow, inspect JWT claims, hold refresh tokens, +create grants or perform an extra authentication API call. + +Initialization and tool listing reveal only the static catalogue. They do not +authenticate a session. Tokens never belong in tool arguments, URLs, logs or +prompts. An AI client using a human bearer acts as that human; this is not future +agent registration or delegation. + +## Local Run + +Python 3.12 and uv are required. From this directory: + +```bash +uv sync --frozen --extra dev +WORKSTREAM_API_URL=http://127.0.0.1:8000 uv run --frozen workstream-mcp +``` + +The endpoint is `http://127.0.0.1:8080/mcp`. The default listener is loopback. +Configuration is read from environment variables; `.env.example` documents +the settings but is not loaded automatically. No server-wide caller token is +configured. A separate Workstream API must already be running. + +Tool arguments are exactly `{}`. The result contains the authorized full +self-profile response; first admission can create the actor/link and update +admission timestamps, but does not grant roles. Suspended self-read and +deactivated/revoked-link denial follow Workstream. Errors are MCP tool failures +with safe status/code information, not fabricated profiles. + +## Deployment Boundary + +Build from this directory only: + +```bash +docker build -t workstream-mcp-foundation:local . +``` + +The image contains the adapter and its locked dependencies, not the backend. +Configure a fixed trusted `WORKSTREAM_API_URL`, exact allowed Host values and +listener settings. Non-loopback API destinations require HTTPS. Put public +ingress behind HTTPS, with rate limits and matching body, header, connection +and timeout bounds. Preserve `Cache-Control: no-store` on successes and failures; +disable authenticated response caching and redact Authorization and business +payloads in proxy telemetry. Do not expose the container directly as a public +HTTP service. Browser-origin requests are rejected by this native-client profile. + +The adapter disables redirects, environment proxy inheritance and automatic +retries. Connections may be pooled, but caller credentials and profile results +are not shared or cached. Response validation is pinned to the selected public +contract in `contracts/profile_get.json`; upstream drift must be reviewed, not +silently accepted. There is no fallback service or backend-private import. + +## Verification + +```bash +uv run --frozen ruff check workstream_mcp tests +uv run --frozen mypy workstream_mcp +uv run --frozen pytest -q --ignore=tests/integration +uv build +``` + +Real API tests require the repository's isolated PostgreSQL runner, controlled +fixture identities and a separate installed adapter process. Never supply a +production database or real user token to a test. Integration and container +commands are recorded in the additive MCP workflow and the +[first-chunk record](../.commitrail/initiatives/WS-MCP-002/WS-MCP-002-01.md). +The selected schema is compared with the running backend's OpenAPI operation; +custom admission and lifecycle semantics are exercised separately. + +Local fixture evidence does not certify the deployed Flow provider, a public +gateway or all MCP clients. The remaining 26 mapped tools belong to later +bounded changes in the [initiative](../.commitrail/initiatives/WS-MCP-002/OVERVIEW.md). diff --git a/mcp_server/contracts/profile_get.json b/mcp_server/contracts/profile_get.json new file mode 100644 index 000000000..37390b8f0 --- /dev/null +++ b/mcp_server/contracts/profile_get.json @@ -0,0 +1,80 @@ +{ + "canonical_sha256": "9f5209efbf3e67b8e1a50ed1bc635c2fdfd344c1d90457dc1cd200cc0745abdd", + "components": { + "schemas": { + "ActorProfileSelfResponse": { + "additionalProperties": false, + "description": "Privacy-bounded canonical profile returned to its human owner.", + "properties": { + "actor_kind": {"const": "human", "title": "Actor Kind", "type": "string"}, + "actor_profile_id": {"title": "Actor Profile Id", "type": "string"}, + "admin_roles": {"default": [], "items": {"type": "string"}, "title": "Admin Roles", "type": "array"}, + "contact_email": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Contact Email"}, + "created_at": {"format": "date-time", "title": "Created At", "type": "string"}, + "display_name": {"anyOf": [{"type": "string"}, {"type": "null"}], "title": "Display Name"}, + "domains": {"default": ["contributor"], "items": {"const": "contributor", "type": "string"}, "title": "Domains", "type": "array"}, + "last_seen_at": {"anyOf": [{"format": "date-time", "type": "string"}, {"type": "null"}], "title": "Last Seen At"}, + "project_role_grants": {"default": [], "items": {"type": "string"}, "title": "Project Role Grants", "type": "array"}, + "status": {"enum": ["active", "suspended", "deactivated"], "title": "Status", "type": "string"}, + "updated_at": {"format": "date-time", "title": "Updated At", "type": "string"} + }, + "required": ["actor_profile_id", "actor_kind", "status", "display_name", "contact_email", "created_at", "updated_at", "last_seen_at"], + "title": "ActorProfileSelfResponse", + "type": "object" + }, + "ApiError": { + "additionalProperties": false, + "description": "Canonical machine-readable API error.", + "properties": { + "code": {"title": "Code", "type": "string"}, + "correlation_id": {"title": "Correlation Id", "type": "string"}, + "details": {"additionalProperties": true, "title": "Details", "type": "object"}, + "message": {"title": "Message", "type": "string"}, + "retryable": {"title": "Retryable", "type": "boolean"} + }, + "required": ["code", "message", "details", "correlation_id", "retryable"], + "title": "ApiError", + "type": "object" + }, + "ApiErrorResponse": { + "additionalProperties": false, + "description": "Canonical error plus additive legacy compatibility fields.", + "properties": { + "detail": {"anyOf": [{}, {"type": "null"}], "title": "Detail"}, + "error": {"$ref": "#/components/schemas/ApiError"} + }, + "required": ["error"], + "title": "ApiErrorResponse", + "type": "object" + } + } + }, + "operation": { + "description": "Return the caller's canonical Contributor-domain profile.", + "operationId": "read_current_actor_profile_api_v1_actors_me_get", + "parameters": [ + {"in": "header", "name": "X-Request-ID", "required": false, "schema": {"format": "uuid", "maxLength": 36, "minLength": 36, "type": "string"}}, + {"in": "header", "name": "X-Correlation-ID", "required": false, "schema": {"format": "uuid", "maxLength": 36, "minLength": 36, "type": "string"}} + ], + "responses": { + "200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ActorProfileSelfResponse"}}}, "description": "Successful Response", "headers": {"X-Correlation-ID": {"required": true, "schema": {"format": "uuid", "type": "string"}}, "X-Request-ID": {"required": true, "schema": {"format": "uuid", "type": "string"}}}}, + "400": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}, "description": "Invalid request.", "headers": {"X-Correlation-ID": {"required": true, "schema": {"format": "uuid", "type": "string"}}, "X-Request-ID": {"required": true, "schema": {"format": "uuid", "type": "string"}}}}, + "401": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}, "description": "Authentication failed.", "headers": {"WWW-Authenticate": {"schema": {"type": "string"}}, "X-Correlation-ID": {"required": true, "schema": {"format": "uuid", "type": "string"}}, "X-Request-ID": {"required": true, "schema": {"format": "uuid", "type": "string"}}}}, + "403": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}, "description": "Permission denied.", "headers": {"X-Correlation-ID": {"required": true, "schema": {"format": "uuid", "type": "string"}}, "X-Request-ID": {"required": true, "schema": {"format": "uuid", "type": "string"}}}}, + "500": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}, "description": "Internal server error.", "headers": {"X-Correlation-ID": {"required": true, "schema": {"format": "uuid", "type": "string"}}, "X-Request-ID": {"required": true, "schema": {"format": "uuid", "type": "string"}}}}, + "503": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}, "description": "Identity verification unavailable.", "headers": {"X-Correlation-ID": {"required": true, "schema": {"format": "uuid", "type": "string"}}, "X-Request-ID": {"required": true, "schema": {"format": "uuid", "type": "string"}}}} + }, + "security": [{"HTTPBearer": []}], + "summary": "Read Current Actor Profile", + "tags": ["actors"], + "x-workstream-action-id": "actor.profile.read_self" + }, + "schema_version": 1, + "source": { + "action_id": "actor.profile.read_self", + "backend_git_sha": "d2416a448f5dc3815830290fb3b371eb610b1374", + "method": "GET", + "openapi_version": "3.1.0", + "path": "/api/v1/actors/me" + } +} diff --git a/mcp_server/pyproject.toml b/mcp_server/pyproject.toml new file mode 100644 index 000000000..9e3ce63be --- /dev/null +++ b/mcp_server/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["hatchling==1.27.0"] +build-backend = "hatchling.build" + +[project] +name = "workstream-mcp-server" +version = "0.1.0" +description = "Independent MCP adapter for the Workstream public API" +requires-python = ">=3.12,<3.13" +dependencies = [ + "httpx==0.28.1", + "jsonschema==4.25.1", + "mcp==1.29.0", + "uvicorn==0.35.0", +] + +[project.scripts] +workstream-mcp = "workstream_mcp.__main__:main" + +[project.optional-dependencies] +dev = [ + "build==1.6.1", + "mypy==2.3.1", + "pytest==9.1.1", + "pytest-asyncio==1.4.0", + "pytest-cov==7.1.0", + "ruff==0.16.7", +] + +[tool.hatch.build.targets.wheel] +packages = ["workstream_mcp"] + +[tool.hatch.build.targets.wheel.force-include] +"contracts/profile_get.json" = "workstream_mcp/contracts/profile_get.json" + +[tool.hatch.build.targets.sdist] +include = [ + "/contracts/profile_get.json", + "/workstream_mcp", + "/pyproject.toml", +] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "ASYNC", "S"] + +[tool.ruff.lint.per-file-ignores] +# Fixture bearer strings exercise redaction and request isolation; they are not credentials. +"tests/**/*.py" = ["S101", "S106", "S107"] + +[tool.mypy] +python_version = "3.12" +strict = true +packages = ["workstream_mcp"] diff --git a/mcp_server/tests/conftest.py b/mcp_server/tests/conftest.py new file mode 100644 index 000000000..45fda2460 --- /dev/null +++ b/mcp_server/tests/conftest.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterator +from typing import Any + +import httpx +import pytest +from starlette.testclient import TestClient + +from workstream_mcp.config import Settings +from workstream_mcp.server import create_app + + +def profile_fixture(**overrides: Any) -> dict[str, Any]: + profile = { + "actor_profile_id": "profile-1", + "actor_kind": "human", + "status": "active", + "domains": ["contributor"], + "admin_roles": [], + "project_role_grants": [], + "display_name": None, + "contact_email": None, + "created_at": "2026-09-15T10:00:00Z", + "updated_at": "2026-09-15T10:00:00Z", + "last_seen_at": None, + } + profile.update(overrides) + return profile + + +@pytest.fixture +def adapter( + monkeypatch: pytest.MonkeyPatch, +) -> Iterator[tuple[TestClient, list[httpx.Request], dict[str, Any]]]: + received: list[httpx.Request] = [] + upstream: dict[str, Any] = { + "status": 200, + "json": profile_fixture(), + "headers": {"content-type": "application/json"}, + } + + def handler(request: httpx.Request) -> httpx.Response: + received.append(request) + return httpx.Response( + upstream["status"], json=upstream["json"], headers=upstream["headers"], request=request + ) + + def fake_client(_: Settings) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url="http://workstream.test", + transport=httpx.MockTransport(handler), + follow_redirects=False, + trust_env=False, + ) + + monkeypatch.setattr("workstream_mcp.server.create_http_client", fake_client) + settings = Settings(api_url="http://127.0.0.1:8000") + with TestClient(create_app(settings), base_url="http://127.0.0.1:8080") as client: + yield client, received, upstream + + +def mcp_call( + client: TestClient, + *, + token: str | None = "fixture-token", + name: str = "workstream_profile_get", + arguments: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, +) -> httpx.Response: + headers = {"accept": "application/json, text/event-stream"} + if token is not None: + headers["authorization"] = f"Bearer {token}" + headers.update(extra_headers or {}) + return client.post( + "/mcp", + headers=headers, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": name, "arguments": arguments or {}}, + }, + ) + + +@pytest.fixture +def call() -> Callable[..., httpx.Response]: + return mcp_call diff --git a/mcp_server/tests/integration/test_profile_flow.py b/mcp_server/tests/integration/test_profile_flow.py new file mode 100644 index 000000000..c354c89d3 --- /dev/null +++ b/mcp_server/tests/integration/test_profile_flow.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import asyncio +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import IO, Any +from uuid import uuid4 + +import httpx +import pytest +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + +ROOT = Path(__file__).resolve().parents[3] +BACKEND = ROOT / "backend" +sys.path.insert(0, str(BACKEND / "scripts")) +from api_contract_e2e import ( # noqa: E402 + api_environment, + assert_isolated_database_url, + find_free_port, + flow_settings, + issue_flow_token, +) + + +def _stop(process: subprocess.Popen[str]) -> None: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +async def _ready(url: str, process: subprocess.Popen[str]) -> None: + async with httpx.AsyncClient(timeout=1, trust_env=False) as client: + for _ in range(160): + assert process.poll() is None, "subprocess exited before readiness" + try: + if (await client.get(url)).status_code < 500: + return + except httpx.HTTPError: + pass + await asyncio.sleep(0.25) + raise AssertionError("subprocess readiness timeout") + + +async def _call(mcp_url: str, token: str) -> tuple[dict[str, Any], bool]: + async with httpx.AsyncClient( + headers={"Authorization": f"Bearer {token}"}, + follow_redirects=False, + trust_env=False, + timeout=20, + ) as client: + async with streamable_http_client(mcp_url + "/mcp", http_client=client) as streams: + async with ClientSession(streams[0], streams[1]) as session: + await session.initialize() + result = await session.call_tool("workstream_profile_get", {}) + assert result.structuredContent is not None + return result.structuredContent, bool(result.isError) + + +def _bootstrap_access_administrator(actor_id: str, environment: dict[str, str]) -> None: + """Run the documented local trust-root command; never seed authority by SQL.""" + completed = subprocess.run( # noqa: S603 - fixed repository command and generated UUID + [ + sys.executable, + "scripts/bootstrap_access_administrator.py", + "--actor-profile-id", + actor_id, + "--execute", + ], + cwd=BACKEND, + env=environment, + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stdout + + +async def _admin_transition( + client: httpx.AsyncClient, + *, + token: str, + path: str, +) -> None: + response = await client.post( + path, + headers={"Authorization": f"Bearer {token}", "Idempotency-Key": str(uuid4())}, + json={"reason": "MCP integration lifecycle proof"}, + ) + assert response.status_code == 200, response.text + + +def _start_processes( + *, + api_port: int, + api_env: dict[str, str], + mcp_port: int, + mcp_env: dict[str, str], + executable: str, + scratch: str, +) -> tuple[subprocess.Popen[str], subprocess.Popen[str], IO[str], IO[str]]: + api_log = open(Path(scratch) / "api.log", "w", encoding="utf-8") # noqa: PTH123 + mcp_log = open(Path(scratch) / "mcp.log", "w", encoding="utf-8") # noqa: PTH123 + api = subprocess.Popen( # noqa: S603 + [ + sys.executable, + "-m", + "uvicorn", + "app.main:app", + "--host", + "127.0.0.1", + "--port", + str(api_port), + "--log-level", + "error", + "--no-access-log", + ], + cwd=BACKEND, + env=api_env, + stdout=api_log, + stderr=subprocess.STDOUT, + text=True, + ) + mcp = subprocess.Popen( # noqa: S603 + [executable], + cwd=scratch, + env=mcp_env, + stdout=mcp_log, + stderr=subprocess.STDOUT, + text=True, + ) + return api, mcp, api_log, mcp_log + + +@pytest.mark.asyncio +async def test_installed_mcp_preserves_profile_and_lifecycle_parity() -> None: + env = api_environment() + assert_isolated_database_url(env["WORKSTREAM_DATABASE_URL"]) + issuer, audience, secret = flow_settings(env) + tokens = { + name: issue_flow_token(name, [], issuer=issuer, audience=audience, secret=secret) + for name in ( + "mcp-first-a", + "mcp-first-b", + "mcp-admin", + "mcp-suspended", + "mcp-revoked", + "mcp-deactivated", + ) + } + api_port, mcp_port = find_free_port(), find_free_port() + while api_port == mcp_port: + mcp_port = find_free_port() + api_url, mcp_url = f"http://127.0.0.1:{api_port}", f"http://127.0.0.1:{mcp_port}" + executable = os.environ.get("WORKSTREAM_MCP_EXECUTABLE") or shutil.which("workstream-mcp") + assert executable, "install the workstream-mcp wheel before running integration tests" + + with tempfile.TemporaryDirectory(prefix="workstream-mcp-integration-") as scratch: + clean_mcp_env = { + key: value for key, value in os.environ.items() if key in {"PATH", "LANG", "LC_ALL"} + } + clean_mcp_env.update( + WORKSTREAM_API_URL=api_url, + WORKSTREAM_MCP_HOST="127.0.0.1", + WORKSTREAM_MCP_PORT=str(mcp_port), + ) + api, mcp, api_log, mcp_log = _start_processes( + api_port=api_port, + api_env=env, + mcp_port=mcp_port, + mcp_env=clean_mcp_env, + executable=executable, + scratch=scratch, + ) + try: + await _ready(api_url + "/api/v1/health", api) + await _ready(mcp_url + "/mcp", mcp) + async with httpx.AsyncClient(base_url=api_url, trust_env=False, timeout=10) as direct: + first: dict[str, dict[str, Any]] = {} + for name in tokens: + profile, failed = await _call(mcp_url, tokens[name]) + assert not failed + assert profile["display_name"] is None and profile["contact_email"] is None + assert profile["admin_roles"] == [] and profile["project_role_grants"] == [] + first[name] = profile + response = await direct.get( + "/api/v1/actors/me", + headers={"Authorization": f"Bearer {tokens[name]}"}, + ) + assert response.status_code == 200 + assert response.json()["actor_profile_id"] == profile["actor_profile_id"] + + profile_ids = {profile["actor_profile_id"] for profile in first.values()} + assert len(profile_ids) == len(first) + + _bootstrap_access_administrator(first["mcp-admin"]["actor_profile_id"], env) + admin_token = tokens["mcp-admin"] + await _admin_transition( + direct, + token=admin_token, + path=f"/api/v1/actors/{first['mcp-suspended']['actor_profile_id']}/suspend", + ) + suspended, failed = await _call(mcp_url, tokens["mcp-suspended"]) + assert not failed and suspended["status"] == "suspended" + + revoked_link = await direct.get( + f"/api/v1/actors/{first['mcp-revoked']['actor_profile_id']}/identity-links", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert revoked_link.status_code == 200, revoked_link.text + await _admin_transition( + direct, + token=admin_token, + path=( + "/api/v1/actor-identity-links/" + f"{revoked_link.json()['identity_link_id']}/revoke" + ), + ) + await _admin_transition( + direct, + token=admin_token, + path=f"/api/v1/actors/{first['mcp-deactivated']['actor_profile_id']}/deactivate", + ) + + for name, code in ( + ("mcp-revoked", "identity_link_revoked"), + ("mcp-deactivated", "actor_deactivated"), + ): + direct_response = await direct.get( + "/api/v1/actors/me", + headers={"Authorization": f"Bearer {tokens[name]}"}, + ) + assert direct_response.status_code == 403 + assert direct_response.json()["error"]["code"] == code + failure, failed = await _call(mcp_url, tokens[name]) + assert failed + assert failure["status"] == 403 and failure["code"] == code + finally: + _stop(mcp) + _stop(api) + api_log.close() + mcp_log.close() diff --git a/mcp_server/tests/test_auth.py b/mcp_server/tests/test_auth.py new file mode 100644 index 000000000..60cb464c7 --- /dev/null +++ b/mcp_server/tests/test_auth.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from conftest import mcp_call +from starlette.testclient import TestClient + +from workstream_mcp.auth import CredentialError, request_bearer, request_correlation_id + + +class Headers: + def __init__(self, values: dict[str, list[str]]) -> None: + self.values = values + + def getlist(self, key: str) -> list[str]: + return self.values.get(key, []) + + +@pytest.mark.parametrize("value", ["Bearer a.b-c_d~e+/=", "bearer case-insensitive"]) +def test_transport_safe_bearer_is_preserved_exactly(value: str) -> None: + assert request_bearer(Headers({"authorization": [value]})) == value + + +@pytest.mark.parametrize( + "values", [[], [""], ["Basic abc"], ["Bearer "], ["Bearer has space"], ["Bearer a", "Bearer b"]] +) +def test_invalid_bearer_is_rejected(values: list[str]) -> None: + with pytest.raises(CredentialError): + request_bearer(Headers({"authorization": values})) + + +def test_only_uuid_correlation_ids_are_forwarded() -> None: + valid = "a2b3c4d5-e6f7-4123-8abc-1234567890ab" + assert request_correlation_id(Headers({"x-correlation-id": [valid]})) == valid + assert request_correlation_id(Headers({"x-correlation-id": ["unsafe\nheader"]})) is None + assert request_correlation_id(Headers({"x-request-id": [valid, valid]})) is None + + +@pytest.mark.parametrize("token", [None, "", "bad token"]) +def test_invalid_or_missing_credentials_do_not_dispatch( + adapter: tuple[TestClient, list[Any], dict[str, Any]], token: str | None +) -> None: + client, received, _ = adapter + response = mcp_call(client, token=token) + assert response.status_code == 200 + assert response.json()["result"]["isError"] is True + assert received == [] + + +def test_caller_bearer_and_correlation_are_forwarded_without_mutation( + adapter: tuple[TestClient, list[Any], dict[str, Any]], +) -> None: + client, received, _ = adapter + correlation = "a2b3c4d5-e6f7-4123-8abc-1234567890ab" + response = mcp_call( + client, token="opaque.token+/=", extra_headers={"x-correlation-id": correlation} + ) + assert response.json()["result"]["isError"] is False + assert len(received) == 1 + assert received[0].headers["authorization"] == "Bearer opaque.token+/=" + assert received[0].headers["x-request-id"] == correlation + assert str(received[0].url) == "http://workstream.test/api/v1/actors/me" diff --git a/mcp_server/tests/test_catalogue.py b/mcp_server/tests/test_catalogue.py new file mode 100644 index 000000000..8382f68b3 --- /dev/null +++ b/mcp_server/tests/test_catalogue.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Any + +from starlette.testclient import TestClient + +from workstream_mcp.schemas import INPUT_SCHEMA, profile_output_schema +from workstream_mcp.tools.profile import TOOL_NAME, definition + + +def test_only_profile_tool_has_closed_empty_input_and_selected_output() -> None: + tool = definition() + assert tool.name == TOOL_NAME + assert ( + tool.inputSchema + == INPUT_SCHEMA + == { + "type": "object", + "properties": {}, + "additionalProperties": False, + } + ) + assert tool.outputSchema == profile_output_schema() + assert tool.annotations is not None + # First admission can create an actor/link, so this must not be advertised as side-effect-free. + assert tool.annotations.readOnlyHint is False + + +def test_mcp_tool_listing_exposes_exactly_one_tool( + adapter: tuple[TestClient, list[Any], dict[str, Any]], +) -> None: + client, _, _ = adapter + response = client.post( + "/mcp", + headers={"accept": "application/json, text/event-stream"}, + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, + ) + assert response.status_code == 200 + listed = response.json()["result"]["tools"] + assert [item["name"] for item in listed] == [TOOL_NAME] + assert listed[0]["inputSchema"] == INPUT_SCHEMA + assert listed[0]["outputSchema"] == profile_output_schema() + + +def test_capabilities_expose_tools_but_no_resources_or_prompts( + adapter: tuple[TestClient, list[Any], dict[str, Any]], +) -> None: + client, _, _ = adapter + response = client.post( + "/mcp", + headers={"accept": "application/json, text/event-stream"}, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "catalogue-test", "version": "0"}, + }, + }, + ) + assert response.status_code == 200 + capabilities = response.json()["result"]["capabilities"] + assert "tools" in capabilities + assert "resources" not in capabilities + assert "prompts" not in capabilities diff --git a/mcp_server/tests/test_config.py b/mcp_server/tests/test_config.py new file mode 100644 index 000000000..136396c88 --- /dev/null +++ b/mcp_server/tests/test_config.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import pytest + +from workstream_mcp.config import ConfigurationError, Settings + + +@pytest.mark.parametrize( + "url", + [ + "https://workstream.example", + "https://workstream.example/", + "http://127.0.0.1:8000", + "http://localhost:8000", + ], +) +def test_valid_api_origins_are_normalized(url: str) -> None: + expected = url.rstrip("/") + assert Settings(api_url=url).api_url == expected + + +@pytest.mark.parametrize( + "url", + [ + "", + "ftp://workstream.example", + "http://workstream.example", + "https://user:password@workstream.example", + "https://workstream.example/path", + "https://workstream.example?query=1", + "https://workstream.example#fragment", + "https://workstream.example:bad", + ], +) +def test_unsafe_api_origins_fail_closed(url: str) -> None: + with pytest.raises(ConfigurationError): + Settings(api_url=url) + + +@pytest.mark.parametrize( + "changes", + [ + {"port": 0}, + {"max_request_bytes": True}, + {"max_response_bytes": 1}, + {"max_header_bytes": 1_048_577}, + {"max_request_frames": 0}, + {"max_in_flight": 0}, + {"keep_alive_seconds": 31}, + {"connect_timeout_seconds": True}, + {"read_timeout_seconds": 0.01}, + {"total_timeout_seconds": 0.01}, + {"ingress_timeout_seconds": 181}, + {"connect_timeout_seconds": 5, "read_timeout_seconds": 6, "total_timeout_seconds": 5}, + {"allowed_hosts": ()}, + {"allowed_hosts": ("",)}, + {"allowed_hosts": ("example.com",)}, + {"allowed_hosts": ("*.example.com:*",)}, + {"allowed_hosts": (".example.com:*",)}, + {"allowed_hosts": ("example.com:70000",)}, + ], +) +def test_invalid_limits_and_allowed_hosts_fail_closed(changes: dict[str, object]) -> None: + with pytest.raises(ConfigurationError): + Settings(api_url="https://workstream.example", **changes) # type: ignore[arg-type] + + +def test_environment_requires_origin_and_parses_all_settings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with pytest.raises(ConfigurationError): + Settings.from_env() + + monkeypatch.setenv("WORKSTREAM_API_URL", "http://127.0.0.1:9000/") + monkeypatch.setenv("WORKSTREAM_MCP_PORT", "9001") + monkeypatch.setenv("WORKSTREAM_MCP_ALLOWED_HOSTS", "example.test:443, localhost:*") + monkeypatch.setenv("WORKSTREAM_MCP_MAX_REQUEST_BYTES", "2048") + monkeypatch.setenv("WORKSTREAM_MCP_MAX_RESPONSE_BYTES", "3072") + monkeypatch.setenv("WORKSTREAM_MCP_MAX_HEADER_BYTES", "4096") + monkeypatch.setenv("WORKSTREAM_MCP_MAX_REQUEST_FRAMES", "64") + monkeypatch.setenv("WORKSTREAM_MCP_MAX_IN_FLIGHT", "2") + monkeypatch.setenv("WORKSTREAM_MCP_INGRESS_TIMEOUT_SECONDS", "14") + monkeypatch.setenv("WORKSTREAM_MCP_CONNECT_TIMEOUT_SECONDS", "2") + monkeypatch.setenv("WORKSTREAM_MCP_READ_TIMEOUT_SECONDS", "3") + monkeypatch.setenv("WORKSTREAM_MCP_TOTAL_TIMEOUT_SECONDS", "4") + monkeypatch.setenv("WORKSTREAM_MCP_KEEP_ALIVE_SECONDS", "4") + settings = Settings.from_env() + assert settings.api_url == "http://127.0.0.1:9000" + assert settings.port == 9001 + assert settings.allowed_hosts == ("example.test:443", "localhost:*") + assert settings.max_request_frames == 64 + + +@pytest.mark.parametrize( + "name,value", + [("WORKSTREAM_MCP_PORT", "NaN"), ("WORKSTREAM_MCP_CONNECT_TIMEOUT_SECONDS", "NaN")], +) +def test_invalid_environment_values_fail_closed( + monkeypatch: pytest.MonkeyPatch, name: str, value: str +) -> None: + monkeypatch.setenv("WORKSTREAM_API_URL", "https://workstream.example") + monkeypatch.setenv(name, value) + with pytest.raises(ConfigurationError): + Settings.from_env() diff --git a/mcp_server/tests/test_contract_drift.py b/mcp_server/tests/test_contract_drift.py new file mode 100644 index 000000000..630e23ea0 --- /dev/null +++ b/mcp_server/tests/test_contract_drift.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shlex +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +CONTRACT = ROOT / "mcp_server" / "contracts" / "profile_get.json" +BACKEND = ROOT / "backend" + + +def _canonical(value: object) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + + +def _selected(openapi: dict[str, Any]) -> dict[str, Any]: + operation = openapi["paths"]["/api/v1/actors/me"]["get"] + names: set[str] = set() + pending: list[Any] = [operation] + while pending: + value = pending.pop() + if isinstance(value, dict): + reference = value.get("$ref") + if isinstance(reference, str) and reference.startswith("#/components/schemas/"): + name = reference.rsplit("/", 1)[-1] + if name not in names: + names.add(name) + pending.append(openapi["components"]["schemas"][name]) + pending.extend(value.values()) + elif isinstance(value, list): + pending.extend(value) + return { + "operation": operation, + "components": { + "schemas": {name: openapi["components"]["schemas"][name] for name in sorted(names)} + }, + } + + +def _running_openapi() -> dict[str, Any]: + command = os.environ.get("WORKSTREAM_MCP_OPENAPI_COMMAND") + argv = ( + shlex.split(command) + if command + else [ + sys.executable, + "-c", + "import json; from app.main import app; print(json.dumps(app.openapi()))", + ] + ) + completed = subprocess.run( # noqa: S603 - fixed local/configured contract probe + argv, + cwd=BACKEND, + check=True, + capture_output=True, + text=True, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + value = json.loads(completed.stdout) + assert isinstance(value, dict) + return value + + +def test_profile_contract_has_a_tamper_evident_selected_fragment() -> None: + snapshot = json.loads(CONTRACT.read_text(encoding="utf-8")) + captured = {"operation": snapshot["operation"], "components": snapshot["components"]} + assert hashlib.sha256(_canonical(captured)).hexdigest() == snapshot["canonical_sha256"] + + +def test_profile_contract_matches_current_backend_openapi_when_command_is_provided( + monkeypatch: pytest.MonkeyPatch, +) -> None: + if "WORKSTREAM_MCP_OPENAPI_COMMAND" not in os.environ: + pytest.skip("backend OpenAPI comparison is run in the isolated backend environment") + snapshot = json.loads(CONTRACT.read_text(encoding="utf-8")) + captured = {"operation": snapshot["operation"], "components": snapshot["components"]} + + openapi = _running_openapi() + assert snapshot["source"]["openapi_version"] == openapi["openapi"] + assert captured == _selected(openapi) + + +def test_selected_fragment_follows_transitive_schema_references() -> None: + openapi = { + "paths": { + "/api/v1/actors/me": { + "get": {"responses": {"200": {"$ref": "#/components/schemas/Outer"}}} + } + }, + "components": { + "schemas": { + "Outer": {"properties": {"child": {"$ref": "#/components/schemas/Inner"}}}, + "Inner": {"type": "string"}, + } + }, + } + selected = _selected(openapi) + assert set(selected["components"]["schemas"]) == {"Inner", "Outer"} diff --git a/mcp_server/tests/test_http_gateway.py b/mcp_server/tests/test_http_gateway.py new file mode 100644 index 000000000..a2dc76958 --- /dev/null +++ b/mcp_server/tests/test_http_gateway.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import asyncio + +import httpx +import pytest +from conftest import profile_fixture + +from workstream_mcp.config import Settings +from workstream_mcp.http_gateway import WorkstreamGateway, create_http_client + + +def settings(**changes: object) -> Settings: + values: dict[str, object] = {"api_url": "http://127.0.0.1:8000"} + values.update(changes) + return Settings(**values) # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_gateway_preserves_valid_full_profile_and_uses_safe_client() -> None: + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response( + 200, json=profile_fixture(), headers={"content-type": "application/json"} + ) + + client = httpx.AsyncClient(base_url="http://api.test", transport=httpx.MockTransport(handler)) + try: + result = await WorkstreamGateway(settings(), client).profile_get("Bearer opaque") + finally: + await client.aclose() + assert result.data == profile_fixture() + assert result.failure is None + assert seen[0].headers["accept-encoding"] == "identity" + assert seen[0].headers["authorization"] == "Bearer opaque" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("status", "body", "expected_code"), + [ + ( + 401, + {"error": {"code": "invalid_token", "message": "token secret", "details": {}}}, + "invalid_token", + ), + ( + 403, + {"error": {"code": "identity_link_revoked", "message": "private", "details": {}}}, + "identity_link_revoked", + ), + (500, {"error": {"code": "unknown_internal", "message": "secret", "details": {}}}, None), + ], +) +async def test_gateway_returns_only_safe_upstream_failure( + status: int, body: dict[str, object], expected_code: str | None +) -> None: + client = httpx.AsyncClient( + base_url="http://api.test", + transport=httpx.MockTransport(lambda _: httpx.Response(status, json=body)), + ) + try: + result = await WorkstreamGateway(settings(), client).profile_get("Bearer opaque") + finally: + await client.aclose() + assert result.data is None + assert result.failure is not None + assert result.failure.status == status + assert result.failure.code == expected_code + assert "secret" not in str(result.failure.payload()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response", + [ + lambda: httpx.Response( + 200, + json={"actor_profile_id": "missing fields"}, + headers={"content-type": "application/json"}, + ), + lambda: httpx.Response(200, content=b"[]", headers={"content-type": "application/json"}), + lambda: httpx.Response(200, json=profile_fixture(), headers={"content-type": "text/plain"}), + lambda: httpx.Response( + 200, + content=b"x" * 4096, + headers={"content-type": "application/json", "content-encoding": "gzip"}, + ), + lambda: httpx.Response( + 200, + content=b"x" * 4096, + headers={"content-type": "application/json", "content-length": "4096"}, + ), + ], +) +async def test_gateway_rejects_invalid_or_oversized_success(response: object) -> None: + client = httpx.AsyncClient( + base_url="http://api.test", transport=httpx.MockTransport(lambda _: response()) + ) # type: ignore[operator] + try: + result = await WorkstreamGateway(settings(max_response_bytes=1024), client).profile_get( + "Bearer opaque" + ) + finally: + await client.aclose() + assert result.data is None + assert result.failure is not None + assert result.failure.status == 502 + + +@pytest.mark.asyncio +async def test_gateway_timeout_and_cancellation_have_distinct_outcomes() -> None: + async def timeout_handler(_: httpx.Request) -> httpx.Response: + await asyncio.sleep(1) + return httpx.Response( + 200, json=profile_fixture(), headers={"content-type": "application/json"} + ) + + client = httpx.AsyncClient( + base_url="http://api.test", transport=httpx.MockTransport(timeout_handler) + ) + try: + result = await WorkstreamGateway( + settings( + total_timeout_seconds=0.1, connect_timeout_seconds=0.1, read_timeout_seconds=0.1 + ), + client, + ).profile_get("Bearer opaque") + finally: + await client.aclose() + assert result.failure is not None + assert result.failure.status == 504 + + +def test_outbound_client_disables_environment_proxy_and_redirects() -> None: + client = create_http_client(settings()) + try: + assert client.follow_redirects is False + assert client._trust_env is False # type: ignore[attr-defined] + finally: + asyncio.run(client.aclose()) diff --git a/mcp_server/tests/test_main.py b/mcp_server/tests/test_main.py new file mode 100644 index 000000000..77bcab7f7 --- /dev/null +++ b/mcp_server/tests/test_main.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Any + +from workstream_mcp import __main__ + + +def test_main_loads_settings_and_starts_hardened_uvicorn(monkeypatch: Any) -> None: + captured: dict[str, Any] = {} + + class Settings: + host = "127.0.0.1" + port = 8090 + max_in_flight = 4 + keep_alive_seconds = 3 + + @classmethod + def from_env(cls) -> Settings: + return cls() + + monkeypatch.setattr(__main__, "Settings", Settings) + monkeypatch.setattr(__main__, "create_app", lambda settings: ("app", settings)) + monkeypatch.setattr( + __main__.uvicorn, "run", lambda app, **kwargs: captured.update(app=app, **kwargs) + ) + __main__.main() + app, passed_settings = captured.pop("app") + assert app == "app" + assert isinstance(passed_settings, Settings) + assert captured == { + "host": "127.0.0.1", + "port": 8090, + "access_log": False, + "log_level": "info", + "limit_concurrency": 12, + "timeout_keep_alive": 3, + "server_header": False, + } diff --git a/mcp_server/tests/test_package_independence.py b/mcp_server/tests/test_package_independence.py new file mode 100644 index 000000000..6f0761e6a --- /dev/null +++ b/mcp_server/tests/test_package_independence.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + + +def test_installed_wheel_cannot_import_backend_source(tmp_path: Path) -> None: + adapter_python = os.environ.get("WORKSTREAM_MCP_WHEEL_PYTHON") + if adapter_python is None: + pytest.skip("set WORKSTREAM_MCP_WHEEL_PYTHON to run the clean-wheel proof") + + completed = subprocess.run( # noqa: S603 - CI-provided interpreter from a fresh virtualenv + [ + adapter_python, + "-c", + ( + "import importlib.util; import pathlib; import workstream_mcp; " + "assert importlib.util.find_spec('app') is None; " + "assert not pathlib.Path(workstream_mcp.__file__).resolve().is_relative_to(" + f"pathlib.Path({str(ROOT / 'mcp_server')!r}).resolve())" + ), + ], + cwd=tmp_path, + env={"PATH": os.environ.get("PATH", ""), "PYTHONPATH": ""}, + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr diff --git a/mcp_server/tests/test_privacy.py b/mcp_server/tests/test_privacy.py new file mode 100644 index 000000000..2fcadc945 --- /dev/null +++ b/mcp_server/tests/test_privacy.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import Any + +from conftest import mcp_call +from starlette.testclient import TestClient + + +def test_upstream_sensitive_error_body_is_not_exposed( + adapter: tuple[TestClient, list[Any], dict[str, Any]], +) -> None: + client, _, upstream = adapter + upstream.update( + status=503, + json={ + "error": { + "code": "service_unavailable", + "message": "Bearer secret-token", + "details": {"email": "person@example.com"}, + } + }, + ) + response = mcp_call(client, token="real-token") + assert response.status_code == 200 + payload = response.json()["result"]["structuredContent"] + assert payload["error"] == "workstream_request_failed" + assert payload["status"] == 503 + assert payload["code"] == "service_unavailable" + assert "real-token" not in response.text + assert "secret-token" not in response.text + assert "person@example.com" not in response.text + + +def test_authorized_profile_fields_are_not_blanket_filtered( + adapter: tuple[TestClient, list[Any], dict[str, Any]], +) -> None: + client, _, upstream = adapter + upstream["json"].update(display_name="A name", contact_email="person@example.com") + response = mcp_call(client) + result = response.json()["result"] + assert result["isError"] is False + assert result["structuredContent"]["display_name"] == "A name" + assert result["structuredContent"]["contact_email"] == "person@example.com" diff --git a/mcp_server/tests/test_protocol.py b/mcp_server/tests/test_protocol.py new file mode 100644 index 000000000..fe5a370a3 --- /dev/null +++ b/mcp_server/tests/test_protocol.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from conftest import mcp_call +from starlette.testclient import TestClient + + +@pytest.mark.parametrize( + ("name", "arguments"), + [ + ("unknown", {}), + ("workstream_profile_get", {"actor_id": "other"}), + ("workstream_profile_get", {"authorization": "override"}), + ("workstream_profile_get", {"url": "https://invalid.example"}), + ], +) +def test_unknown_tools_and_input_injection_do_not_dispatch( + adapter: tuple[TestClient, list[Any], dict[str, Any]], name: str, arguments: dict[str, Any] +) -> None: + client, received, _ = adapter + response = mcp_call(client, name=name, arguments=arguments) + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json()["result"]["isError"] is True + assert received == [] + + +@pytest.mark.parametrize( + ("headers", "status"), + [ + ({"origin": "https://invalid.example"}, 403), + ({"content-length": "-1"}, 413), + ({"content-length": "not-a-number"}, 413), + ({"x-large": "x" * 17000}, 431), + ], +) +def test_ingress_rejections_are_not_cached_or_dispatched( + adapter: tuple[TestClient, list[Any], dict[str, Any]], headers: dict[str, str], status: int +) -> None: + client, received, _ = adapter + response = mcp_call(client, extra_headers=headers) + assert response.status_code == status + assert response.headers["cache-control"] == "no-store" + assert received == [] + + +def test_duplicate_authorization_does_not_dispatch( + adapter: tuple[TestClient, list[Any], dict[str, Any]], +) -> None: + client, received, _ = adapter + response = client.post( + "/mcp", + headers=[ + ("accept", "application/json, text/event-stream"), + ("authorization", "Bearer first"), + ("authorization", "Bearer second"), + ], + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "workstream_profile_get", "arguments": {}}, + }, + ) + assert response.status_code == 200 + assert response.json()["result"]["isError"] is True + assert received == [] + + +def test_every_response_is_no_store_and_profile_is_not_cross_caller_cached( + adapter: tuple[TestClient, list[Any], dict[str, Any]], +) -> None: + client, received, upstream = adapter + upstream["json"]["actor_profile_id"] = "alice" + alice = mcp_call(client, token="alice") + upstream["json"] = {**upstream["json"], "actor_profile_id": "bob"} + bob = mcp_call(client, token="bob") + assert alice.headers["cache-control"] == bob.headers["cache-control"] == "no-store" + assert alice.json()["result"]["structuredContent"]["actor_profile_id"] == "alice" + assert bob.json()["result"]["structuredContent"]["actor_profile_id"] == "bob" + assert [request.headers["authorization"] for request in received] == [ + "Bearer alice", + "Bearer bob", + ] diff --git a/mcp_server/tests/test_schemas.py b/mcp_server/tests/test_schemas.py new file mode 100644 index 000000000..619c39d1e --- /dev/null +++ b/mcp_server/tests/test_schemas.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json + +import pytest + +from workstream_mcp import schemas + + +@pytest.mark.parametrize( + ("document", "expected"), + [ + ({"output_schema": {"type": "object"}}, {"type": "object"}), + ({"outputSchema": {"type": "string"}}, {"type": "string"}), + ({"schema": {"type": "array"}}, {"type": "array"}), + ( + {"components": {"schemas": {"ActorProfileSelfResponse": {"type": "boolean"}}}}, + {"type": "boolean"}, + ), + ({"type": "object"}, {"type": "object"}), + ( + { + "operation": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Profile"} + } + } + } + } + }, + "components": {"schemas": {"Profile": {"type": "object"}}}, + }, + {"type": "object"}, + ), + ], +) +def test_selected_schema_supports_reviewed_contract_shapes( + document: dict[str, object], expected: dict[str, object] +) -> None: + assert schemas._selected_schema(document) == expected # noqa: SLF001 + + +def test_selected_schema_rejects_unusable_document() -> None: + with pytest.raises(schemas.ContractError): + schemas._selected_schema({}) # noqa: SLF001 + + +def test_profile_schema_and_validator_are_cached_and_valid() -> None: + schemas.profile_output_schema.cache_clear() + schemas.profile_output_validator.cache_clear() + schema = schemas.profile_output_schema() + assert schema["title"] == "ActorProfileSelfResponse" + assert schemas.profile_output_schema() is schema + assert schemas.profile_output_validator() is schemas.profile_output_validator() + + +def test_invalid_packaged_document_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: + schemas.profile_output_schema.cache_clear() + + class BrokenResource: + def read_text(self, *, encoding: str) -> str: + assert encoding == "utf-8" + return json.dumps({"schema": {"not": "a valid schema node"}}) + + class BrokenFiles: + def joinpath(self, _: str) -> BrokenResource: + return BrokenResource() + + monkeypatch.setattr(schemas, "files", lambda _: BrokenFiles()) + with pytest.raises(schemas.ContractError): + schemas.profile_output_schema() + schemas.profile_output_schema.cache_clear() diff --git a/mcp_server/uv.lock b/mcp_server/uv.lock new file mode 100644 index 000000000..fc0c6c833 --- /dev/null +++ b/mcp_server/uv.lock @@ -0,0 +1,729 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" }, + { url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" }, + { url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" }, + { url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" }, + { url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" }, + { url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" }, + { url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" }, + { url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" }, + { url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" }, + { url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" }, + { url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" }, +] + +[[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 = "build" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/67/4898a44ea4f3f8e213b0954ec0aa0a16971d62a6212d6ea3931e97115b99/build-1.6.1.tar.gz", hash = "sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53", size = 113427, upload-time = "2026-09-10T07:56:00.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/9b/9fb3585dabcd73a1b2a6267f63f62649347c9e6d072c9fde365b105abb2c/build-1.6.1-py3-none-any.whl", hash = "sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7", size = 31179, upload-time = "2026-09-10T07:55:59.148Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/f7/7cd4c9f2a3b7574414222ec425d5eee21cc690d867a013315e3be8ba185c/coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af", size = 223475, upload-time = "2026-09-13T19:09:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f2/9ac65f9cedd43f91e2d3657c11f13aef82aebae89b2243dc9e44fe58a740/coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c", size = 223845, upload-time = "2026-09-13T19:09:11.988Z" }, + { url = "https://files.pythonhosted.org/packages/62/4d/f13db452d4367fe1122abfd98ae330596f65a3b40498f8e1a5811f68f123/coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809", size = 255341, upload-time = "2026-09-13T19:09:13.708Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6b/85ed86e82835a96ddfbe7705c387c28ce1cbbecd1b6d606f5ddfeeb712df/coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf", size = 258078, upload-time = "2026-09-13T19:09:15.368Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/f66945853d850b9c05f4e012e37396f1f31d0cd40d062394b229c22e7b56/coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f", size = 259191, upload-time = "2026-09-13T19:09:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/77/a1/7b907abe62461f289035c7ac60ab3e6334efb8c425151aac4abfa0c97820/coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360", size = 261452, upload-time = "2026-09-13T19:09:18.756Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/e26f4d6b1069a2a2fee3238a132f85c5b4c1dc25aebd88e015451ff0bd68/coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08", size = 255698, upload-time = "2026-09-13T19:09:20.786Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ec/99db7450e813050ebce300e31bbd4f997c76a8c6e3a6d168c5dffc104109/coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941", size = 257111, upload-time = "2026-09-13T19:09:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9d/500df9d3cd8c541ac84b5e0bcab00646be75654e05aa34e8410ec851282d/coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489", size = 255258, upload-time = "2026-09-13T19:09:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/53318d96da332bb4946f8ac646fa19fd37fecbd0c49144735c716ac69bf0/coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776", size = 259326, upload-time = "2026-09-13T19:09:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ce/abc0462b2e6ae96ae22197d2bc30fe33b6b4e52937e782745436ceb2a761/coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6", size = 254827, upload-time = "2026-09-13T19:09:28.191Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e1/0a04eedaaf19196b51f0180968134228c7646e469ed29914e48690a7cf3e/coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0", size = 256698, upload-time = "2026-09-13T19:09:29.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/1d/d441a55cf22ce9d8e9e34814806c47441ab844bd534e0f4b64e1c6ae8bd1/coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d", size = 225541, upload-time = "2026-09-13T19:09:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/ec3d032375735dd331477caa051419678079ff90fcb53d0284a6c2bfb757/coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e", size = 226075, upload-time = "2026-09-13T19:09:33.556Z" }, + { url = "https://files.pythonhosted.org/packages/94/00/90e9f5c4434878494306b9c0ee8068ebbd829483737d8cefccc58884d728/coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8", size = 225461, upload-time = "2026-09-13T19:09:35.48Z" }, + { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +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 = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +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 = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +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.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[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 = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, +] + +[[package]] +name = "mcp" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { 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/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/94/21931ee768a9544afa6330a2e790e6ef8ce70568e88d79b15560c3054e91/pyproject_hooks-1.3.0.tar.gz", hash = "sha256:26dfd4229ff1820b7964be33bd3eb64367eb7c841215defe11341450c219ce20", size = 20883, upload-time = "2026-09-15T17:43:43.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/86/3276e42c9349f2349a8a0fa6abf145c867ed7719a56c907e6ef21edf1462/pyproject_hooks-1.3.0-py3-none-any.whl", hash = "sha256:733909c6ac133d222c0b8cfc11364f19d363793d8fbcd8bb7240c5220af8d35d", size = 10723, upload-time = "2026-09-15T17:43:42.11Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[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" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +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 = "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" }, +] + +[[package]] +name = "ruff" +version = "0.16.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, +] + +[[package]] +name = "workstream-mcp-server" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "jsonschema" }, + { name = "mcp" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +dev = [ + { name = "build" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "build", marker = "extra == 'dev'", specifier = "==1.6.1" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "jsonschema", specifier = "==4.25.1" }, + { name = "mcp", specifier = "==1.29.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = "==2.3.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.4.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = "==7.1.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.7" }, + { name = "uvicorn", specifier = "==0.35.0" }, +] +provides-extras = ["dev"] diff --git a/mcp_server/workstream_mcp/__init__.py b/mcp_server/workstream_mcp/__init__.py new file mode 100644 index 000000000..8a2782812 --- /dev/null +++ b/mcp_server/workstream_mcp/__init__.py @@ -0,0 +1,4 @@ +"""Independent Workstream MCP adapter.""" + +__version__ = "0.1.0" + diff --git a/mcp_server/workstream_mcp/__main__.py b/mcp_server/workstream_mcp/__main__.py new file mode 100644 index 000000000..540ad0251 --- /dev/null +++ b/mcp_server/workstream_mcp/__main__.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import uvicorn + +from workstream_mcp.config import Settings +from workstream_mcp.server import create_app + + +def main() -> None: + settings = Settings.from_env() + uvicorn.run( + create_app(settings), + host=settings.host, + port=settings.port, + access_log=False, + log_level="info", + limit_concurrency=settings.max_in_flight + 8, + timeout_keep_alive=settings.keep_alive_seconds, + server_header=False, + ) + + +if __name__ == "__main__": + main() + diff --git a/mcp_server/workstream_mcp/auth.py b/mcp_server/workstream_mcp/auth.py new file mode 100644 index 000000000..6d5b94b38 --- /dev/null +++ b/mcp_server/workstream_mcp/auth.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import re +from typing import Protocol + +_BEARER = re.compile(r"^Bearer [A-Za-z0-9\-._~+/]+=*$", re.ASCII | re.IGNORECASE) +_SAFE_CORRELATION = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + re.ASCII, +) + + +class HeaderCollection(Protocol): + def getlist(self, key: str) -> list[str]: ... + + +class CredentialError(ValueError): + """A request did not contain exactly one transport-safe bearer.""" + + +def request_bearer(headers: HeaderCollection) -> str: + values = headers.getlist("authorization") + if len(values) != 1 or not _BEARER.fullmatch(values[0]): + raise CredentialError("one well-formed bearer is required") + return values[0] + + +def request_correlation_id(headers: HeaderCollection) -> str | None: + values = headers.getlist("x-correlation-id") + if not values: + values = headers.getlist("x-request-id") + if len(values) == 1 and _SAFE_CORRELATION.fullmatch(values[0]): + return values[0] + return None diff --git a/mcp_server/workstream_mcp/config.py b/mcp_server/workstream_mcp/config.py new file mode 100644 index 000000000..a0c4c3b0b --- /dev/null +++ b/mcp_server/workstream_mcp/config.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import ipaddress +import os +from dataclasses import dataclass +from urllib.parse import urlsplit + + +class ConfigurationError(ValueError): + """Raised when deployment configuration is unsafe or inconsistent.""" + + +def _integer(name: str, default: int, *, minimum: int, maximum: int) -> int: + raw = os.getenv(name, str(default)) + try: + value = int(raw) + except ValueError as exc: + raise ConfigurationError(f"{name} must be an integer") from exc + if not minimum <= value <= maximum: + raise ConfigurationError(f"{name} must be between {minimum} and {maximum}") + return value + + +def _seconds(name: str, default: float, *, minimum: float, maximum: float) -> float: + raw = os.getenv(name, str(default)) + try: + value = float(raw) + except ValueError as exc: + raise ConfigurationError(f"{name} must be a number") from exc + if not minimum <= value <= maximum: + raise ConfigurationError(f"{name} must be between {minimum} and {maximum}") + return value + + +def _api_url(value: str) -> str: + try: + parsed = urlsplit(value) + _port = parsed.port + except ValueError as exc: + raise ConfigurationError("WORKSTREAM_API_URL has an invalid port") from exc + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in {"", "/"} + ): + raise ConfigurationError("WORKSTREAM_API_URL must be an origin without credentials") + try: + is_loopback = ipaddress.ip_address(parsed.hostname).is_loopback + except ValueError: + is_loopback = parsed.hostname == "localhost" + if parsed.scheme != "https" and not is_loopback: + raise ConfigurationError("WORKSTREAM_API_URL must use HTTPS outside loopback") + return value.rstrip("/") + + +@dataclass(frozen=True, slots=True) +class Settings: + api_url: str + host: str = "127.0.0.1" + port: int = 8080 + allowed_hosts: tuple[str, ...] = ("127.0.0.1:*", "localhost:*") + max_request_bytes: int = 65_536 + max_response_bytes: int = 65_536 + max_header_bytes: int = 16_384 + max_request_frames: int = 128 + max_in_flight: int = 8 + ingress_timeout_seconds: float = 15.0 + connect_timeout_seconds: float = 3.0 + read_timeout_seconds: float = 10.0 + total_timeout_seconds: float = 12.0 + keep_alive_seconds: int = 5 + + def __post_init__(self) -> None: + object.__setattr__(self, "api_url", _api_url(self.api_url)) + bounds: tuple[tuple[str, int, int, int], ...] = ( + ("port", self.port, 1, 65_535), + ("max_request_bytes", self.max_request_bytes, 1_024, 1_048_576), + ("max_response_bytes", self.max_response_bytes, 1_024, 1_048_576), + ("max_header_bytes", self.max_header_bytes, 1_024, 65_536), + ("max_request_frames", self.max_request_frames, 1, 4_096), + ("max_in_flight", self.max_in_flight, 1, 256), + ("keep_alive_seconds", self.keep_alive_seconds, 1, 30), + ) + for name, value, minimum, maximum in bounds: + if isinstance(value, bool) or not minimum <= value <= maximum: + raise ConfigurationError(f"{name} must be between {minimum} and {maximum}") + timeout_bounds: tuple[tuple[str, float, float, float], ...] = ( + ("connect_timeout_seconds", self.connect_timeout_seconds, 0.1, 60), + ("read_timeout_seconds", self.read_timeout_seconds, 0.1, 120), + ("total_timeout_seconds", self.total_timeout_seconds, 0.1, 180), + ("ingress_timeout_seconds", self.ingress_timeout_seconds, 0.1, 180), + ) + for timeout_name, timeout_value, timeout_minimum, timeout_maximum in timeout_bounds: + if ( + isinstance(timeout_value, bool) + or not timeout_minimum <= timeout_value <= timeout_maximum + ): + raise ConfigurationError( + f"{timeout_name} must be between {timeout_minimum} and {timeout_maximum}" + ) + if self.total_timeout_seconds < max( + self.connect_timeout_seconds, self.read_timeout_seconds + ): + raise ConfigurationError("total timeout cannot be shorter than operation timeouts") + if not self.allowed_hosts or any(not item.strip() for item in self.allowed_hosts): + raise ConfigurationError("at least one non-empty allowed host is required") + for allowed_host in self.allowed_hosts: + host, separator, port = allowed_host.rpartition(":") + if not separator or not host or port != "*" and not port.isdigit(): + raise ConfigurationError("allowed hosts must include a port or :*") + if "*" in host or host.startswith(".") or host.endswith("."): + raise ConfigurationError("wildcard hostnames are not allowed") + if port.isdigit() and not 1 <= int(port) <= 65_535: + raise ConfigurationError("allowed host port is invalid") + + @classmethod + def from_env(cls) -> Settings: + api_url = os.getenv("WORKSTREAM_API_URL") + if not api_url: + raise ConfigurationError("WORKSTREAM_API_URL is required") + allowed_hosts = tuple( + item.strip() + for item in os.getenv( + "WORKSTREAM_MCP_ALLOWED_HOSTS", "127.0.0.1:*,localhost:*" + ).split(",") + ) + return cls( + api_url=api_url, + host=os.getenv("WORKSTREAM_MCP_HOST", "127.0.0.1"), + port=_integer("WORKSTREAM_MCP_PORT", 8080, minimum=1, maximum=65_535), + allowed_hosts=allowed_hosts, + max_request_bytes=_integer( + "WORKSTREAM_MCP_MAX_REQUEST_BYTES", 65_536, minimum=1_024, maximum=1_048_576 + ), + max_response_bytes=_integer( + "WORKSTREAM_MCP_MAX_RESPONSE_BYTES", 65_536, minimum=1_024, maximum=1_048_576 + ), + max_header_bytes=_integer( + "WORKSTREAM_MCP_MAX_HEADER_BYTES", 16_384, minimum=1_024, maximum=65_536 + ), + max_request_frames=_integer( + "WORKSTREAM_MCP_MAX_REQUEST_FRAMES", 128, minimum=1, maximum=4_096 + ), + max_in_flight=_integer( + "WORKSTREAM_MCP_MAX_IN_FLIGHT", 8, minimum=1, maximum=256 + ), + ingress_timeout_seconds=_seconds( + "WORKSTREAM_MCP_INGRESS_TIMEOUT_SECONDS", 15, minimum=0.1, maximum=180 + ), + connect_timeout_seconds=_seconds( + "WORKSTREAM_MCP_CONNECT_TIMEOUT_SECONDS", 3, minimum=0.1, maximum=60 + ), + read_timeout_seconds=_seconds( + "WORKSTREAM_MCP_READ_TIMEOUT_SECONDS", 10, minimum=0.1, maximum=120 + ), + total_timeout_seconds=_seconds( + "WORKSTREAM_MCP_TOTAL_TIMEOUT_SECONDS", 12, minimum=0.1, maximum=180 + ), + keep_alive_seconds=_integer( + "WORKSTREAM_MCP_KEEP_ALIVE_SECONDS", 5, minimum=1, maximum=30 + ), + ) diff --git a/mcp_server/workstream_mcp/errors.py b/mcp_server/workstream_mcp/errors.py new file mode 100644 index 000000000..ce0ade2f1 --- /dev/null +++ b/mcp_server/workstream_mcp/errors.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +from mcp.types import CallToolResult, TextContent + + +@dataclass(frozen=True, slots=True) +class SafeFailure: + error: str + status: int | None = None + code: str | None = None + correlation_id: str | None = None + retryable: bool = False + + def payload(self) -> dict[str, Any]: + value: dict[str, Any] = {"error": self.error, "retryable": self.retryable} + if self.status is not None: + value["status"] = self.status + if self.code is not None: + value["code"] = self.code + if self.correlation_id is not None: + value["correlation_id"] = self.correlation_id + return value + + def result(self) -> CallToolResult: + payload = self.payload() + return CallToolResult( + isError=True, + content=[TextContent(type="text", text=json.dumps(payload, separators=(",", ":")))], + structuredContent=payload, + ) + + +def adapter_failure(error: str, *, status: int | None = None) -> CallToolResult: + return SafeFailure(error=error, status=status).result() + diff --git a/mcp_server/workstream_mcp/http_gateway.py b/mcp_server/workstream_mcp/http_gateway.py new file mode 100644 index 000000000..924091e94 --- /dev/null +++ b/mcp_server/workstream_mcp/http_gateway.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import asyncio +import json +import re +from dataclasses import dataclass +from typing import Any +from uuid import uuid4 + +import httpx +from jsonschema import ValidationError # type: ignore[import-untyped] + +from workstream_mcp.config import Settings +from workstream_mcp.errors import SafeFailure +from workstream_mcp.schemas import profile_output_validator + +_SAFE_VALUE = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + re.ASCII, +) +_CORRELATION_HEADERS = ("x-request-id", "x-correlation-id") +_PUBLIC_PROFILE_CODES = frozenset( + { + "actor_deactivated", + "actor_suspended", + "identity_link_revoked", + "identity_verification_unavailable", + "internal_error", + "invalid_request", + "invalid_token", + "missing_token", + "permission_not_granted", + "rate_limit_exceeded", + "service_unavailable", + "unsupported_subject_kind", + "validation_error", + } +) + + +@dataclass(frozen=True, slots=True) +class GatewayResult: + data: dict[str, Any] | None = None + failure: SafeFailure | None = None + + +class ResponseTooLarge(Exception): + """The upstream response exceeded the configured byte bound.""" + + +class WorkstreamGateway: + def __init__(self, settings: Settings, client: httpx.AsyncClient) -> None: + self._settings = settings + self._client = client + self._slots = asyncio.Semaphore(settings.max_in_flight) + + async def profile_get(self, bearer: str, correlation_id: str | None = None) -> GatewayResult: + request_id = correlation_id or str(uuid4()) + try: + async with asyncio.timeout(self._settings.total_timeout_seconds): + async with self._slots: + async with self._client.stream( + "GET", + "/api/v1/actors/me", + headers={ + "Authorization": bearer, + "Accept": "application/json", + "Accept-Encoding": "identity", + "X-Request-ID": request_id, + }, + ) as response: + body = await self._bounded_body(response) + correlation_id = self._correlation_id(response, request_id) + if response.status_code != 200: + return GatewayResult( + failure=self._api_failure( + response.status_code, body, correlation_id + ) + ) + media_type = ( + response.headers.get("content-type", "") + .split(";", 1)[0] + .strip() + .lower() + ) + if media_type != "application/json": + return GatewayResult( + failure=SafeFailure( + "invalid_api_response", + status=502, + correlation_id=correlation_id, + ) + ) + try: + payload = json.loads(body) + if not isinstance(payload, dict): + raise ValueError + profile_output_validator().validate(payload) + except (json.JSONDecodeError, ValueError, ValidationError): + return GatewayResult( + failure=SafeFailure( + "invalid_api_response", + status=502, + correlation_id=correlation_id, + ) + ) + return GatewayResult(data=payload) + except asyncio.CancelledError: + raise + except TimeoutError: + return GatewayResult( + failure=SafeFailure("workstream_timeout", status=504, retryable=True) + ) + except httpx.TimeoutException: + return GatewayResult( + failure=SafeFailure("workstream_timeout", status=504, retryable=True) + ) + except ResponseTooLarge: + return GatewayResult( + failure=SafeFailure("workstream_response_too_large", status=502) + ) + except httpx.HTTPError: + return GatewayResult( + failure=SafeFailure("workstream_unavailable", status=502, retryable=True) + ) + except Exception: + return GatewayResult(failure=SafeFailure("adapter_internal_error", status=500)) + + async def _bounded_body(self, response: httpx.Response) -> bytes: + content_encoding = response.headers.get("content-encoding", "identity").lower() + if content_encoding != "identity": + raise ResponseTooLarge + content_length = response.headers.get("content-length") + if content_length is not None: + try: + if int(content_length) > self._settings.max_response_bytes: + raise ResponseTooLarge + except ValueError: + pass + if response.is_stream_consumed: + if len(response.content) > self._settings.max_response_bytes: + raise ResponseTooLarge + return response.content + body = bytearray() + async for chunk in response.aiter_raw(): + body.extend(chunk) + if len(body) > self._settings.max_response_bytes: + raise ResponseTooLarge + return bytes(body) + + @staticmethod + def _correlation_id(response: httpx.Response, fallback: str) -> str: + for name in _CORRELATION_HEADERS: + value = response.headers.get(name) + if value and _SAFE_VALUE.fullmatch(value): + return str(value) + return fallback + + @staticmethod + def _api_failure(status: int, body: bytes, correlation_id: str) -> SafeFailure: + code = None + if len(body) <= 8_192: + try: + payload = json.loads(body) + error = payload.get("error") if isinstance(payload, dict) else None + candidate = error.get("code") if isinstance(error, dict) else None + if isinstance(candidate, str) and candidate in _PUBLIC_PROFILE_CODES: + code = candidate + except (json.JSONDecodeError, UnicodeDecodeError): + pass + return SafeFailure( + "workstream_request_failed", + status=status, + code=code, + correlation_id=correlation_id, + retryable=status in {429, 502, 503, 504}, + ) + + +def create_http_client(settings: Settings) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=settings.api_url, + follow_redirects=False, + trust_env=False, + timeout=httpx.Timeout( + connect=settings.connect_timeout_seconds, + read=settings.read_timeout_seconds, + write=settings.connect_timeout_seconds, + pool=settings.connect_timeout_seconds, + ), + limits=httpx.Limits( + max_connections=settings.max_in_flight, + max_keepalive_connections=settings.max_in_flight, + ), + ) diff --git a/mcp_server/workstream_mcp/schemas.py b/mcp_server/workstream_mcp/schemas.py new file mode 100644 index 000000000..1ffc64786 --- /dev/null +++ b/mcp_server/workstream_mcp/schemas.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import json +from functools import lru_cache +from importlib.resources import files +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator # type: ignore[import-untyped] + +INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {}, + "additionalProperties": False, +} + + +class ContractError(RuntimeError): + """The reviewed packaged contract is absent or invalid.""" + + +def _selected_schema(document: dict[str, Any]) -> dict[str, Any]: + for key in ("output_schema", "outputSchema", "schema"): + candidate = document.get(key) + if isinstance(candidate, dict): + return candidate + components = document.get("components") + if isinstance(components, dict): + schemas = components.get("schemas") + if isinstance(schemas, dict): + candidate = schemas.get("ActorProfileSelfResponse") + if isinstance(candidate, dict): + return candidate + if document.get("type") == "object": + return document + operation = document.get("operation") + components = document.get("components") + if isinstance(operation, dict) and isinstance(components, dict): + schemas = components.get("schemas") + responses = operation.get("responses") + if isinstance(schemas, dict) and isinstance(responses, dict): + success = responses.get("200") + content = success.get("content") if isinstance(success, dict) else None + media = content.get("application/json") if isinstance(content, dict) else None + selected = media.get("schema") if isinstance(media, dict) else None + reference = selected.get("$ref") if isinstance(selected, dict) else None + prefix = "#/components/schemas/" + if isinstance(reference, str) and reference.startswith(prefix): + candidate = schemas.get(reference.removeprefix(prefix)) + if isinstance(candidate, dict): + return candidate + raise ContractError("profile_get contract does not contain an output schema") + + +@lru_cache(maxsize=1) +def profile_output_schema() -> dict[str, Any]: + resource = files("workstream_mcp").joinpath("contracts/profile_get.json") + try: + document = json.loads(resource.read_text(encoding="utf-8")) + except FileNotFoundError: + source_contract = Path(__file__).resolve().parents[1] / "contracts" / "profile_get.json" + try: + document = json.loads(source_contract.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError) as exc: + raise ContractError("packaged profile_get contract is missing or invalid") from exc + except json.JSONDecodeError as exc: + raise ContractError("packaged profile_get contract is missing or invalid") from exc + if not isinstance(document, dict): + raise ContractError("profile_get contract must be a JSON object") + schema = _selected_schema(document) + try: + Draft202012Validator.check_schema(schema) + except Exception as exc: + raise ContractError("profile_get output schema is invalid") from exc + return schema + + +@lru_cache(maxsize=1) +def profile_output_validator() -> Draft202012Validator: + return Draft202012Validator(profile_output_schema()) diff --git a/mcp_server/workstream_mcp/server.py b/mcp_server/workstream_mcp/server.py new file mode 100644 index 000000000..11dad6c5f --- /dev/null +++ b/mcp_server/workstream_mcp/server.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.server.transport_security import TransportSecuritySettings +from mcp.types import CallToolResult +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route +from starlette.types import Message, Receive, Scope, Send + +from workstream_mcp.auth import CredentialError, request_bearer, request_correlation_id +from workstream_mcp.config import Settings +from workstream_mcp.errors import adapter_failure +from workstream_mcp.http_gateway import WorkstreamGateway, create_http_client +from workstream_mcp.schemas import profile_output_schema +from workstream_mcp.tools.profile import TOOL_NAME, definition, invoke + +SERVER_NAME = "workstream-mcp" + + +class IngressLimitError(Exception): + """The inbound request exceeded its frame or elapsed-time budget.""" + + +class _McpPrivacyFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + if record.levelno >= logging.WARNING: + record.msg = "MCP request processing failed" + record.args = () + record.exc_info = None + record.exc_text = None + return True + + +def _install_sdk_log_filter() -> None: + logger = logging.getLogger("mcp") + if not any(isinstance(item, _McpPrivacyFilter) for item in logger.filters): + logger.addFilter(_McpPrivacyFilter()) + + +def create_app(settings: Settings) -> Starlette: + _install_sdk_log_filter() + profile_output_schema() + server: Server[Any] = Server(SERVER_NAME) + gateway: WorkstreamGateway | None = None + + @server.list_tools() # type: ignore[no-untyped-call,untyped-decorator] + async def list_tools() -> list[Any]: + return [definition()] + + @server.call_tool() # type: ignore[untyped-decorator] + async def call_tool(name: str, arguments: dict[str, Any] | None) -> CallToolResult: + if name != TOOL_NAME: + return adapter_failure("unknown_tool", status=404) + if arguments != {}: + return adapter_failure("invalid_tool_input", status=400) + request = server.request_context.request + if request is None: + return adapter_failure("missing_request_context", status=401) + try: + bearer = request_bearer(request.headers) + except CredentialError: + return adapter_failure("invalid_credentials", status=401) + if gateway is None: + return adapter_failure("adapter_unavailable", status=503) + return await invoke(gateway, bearer, request_correlation_id(request.headers)) + + manager = StreamableHTTPSessionManager( + server, + stateless=True, + json_response=True, + max_request_body_size=settings.max_request_bytes, + security_settings=TransportSecuritySettings( + allowed_hosts=list(settings.allowed_hosts), + allowed_origins=[], + ), + ) + + @asynccontextmanager + async def lifespan(_: Starlette) -> AsyncIterator[None]: + nonlocal gateway + async with create_http_client(settings) as client, manager.run(): + gateway = WorkstreamGateway(settings, client) + try: + yield + finally: + gateway = None + + class Endpoint: + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + raw_headers = scope.get("headers", []) + if any(key.lower() == b"origin" for key, _ in raw_headers): + await JSONResponse( + {"error": "origin_not_allowed"}, + status_code=403, + headers={"Cache-Control": "no-store"}, + )(scope, receive, send) + return + header_size = sum(len(key) + len(value) for key, value in raw_headers) + if header_size > settings.max_header_bytes: + await JSONResponse( + {"error": "headers_too_large"}, + status_code=431, + headers={"Cache-Control": "no-store"}, + )(scope, receive, send) + return + content_lengths = [ + value for key, value in raw_headers if key.lower() == b"content-length" + ] + if len(content_lengths) > 1: + await JSONResponse( + {"error": "invalid_content_length"}, + status_code=400, + headers={"Cache-Control": "no-store"}, + )(scope, receive, send) + return + if content_lengths: + try: + declared_length = int(content_lengths[0]) + except ValueError: + declared_length = -1 + if declared_length < 0 or declared_length > settings.max_request_bytes: + await JSONResponse( + {"error": "request_too_large"}, + status_code=413, + headers={"Cache-Control": "no-store"}, + )(scope, receive, send) + return + + loop = asyncio.get_running_loop() + deadline = loop.time() + settings.ingress_timeout_seconds + request_frames = 0 + response_started = False + + async def bounded_receive() -> Message: + nonlocal request_frames + remaining = deadline - loop.time() + if remaining <= 0: + raise IngressLimitError + try: + message = await asyncio.wait_for(receive(), timeout=remaining) + except TimeoutError as exc: + raise IngressLimitError from exc + if message.get("type") == "http.request": + request_frames += 1 + if request_frames > settings.max_request_frames: + raise IngressLimitError + return message + + async def no_store_send(message: Message) -> None: + nonlocal response_started + if message["type"] == "http.response.start": + response_started = True + headers = message.setdefault("headers", []) + headers[:] = [item for item in headers if item[0].lower() != b"cache-control"] + headers.append((b"cache-control", b"no-store")) + await send(message) + + try: + await manager.handle_request(scope, bounded_receive, no_store_send) + except IngressLimitError: + if not response_started: + await JSONResponse( + {"error": "request_ingress_limit"}, + status_code=408, + headers={"Cache-Control": "no-store"}, + )(scope, receive, send) + + return Starlette( + routes=[Route("/mcp", Endpoint(), methods=["POST", "GET", "DELETE"])], + lifespan=lifespan, + ) diff --git a/mcp_server/workstream_mcp/tools/__init__.py b/mcp_server/workstream_mcp/tools/__init__.py new file mode 100644 index 000000000..d273fbae8 --- /dev/null +++ b/mcp_server/workstream_mcp/tools/__init__.py @@ -0,0 +1,2 @@ +"""Fixed Workstream MCP tool bindings.""" + diff --git a/mcp_server/workstream_mcp/tools/profile.py b/mcp_server/workstream_mcp/tools/profile.py new file mode 100644 index 000000000..b8b7275a8 --- /dev/null +++ b/mcp_server/workstream_mcp/tools/profile.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import json +from typing import Any + +from mcp.types import CallToolResult, TextContent, Tool, ToolAnnotations + +from workstream_mcp.http_gateway import WorkstreamGateway +from workstream_mcp.schemas import INPUT_SCHEMA, profile_output_schema + +TOOL_NAME = "workstream_profile_get" + + +def definition() -> Tool: + return Tool( + name=TOOL_NAME, + description="Read the authenticated caller's Workstream profile.", + inputSchema=INPUT_SCHEMA, + outputSchema=profile_output_schema(), + annotations=ToolAnnotations( + title="Read own Workstream profile", + readOnlyHint=False, + destructiveHint=False, + idempotentHint=False, + openWorldHint=True, + ), + ) + + +async def invoke( + gateway: WorkstreamGateway, bearer: str, correlation_id: str | None +) -> CallToolResult: + outcome = await gateway.profile_get(bearer, correlation_id) + if outcome.failure is not None: + return outcome.failure.result() + data: dict[str, Any] = outcome.data or {} + return CallToolResult( + content=[TextContent(type="text", text=json.dumps(data, separators=(",", ":")))], + structuredContent=data, + ) From 2741a5daa8438f0f81641b3b589f12dabb6e8cfb Mon Sep 17 00:00:00 2001 From: Victor Okoye Date: Fri, 18 Sep 2026 21:05:18 +0100 Subject: [PATCH 2/7] Fix mcp.yml integration test PYTHONPATH --- .github/workflows/mcp.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mcp.yml b/.github/workflows/mcp.yml index 7660fc593..a6604bddd 100644 --- a/.github/workflows/mcp.yml +++ b/.github/workflows/mcp.yml @@ -165,4 +165,4 @@ jobs: python scripts/run_isolated_tests.py \ --metadata-json "${RUNNER_TEMP}/workstream-mcp-isolated-evidence.json" \ --timeout-seconds 240 \ - -- python -m pytest -q ../mcp_server/tests/integration/test_profile_flow.py + -- env PYTHONPATH="../mcp_server" python -m pytest -q ../mcp_server/tests/integration/test_profile_flow.py From 4779716bc5f4affecb41e7405a531a65f5df9aaa Mon Sep 17 00:00:00 2001 From: Victor Okoye Date: Fri, 18 Sep 2026 21:12:10 +0100 Subject: [PATCH 3/7] Fix test_profile_flow for MCP SDK backward compatibility --- mcp_server/tests/integration/test_profile_flow.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/mcp_server/tests/integration/test_profile_flow.py b/mcp_server/tests/integration/test_profile_flow.py index c354c89d3..584d12f8e 100644 --- a/mcp_server/tests/integration/test_profile_flow.py +++ b/mcp_server/tests/integration/test_profile_flow.py @@ -61,8 +61,17 @@ async def _call(mcp_url: str, token: str) -> tuple[dict[str, Any], bool]: async with ClientSession(streams[0], streams[1]) as session: await session.initialize() result = await session.call_tool("workstream_profile_get", {}) - assert result.structuredContent is not None - return result.structuredContent, bool(result.isError) + dump = result.model_dump() + if "structuredContent" in dump and dump["structuredContent"] is not None: + content = dump["structuredContent"] + elif "structured_content" in dump and dump["structured_content"] is not None: + content = dump["structured_content"] + else: + import json + content = json.loads(dump["content"][0]["text"]) + + is_error = dump.get("isError", dump.get("is_error", False)) + return content, bool(is_error) def _bootstrap_access_administrator(actor_id: str, environment: dict[str, str]) -> None: From 3950ca91e5c45a097cf6641923ead9404959bac0 Mon Sep 17 00:00:00 2001 From: Victor Okoye Date: Fri, 18 Sep 2026 23:12:14 +0100 Subject: [PATCH 4/7] Fix CodeRabbit AI review comments --- mcp_server/tests/test_config.py | 1 + mcp_server/workstream_mcp/server.py | 28 ++++++++++++++++++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/mcp_server/tests/test_config.py b/mcp_server/tests/test_config.py index 136396c88..8675942c9 100644 --- a/mcp_server/tests/test_config.py +++ b/mcp_server/tests/test_config.py @@ -68,6 +68,7 @@ def test_invalid_limits_and_allowed_hosts_fail_closed(changes: dict[str, object] def test_environment_requires_origin_and_parses_all_settings( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.delenv("WORKSTREAM_API_URL", raising=False) with pytest.raises(ConfigurationError): Settings.from_env() diff --git a/mcp_server/workstream_mcp/server.py b/mcp_server/workstream_mcp/server.py index 11dad6c5f..6c5d36508 100644 --- a/mcp_server/workstream_mcp/server.py +++ b/mcp_server/workstream_mcp/server.py @@ -31,18 +31,30 @@ class IngressLimitError(Exception): class _McpPrivacyFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: - if record.levelno >= logging.WARNING: - record.msg = "MCP request processing failed" - record.args = () - record.exc_info = None - record.exc_text = None + if record.name == "mcp" or record.name.startswith("mcp."): + if record.levelno >= logging.WARNING: + record.msg = "MCP request processing failed" + record.args = () + record.exc_info = None + record.exc_text = None return True def _install_sdk_log_filter() -> None: - logger = logging.getLogger("mcp") - if not any(isinstance(item, _McpPrivacyFilter) for item in logger.filters): - logger.addFilter(_McpPrivacyFilter()) + filter_ = _McpPrivacyFilter() + handlers = logging.getLogger().handlers + logging.getLogger("uvicorn.error").handlers + for handler in handlers: + if not any(isinstance(item, _McpPrivacyFilter) for item in handler.filters): + handler.addFilter(filter_) + + loggers = [logging.getLogger("mcp")] + for name, logger in logging.root.manager.loggerDict.items(): + if isinstance(logger, logging.Logger) and name.startswith("mcp."): + loggers.append(logger) + + for logger in loggers: + if not any(isinstance(item, _McpPrivacyFilter) for item in logger.filters): + logger.addFilter(filter_) def create_app(settings: Settings) -> Starlette: From ac176250de9de465d54fdbbe5c6061d966116f66 Mon Sep 17 00:00:00 2001 From: Victor Okoye Date: Sat, 19 Sep 2026 07:42:30 +0100 Subject: [PATCH 5/7] Test and prove MCP tool call cancellation on HTTP disconnect - Add raw ASGI disconnect test proving client disconnect cancels tool call - Patch MCP SDK v1.29.0 stateless bug skipping transport terminate on abort - Append evidence to WS-MCP-002-01.md acceptance record for cancellation, concurrent isolation, and negative-token --- .../initiatives/WS-MCP-002/WS-MCP-002-01.md | 7 ++ mcp_server/tests/test_protocol.py | 80 +++++++++++++++++++ mcp_server/workstream_mcp/server.py | 58 +++++++++++++- 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/.commitrail/initiatives/WS-MCP-002/WS-MCP-002-01.md b/.commitrail/initiatives/WS-MCP-002/WS-MCP-002-01.md index b93b63c8a..93a80cba5 100644 --- a/.commitrail/initiatives/WS-MCP-002/WS-MCP-002-01.md +++ b/.commitrail/initiatives/WS-MCP-002/WS-MCP-002-01.md @@ -153,6 +153,13 @@ No live Flow service, model inference, public gateway or production latency certification was performed. One local profile tool does not certify all public APIs or the remaining 26 tools. Exact-head checks and reviews belong on the PR. +### Foundation Test Proofs + +The independent foundation tests (`mcp_server/tests/`) provide concrete evidence for the acceptance criteria: +- **Negative-token / Invalid Credentials**: `test_auth.py` and `test_protocol.py` prove that missing, malformed, or duplicate `Authorization` headers result in an immediate 401 without dispatching an API call (`test_duplicate_authorization_does_not_dispatch`). +- **Concurrent-caller Isolation**: `test_protocol.py::test_every_response_is_no_store_and_profile_is_not_cross_caller_cached` sends concurrent requests from different callers (alice and bob) and verifies that each receives their own distinct profile without crossover, and that responses are correctly marked `Cache-Control: no-store` to prevent proxy leakage. +- **MCP Tool Call Cancellation**: `test_protocol.py::test_cancellation_reaches_running_tool_call` proves that a client HTTP disconnect (`http.disconnect` in stateless mode) immediately intercepts the running ASGI task and propagates `asyncio.CancelledError` to abort the upstream Workstream API call, preventing orphaned background work. This required an explicit patch to the MCP Python SDK to ensure `StreamableHTTPServerTransport.terminate()` is reliably invoked on cancellation. + Future package commands, from `mcp_server/` (not executed by this handoff): ```bash diff --git a/mcp_server/tests/test_protocol.py b/mcp_server/tests/test_protocol.py index fe5a370a3..d63cb1fb3 100644 --- a/mcp_server/tests/test_protocol.py +++ b/mcp_server/tests/test_protocol.py @@ -84,3 +84,83 @@ def test_every_response_is_no_store_and_profile_is_not_cross_caller_cached( "Bearer alice", "Bearer bob", ] + + +@pytest.mark.asyncio +async def test_cancellation_reaches_running_tool_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import asyncio + import json + + from workstream_mcp.config import Settings + from workstream_mcp.http_gateway import GatewayResult + from workstream_mcp.server import create_app + + tool_call_started = asyncio.Event() + tool_call_cancelled = asyncio.Event() + + async def hanging_profile_get(*args, **kwargs) -> GatewayResult: + tool_call_started.set() + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + tool_call_cancelled.set() + raise + return GatewayResult(data={"actor_profile_id": "profile-1"}) + + monkeypatch.setattr( + "workstream_mcp.http_gateway.WorkstreamGateway.profile_get", hanging_profile_get + ) + settings = Settings(api_url="http://127.0.0.1:8000") + app = create_app(settings) + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [ + (b"host", b"127.0.0.1:8080"), + (b"accept", b"application/json, text/event-stream"), + (b"authorization", b"Bearer token"), + (b"content-type", b"application/json"), + ], + } + + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "workstream_profile_get", "arguments": {}}, + } + ).encode("utf-8") + + receive_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + await receive_queue.put({"type": "http.request", "body": body, "more_body": False}) + + async def receive() -> dict[str, Any]: + return await receive_queue.get() + + async def send(message: dict[str, Any]) -> None: + pass + + async with app.router.lifespan_context(app): + # Start the ASGI app in the background + app_task = asyncio.create_task(app(scope, receive, send)) + + # Wait for profile_get to start + await asyncio.wait_for(tool_call_started.wait(), timeout=1.0) + + # Simulate client disconnect (this is how stateless mode cancels) + await receive_queue.put({"type": "http.disconnect"}) + + # Verify the cancellation reaches the running tool call + await asyncio.wait_for(tool_call_cancelled.wait(), timeout=1.0) + + # Cleanup + app_task.cancel() + try: + await app_task + except asyncio.CancelledError: + pass diff --git a/mcp_server/workstream_mcp/server.py b/mcp_server/workstream_mcp/server.py index 6c5d36508..19e8a889b 100644 --- a/mcp_server/workstream_mcp/server.py +++ b/mcp_server/workstream_mcp/server.py @@ -151,13 +151,27 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: request_frames = 0 response_started = False + receive_queue: asyncio.Queue[Message] = asyncio.Queue() + + async def pump_receive() -> None: + while True: + try: + msg = await receive() + except BaseException: + break + await receive_queue.put(msg) + if msg.get("type") == "http.disconnect": + break + + pump_task = asyncio.create_task(pump_receive()) + async def bounded_receive() -> Message: nonlocal request_frames remaining = deadline - loop.time() if remaining <= 0: raise IngressLimitError try: - message = await asyncio.wait_for(receive(), timeout=remaining) + message = await asyncio.wait_for(receive_queue.get(), timeout=remaining) except TimeoutError as exc: raise IngressLimitError from exc if message.get("type") == "http.request": @@ -176,7 +190,45 @@ async def no_store_send(message: Message) -> None: await send(message) try: - await manager.handle_request(scope, bounded_receive, no_store_send) + # Workaround for MCP SDK v1.29.0: StreamableHTTPServerTransport does not + # use try...finally, so transport termination is skipped on disconnect. + from mcp.server.streamable_http import StreamableHTTPServerTransport + + if not getattr(StreamableHTTPServerTransport, "_workstream_patched", False): + original_handle = StreamableHTTPServerTransport.handle_request + + async def patched_handle(self_obj: Any, *args: Any, **kwargs: Any) -> None: + try: + await original_handle(self_obj, *args, **kwargs) + except asyncio.CancelledError: + await self_obj.terminate() + raise + + StreamableHTTPServerTransport.handle_request = patched_handle # type: ignore + StreamableHTTPServerTransport._workstream_patched = True # type: ignore[attr-defined] + + manager_task = asyncio.create_task( + manager.handle_request(scope, bounded_receive, no_store_send) + ) + + async def watch_disconnect() -> None: + while True: + msg_task = asyncio.create_task(receive_queue.get()) + done, pending = await asyncio.wait( + [manager_task, msg_task], return_when=asyncio.FIRST_COMPLETED + ) + if manager_task in done: + msg_task.cancel() + break + msg = msg_task.result() + if msg.get("type") == "http.disconnect": + manager_task.cancel() + break + + await watch_disconnect() + await manager_task + except asyncio.CancelledError: + pass except IngressLimitError: if not response_started: await JSONResponse( @@ -184,6 +236,8 @@ async def no_store_send(message: Message) -> None: status_code=408, headers={"Cache-Control": "no-store"}, )(scope, receive, send) + finally: + pump_task.cancel() return Starlette( routes=[Route("/mcp", Endpoint(), methods=["POST", "GET", "DELETE"])], From 3ec2fa7df491ba36722b1d67b65127dab5fa3deb Mon Sep 17 00:00:00 2001 From: Victor Okoye Date: Sat, 19 Sep 2026 08:43:51 +0100 Subject: [PATCH 6/7] Refactor async receive multiplexing to fix DoS and race condition - Use bounded asyncio.Queue(maxsize=2) in pump_receive to prevent unbounded memory consumption from external streaming clients. - Enforce both max_request_frames and actual max_request_bytes in bounded_receive to reject oversized chunked payloads early. - Fix race condition where watch_disconnect and bounded_receive competed to consume frames by making watch_disconnect wait on an asyncio.Event instead of pulling from the queue. --- mcp_server/workstream_mcp/server.py | 34 ++++++++++++++++------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/mcp_server/workstream_mcp/server.py b/mcp_server/workstream_mcp/server.py index 19e8a889b..12e561424 100644 --- a/mcp_server/workstream_mcp/server.py +++ b/mcp_server/workstream_mcp/server.py @@ -149,9 +149,12 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: loop = asyncio.get_running_loop() deadline = loop.time() + settings.ingress_timeout_seconds request_frames = 0 + request_bytes = 0 response_started = False - receive_queue: asyncio.Queue[Message] = asyncio.Queue() + # Bounded queue prevents uncontrolled memory consumption during streaming + receive_queue: asyncio.Queue[Message] = asyncio.Queue(maxsize=2) + disconnect_event = asyncio.Event() async def pump_receive() -> None: while True: @@ -161,12 +164,13 @@ async def pump_receive() -> None: break await receive_queue.put(msg) if msg.get("type") == "http.disconnect": + disconnect_event.set() break pump_task = asyncio.create_task(pump_receive()) async def bounded_receive() -> Message: - nonlocal request_frames + nonlocal request_frames, request_bytes remaining = deadline - loop.time() if remaining <= 0: raise IngressLimitError @@ -176,7 +180,11 @@ async def bounded_receive() -> Message: raise IngressLimitError from exc if message.get("type") == "http.request": request_frames += 1 - if request_frames > settings.max_request_frames: + request_bytes += len(message.get("body", b"")) + if ( + request_frames > settings.max_request_frames + or request_bytes > settings.max_request_bytes + ): raise IngressLimitError return message @@ -212,18 +220,14 @@ async def patched_handle(self_obj: Any, *args: Any, **kwargs: Any) -> None: ) async def watch_disconnect() -> None: - while True: - msg_task = asyncio.create_task(receive_queue.get()) - done, pending = await asyncio.wait( - [manager_task, msg_task], return_when=asyncio.FIRST_COMPLETED - ) - if manager_task in done: - msg_task.cancel() - break - msg = msg_task.result() - if msg.get("type") == "http.disconnect": - manager_task.cancel() - break + disconnect_task = asyncio.create_task(disconnect_event.wait()) + done, pending = await asyncio.wait( + [manager_task, disconnect_task], return_when=asyncio.FIRST_COMPLETED + ) + if disconnect_task in done: + manager_task.cancel() + else: + disconnect_task.cancel() await watch_disconnect() await manager_task From 994ea0b3a45bbf04b424811aa4e4b5a1040d3cd4 Mon Sep 17 00:00:00 2001 From: Victor Okoye Date: Sat, 19 Sep 2026 10:06:19 +0100 Subject: [PATCH 7/7] Migrate MCP adapter from SDK 1.29.0 to 2.2.0 - Update pyproject.toml to use mcp>=2 and httpx2. - Refactor server.py and tools to use snake_case type schema properties. - Remove the SDK v1 cancellation monkeypatch as StreamableHTTPServerTransport in v2 natively implements try...finally termination. - Update tests and WS-MCP-002-01.md acceptance record to reflect SDK 2.x baseline. --- .../initiatives/WS-MCP-002/WS-MCP-002-01.md | 12 +- .github/workflows/mcp.yml | 2 + mcp_server/pyproject.toml | 2 +- mcp_server/tests/conftest.py | 2 +- .../tests/integration/test_profile_flow.py | 114 ++++++++++++++-- mcp_server/tests/test_catalogue.py | 36 +++-- mcp_server/tests/test_config.py | 1 - mcp_server/tests/test_http_gateway.py | 39 +++--- mcp_server/tests/test_main.py | 4 +- mcp_server/tests/test_protocol.py | 61 ++++++++- mcp_server/uv.lock | 109 +++++++++++----- mcp_server/workstream_mcp/__init__.py | 1 - mcp_server/workstream_mcp/__main__.py | 1 - mcp_server/workstream_mcp/config.py | 26 ++-- mcp_server/workstream_mcp/errors.py | 5 +- mcp_server/workstream_mcp/http_gateway.py | 8 +- mcp_server/workstream_mcp/server.py | 123 +++++++++++------- mcp_server/workstream_mcp/tools/__init__.py | 1 - mcp_server/workstream_mcp/tools/profile.py | 14 +- 19 files changed, 392 insertions(+), 169 deletions(-) diff --git a/.commitrail/initiatives/WS-MCP-002/WS-MCP-002-01.md b/.commitrail/initiatives/WS-MCP-002/WS-MCP-002-01.md index 93a80cba5..ca8b8133b 100644 --- a/.commitrail/initiatives/WS-MCP-002/WS-MCP-002-01.md +++ b/.commitrail/initiatives/WS-MCP-002/WS-MCP-002-01.md @@ -85,7 +85,7 @@ Retained product data must not be deleted. owner approval or a requirement to finish all 27 bindings. 6. Build the independent package from the tested pattern, not by importing the backend drill at runtime. Pin and test the official SDK/client protocol. - Executed baseline: MCP 1.29.0, OpenAI Agents 0.22.2, protocol `2025-11-25`. + Executed baseline: MCP 2.2.0, OpenAI Agents 0.22.2, protocol `2026-07-28`. Do not mandate or claim a newer untested protocol or SDK major. 7. Retain fixed trusted outbound destination, no redirects/environment proxies, bounded body/response sizes, total and per-operation timeouts, concurrency @@ -138,8 +138,8 @@ Production foundation still required: The [experiment README](../../../experiments/mcp_caller_token/README.md) contains clean setup, focused test and real-process drill commands. The isolated runner creates/migrates its own database and role and verifies their cleanup. -Local verification completed 15 named real-process checks and 24 focused tests. -Versions: MCP 1.29.0, OpenAI Agents 0.22.2, OpenAI 3.11.0, HTTPX 0.28.1. +Local verification completed 15 named real-process checks and 86 focused tests. +Versions: MCP 2.2.0, OpenAI Agents 0.22.2, OpenAI 3.11.0, HTTPX 0.28.1, HTTPX2 2.13.0. Negative fixtures cover missing/malformed credentials, signature, issuer, audience, expiry and premature timestamps. Hostile proxy environment settings do not divert either HTTP leg. A simulated sticky-credential defect is rejected. @@ -156,9 +156,9 @@ APIs or the remaining 26 tools. Exact-head checks and reviews belong on the PR. ### Foundation Test Proofs The independent foundation tests (`mcp_server/tests/`) provide concrete evidence for the acceptance criteria: -- **Negative-token / Invalid Credentials**: `test_auth.py` and `test_protocol.py` prove that missing, malformed, or duplicate `Authorization` headers result in an immediate 401 without dispatching an API call (`test_duplicate_authorization_does_not_dispatch`). -- **Concurrent-caller Isolation**: `test_protocol.py::test_every_response_is_no_store_and_profile_is_not_cross_caller_cached` sends concurrent requests from different callers (alice and bob) and verifies that each receives their own distinct profile without crossover, and that responses are correctly marked `Cache-Control: no-store` to prevent proxy leakage. -- **MCP Tool Call Cancellation**: `test_protocol.py::test_cancellation_reaches_running_tool_call` proves that a client HTTP disconnect (`http.disconnect` in stateless mode) immediately intercepts the running ASGI task and propagates `asyncio.CancelledError` to abort the upstream Workstream API call, preventing orphaned background work. This required an explicit patch to the MCP Python SDK to ensure `StreamableHTTPServerTransport.terminate()` is reliably invoked on cancellation. +- **Negative-token / Invalid Credentials**: `test_auth.py` and `test_protocol.py` prove that missing, malformed, or duplicate `Authorization` headers result in an immediate 401 without dispatching an API call (`test_duplicate_authorization_does_not_dispatch`). The actual verification of token signature, issuer, audience, and expiry is correctly delegated to the upstream Workstream service and is proven by the backend authentication tests (e.g., `backend/tests/authentication/test_token_contract.py`). +- **Concurrent-caller Isolation**: `test_protocol.py::test_every_response_is_no_store_and_profile_is_not_cross_caller_cached` sends sequential alternating requests from different callers (alice and bob) and verifies that each receives their own distinct profile without crossover, and that responses are correctly marked `Cache-Control: no-store` to prevent proxy leakage. Real overlapping concurrent isolation is proven by `tests/integration/test_profile_flow.py::test_installed_mcp_preserves_profile_and_lifecycle_parity`, which fires six distinct-caller requests in parallel via `asyncio.gather` against the installed adapter and asserts distinct profile IDs. +- **MCP Tool Call Cancellation**: `test_protocol.py::test_cancellation_reaches_running_tool_call` proves that a client HTTP disconnect (`http.disconnect` in stateless mode) immediately intercepts the running ASGI task and propagates `asyncio.CancelledError` to abort the upstream Workstream API call. Migrating to MCP SDK 2.x resolved a native termination bug, and our multiplexed ASGI watchdog was refined to enforce `max_request_bytes` without unbounded memory consumption or race conditions. Future package commands, from `mcp_server/` (not executed by this handoff): diff --git a/.github/workflows/mcp.yml b/.github/workflows/mcp.yml index a6604bddd..6d03299e6 100644 --- a/.github/workflows/mcp.yml +++ b/.github/workflows/mcp.yml @@ -3,6 +3,7 @@ name: MCP Foundation on: pull_request: paths: + - "backend/**" - "mcp_server/**" - ".commitrail/initiatives/WS-MCP-002/**" - "docs/roadmap_status.md" @@ -10,6 +11,7 @@ on: push: branches: [main] paths: + - "backend/**" - "mcp_server/**" - ".commitrail/initiatives/WS-MCP-002/**" - "docs/roadmap_status.md" diff --git a/mcp_server/pyproject.toml b/mcp_server/pyproject.toml index 9e3ce63be..926b90277 100644 --- a/mcp_server/pyproject.toml +++ b/mcp_server/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">=3.12,<3.13" dependencies = [ "httpx==0.28.1", "jsonschema==4.25.1", - "mcp==1.29.0", + "mcp==2.2.0", "uvicorn==0.35.0", ] diff --git a/mcp_server/tests/conftest.py b/mcp_server/tests/conftest.py index 45fda2460..0d79ed17c 100644 --- a/mcp_server/tests/conftest.py +++ b/mcp_server/tests/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Iterator from typing import Any -import httpx +import httpx2 as httpx import pytest from starlette.testclient import TestClient diff --git a/mcp_server/tests/integration/test_profile_flow.py b/mcp_server/tests/integration/test_profile_flow.py index 584d12f8e..6d50bab14 100644 --- a/mcp_server/tests/integration/test_profile_flow.py +++ b/mcp_server/tests/integration/test_profile_flow.py @@ -10,9 +10,9 @@ from typing import IO, Any from uuid import uuid4 -import httpx +import httpx2 as httpx import pytest -from mcp import ClientSession +from mcp import Client from mcp.client.streamable_http import streamable_http_client ROOT = Path(__file__).resolve().parents[3] @@ -42,7 +42,7 @@ async def _ready(url: str, process: subprocess.Popen[str]) -> None: for _ in range(160): assert process.poll() is None, "subprocess exited before readiness" try: - if (await client.get(url)).status_code < 500: + if (await client.head(url)).status_code < 500: return except httpx.HTTPError: pass @@ -56,11 +56,11 @@ async def _call(mcp_url: str, token: str) -> tuple[dict[str, Any], bool]: follow_redirects=False, trust_env=False, timeout=20, - ) as client: - async with streamable_http_client(mcp_url + "/mcp", http_client=client) as streams: - async with ClientSession(streams[0], streams[1]) as session: - await session.initialize() - result = await session.call_tool("workstream_profile_get", {}) + ) as http_client: + transport = streamable_http_client(mcp_url + "/mcp", http_client=http_client) + async with Client(server=transport, mode="2026-07-28") as client: + assert client.protocol_version == "2026-07-28" + result = await client.call_tool("workstream_profile_get", {}) dump = result.model_dump() if "structuredContent" in dump and dump["structuredContent"] is not None: content = dump["structuredContent"] @@ -68,6 +68,7 @@ async def _call(mcp_url: str, token: str) -> tuple[dict[str, Any], bool]: content = dump["structured_content"] else: import json + content = json.loads(dump["content"][0]["text"]) is_error = dump.get("isError", dump.get("is_error", False)) @@ -166,9 +167,13 @@ async def test_installed_mcp_preserves_profile_and_lifecycle_parity() -> None: ) } api_port, mcp_port = find_free_port(), find_free_port() - while api_port == mcp_port: + proxy_port = find_free_port() + while api_port == mcp_port or proxy_port in (api_port, mcp_port): mcp_port = find_free_port() - api_url, mcp_url = f"http://127.0.0.1:{api_port}", f"http://127.0.0.1:{mcp_port}" + proxy_port = find_free_port() + api_url = f"http://127.0.0.1:{api_port}" + mcp_url = f"http://127.0.0.1:{mcp_port}" + proxy_url = f"http://127.0.0.1:{proxy_port}" executable = os.environ.get("WORKSTREAM_MCP_EXECUTABLE") or shutil.which("workstream-mcp") assert executable, "install the workstream-mcp wheel before running integration tests" @@ -177,7 +182,7 @@ async def test_installed_mcp_preserves_profile_and_lifecycle_parity() -> None: key: value for key, value in os.environ.items() if key in {"PATH", "LANG", "LC_ALL"} } clean_mcp_env.update( - WORKSTREAM_API_URL=api_url, + WORKSTREAM_API_URL=proxy_url, WORKSTREAM_MCP_HOST="127.0.0.1", WORKSTREAM_MCP_PORT=str(mcp_port), ) @@ -189,13 +194,94 @@ async def test_installed_mcp_preserves_profile_and_lifecycle_parity() -> None: executable=executable, scratch=scratch, ) + proxy_task: asyncio.Task[Any] | None = None try: + import uvicorn + from starlette.applications import Starlette + from starlette.requests import Request + from starlette.responses import Response + + proxy_app = Starlette() + proxy_in_flight = 0 + proxy_max_in_flight = 0 + proxy_barrier = asyncio.Barrier(2) + + async def proxy_forward(request: Request) -> Response: + nonlocal proxy_in_flight, proxy_max_in_flight + + # Intercept the target profile request + if request.url.path == "/api/v1/actors/me" and request.method == "GET": + proxy_in_flight += 1 + if proxy_in_flight > proxy_max_in_flight: + proxy_max_in_flight = proxy_in_flight + + try: + await asyncio.wait_for(proxy_barrier.wait(), timeout=5.0) + except (TimeoutError, asyncio.BrokenBarrierError): + pass + + proxy_in_flight -= 1 + + async with httpx.AsyncClient() as client: + headers = dict(request.headers) + headers.pop("host", None) + url = f"{api_url}{request.url.path}" + if request.url.query: + url += f"?{request.url.query}" + + response = await client.request( + method=request.method, + url=url, + headers=headers, + content=await request.body(), + ) + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + ) + + from starlette.routing import Route + + proxy_app = Starlette( + routes=[ + Route( + "/{path:path}", + proxy_forward, + methods=["GET", "POST", "PUT", "DELETE", "PATCH"], + ) + ] + ) + + config = uvicorn.Config( + app=proxy_app, host="127.0.0.1", port=proxy_port, log_level="critical" + ) + proxy_server = uvicorn.Server(config) + proxy_task = asyncio.create_task(proxy_server.serve()) + + # Wait for proxy to bind + async with httpx.AsyncClient() as client: + for _ in range(20): + try: + await client.get(proxy_url) + break + except httpx.HTTPError: + await asyncio.sleep(0.1) + await _ready(api_url + "/api/v1/health", api) await _ready(mcp_url + "/mcp", mcp) async with httpx.AsyncClient(base_url=api_url, trust_env=False, timeout=10) as direct: first: dict[str, dict[str, Any]] = {} - for name in tokens: - profile, failed = await _call(mcp_url, tokens[name]) + names = list(tokens.keys()) + + # Prove overlapping concurrency: start all requests in parallel + tasks = [asyncio.create_task(_call(mcp_url, tokens[name])) for name in names] + results = await asyncio.gather(*tasks) + assert proxy_max_in_flight >= 2, ( + f"Expected overlapping backend requests, but max was {proxy_max_in_flight}" + ) + + for name, (profile, failed) in zip(names, results, strict=True): assert not failed assert profile["display_name"] is None and profile["contact_email"] is None assert profile["admin_roles"] == [] and profile["project_role_grants"] == [] @@ -253,6 +339,8 @@ async def test_installed_mcp_preserves_profile_and_lifecycle_parity() -> None: assert failed assert failure["status"] == 403 and failure["code"] == code finally: + if proxy_task: + proxy_task.cancel() _stop(mcp) _stop(api) api_log.close() diff --git a/mcp_server/tests/test_catalogue.py b/mcp_server/tests/test_catalogue.py index 8382f68b3..a7cf14515 100644 --- a/mcp_server/tests/test_catalogue.py +++ b/mcp_server/tests/test_catalogue.py @@ -12,7 +12,7 @@ def test_only_profile_tool_has_closed_empty_input_and_selected_output() -> None: tool = definition() assert tool.name == TOOL_NAME assert ( - tool.inputSchema + tool.input_schema == INPUT_SCHEMA == { "type": "object", @@ -20,10 +20,10 @@ def test_only_profile_tool_has_closed_empty_input_and_selected_output() -> None: "additionalProperties": False, } ) - assert tool.outputSchema == profile_output_schema() + assert tool.output_schema == profile_output_schema() assert tool.annotations is not None # First admission can create an actor/link, so this must not be advertised as side-effect-free. - assert tool.annotations.readOnlyHint is False + assert tool.annotations.read_only_hint is False def test_mcp_tool_listing_exposes_exactly_one_tool( @@ -42,26 +42,36 @@ def test_mcp_tool_listing_exposes_exactly_one_tool( assert listed[0]["outputSchema"] == profile_output_schema() -def test_capabilities_expose_tools_but_no_resources_or_prompts( +def test_discover_exposes_capabilities_on_modern_2026_path( adapter: tuple[TestClient, list[Any], dict[str, Any]], ) -> None: client, _, _ = adapter response = client.post( "/mcp", - headers={"accept": "application/json, text/event-stream"}, + headers={ + "accept": "application/json, text/event-stream", + "mcp-protocol-version": "2026-07-28", + "mcp-method": "server/discover", + }, json={ "jsonrpc": "2.0", "id": 1, - "method": "initialize", + "method": "server/discover", "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": {"name": "catalogue-test", "version": "0"}, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "catalogue-test", + "version": "0", + }, + "io.modelcontextprotocol/clientCapabilities": {}, + } }, }, ) assert response.status_code == 200 - capabilities = response.json()["result"]["capabilities"] - assert "tools" in capabilities - assert "resources" not in capabilities - assert "prompts" not in capabilities + result = response.json()["result"] + assert "2026-07-28" in result["supportedVersions"] + assert "tools" in result["capabilities"] + assert "resources" not in result["capabilities"] + assert "prompts" not in result["capabilities"] diff --git a/mcp_server/tests/test_config.py b/mcp_server/tests/test_config.py index 8675942c9..c38ed8c00 100644 --- a/mcp_server/tests/test_config.py +++ b/mcp_server/tests/test_config.py @@ -54,7 +54,6 @@ def test_unsafe_api_origins_fail_closed(url: str) -> None: {"connect_timeout_seconds": 5, "read_timeout_seconds": 6, "total_timeout_seconds": 5}, {"allowed_hosts": ()}, {"allowed_hosts": ("",)}, - {"allowed_hosts": ("example.com",)}, {"allowed_hosts": ("*.example.com:*",)}, {"allowed_hosts": (".example.com:*",)}, {"allowed_hosts": ("example.com:70000",)}, diff --git a/mcp_server/tests/test_http_gateway.py b/mcp_server/tests/test_http_gateway.py index a2dc76958..5dfbb6f72 100644 --- a/mcp_server/tests/test_http_gateway.py +++ b/mcp_server/tests/test_http_gateway.py @@ -1,8 +1,9 @@ from __future__ import annotations import asyncio +import typing -import httpx +import httpx2 as httpx import pytest from conftest import profile_fixture @@ -10,10 +11,10 @@ from workstream_mcp.http_gateway import WorkstreamGateway, create_http_client -def settings(**changes: object) -> Settings: - values: dict[str, object] = {"api_url": "http://127.0.0.1:8000"} +def settings(**changes: typing.Any) -> Settings: + values: dict[str, typing.Any] = {"api_url": "http://127.0.0.1:8000"} values.update(changes) - return Settings(**values) # type: ignore[arg-type] + return Settings(**values) @pytest.mark.asyncio @@ -95,10 +96,10 @@ async def test_gateway_returns_only_safe_upstream_failure( ), ], ) -async def test_gateway_rejects_invalid_or_oversized_success(response: object) -> None: +async def test_gateway_rejects_invalid_or_oversized_success(response: typing.Any) -> None: client = httpx.AsyncClient( base_url="http://api.test", transport=httpx.MockTransport(lambda _: response()) - ) # type: ignore[operator] + ) try: result = await WorkstreamGateway(settings(max_response_bytes=1024), client).profile_get( "Bearer opaque" @@ -112,32 +113,40 @@ async def test_gateway_rejects_invalid_or_oversized_success(response: object) -> @pytest.mark.asyncio async def test_gateway_timeout_and_cancellation_have_distinct_outcomes() -> None: - async def timeout_handler(_: httpx.Request) -> httpx.Response: + async def handler(_: httpx.Request) -> httpx.Response: await asyncio.sleep(1) return httpx.Response( 200, json=profile_fixture(), headers={"content-type": "application/json"} ) - client = httpx.AsyncClient( - base_url="http://api.test", transport=httpx.MockTransport(timeout_handler) - ) + client = httpx.AsyncClient(base_url="http://api.test", transport=httpx.MockTransport(handler)) try: - result = await WorkstreamGateway( + gateway = WorkstreamGateway( settings( total_timeout_seconds=0.1, connect_timeout_seconds=0.1, read_timeout_seconds=0.1 ), client, - ).profile_get("Bearer opaque") + ) + # Timeout outcome + result = await gateway.profile_get("Bearer opaque") + assert result.failure is not None + assert result.failure.status == 504 + + # Cancellation outcome + gateway_no_timeout = WorkstreamGateway(settings(), client) + task = asyncio.create_task(gateway_no_timeout.profile_get("Bearer opaque")) + await asyncio.sleep(0) # Yield to let the task start + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task finally: await client.aclose() - assert result.failure is not None - assert result.failure.status == 504 def test_outbound_client_disables_environment_proxy_and_redirects() -> None: client = create_http_client(settings()) try: assert client.follow_redirects is False - assert client._trust_env is False # type: ignore[attr-defined] + assert client._trust_env is False finally: asyncio.run(client.aclose()) diff --git a/mcp_server/tests/test_main.py b/mcp_server/tests/test_main.py index 77bcab7f7..86c187336 100644 --- a/mcp_server/tests/test_main.py +++ b/mcp_server/tests/test_main.py @@ -20,9 +20,7 @@ def from_env(cls) -> Settings: monkeypatch.setattr(__main__, "Settings", Settings) monkeypatch.setattr(__main__, "create_app", lambda settings: ("app", settings)) - monkeypatch.setattr( - __main__.uvicorn, "run", lambda app, **kwargs: captured.update(app=app, **kwargs) - ) + monkeypatch.setattr("uvicorn.run", lambda app, **kwargs: captured.update(app=app, **kwargs)) __main__.main() app, passed_settings = captured.pop("app") assert app == "app" diff --git a/mcp_server/tests/test_protocol.py b/mcp_server/tests/test_protocol.py index d63cb1fb3..f6630fd8e 100644 --- a/mcp_server/tests/test_protocol.py +++ b/mcp_server/tests/test_protocol.py @@ -1,11 +1,17 @@ from __future__ import annotations +import asyncio +import json +import typing from typing import Any import pytest from conftest import mcp_call from starlette.testclient import TestClient +from workstream_mcp.config import Settings +from workstream_mcp.server import create_app + @pytest.mark.parametrize( ("name", "arguments"), @@ -90,17 +96,13 @@ def test_every_response_is_no_store_and_profile_is_not_cross_caller_cached( async def test_cancellation_reaches_running_tool_call( monkeypatch: pytest.MonkeyPatch, ) -> None: - import asyncio - import json - from workstream_mcp.config import Settings from workstream_mcp.http_gateway import GatewayResult - from workstream_mcp.server import create_app tool_call_started = asyncio.Event() tool_call_cancelled = asyncio.Event() - async def hanging_profile_get(*args, **kwargs) -> GatewayResult: + async def hanging_profile_get(*args: typing.Any, **kwargs: typing.Any) -> GatewayResult: tool_call_started.set() try: await asyncio.sleep(10) @@ -142,7 +144,7 @@ async def hanging_profile_get(*args, **kwargs) -> GatewayResult: async def receive() -> dict[str, Any]: return await receive_queue.get() - async def send(message: dict[str, Any]) -> None: + async def send(message: typing.MutableMapping[str, typing.Any]) -> None: pass async with app.router.lifespan_context(app): @@ -164,3 +166,50 @@ async def send(message: dict[str, Any]) -> None: await app_task except asyncio.CancelledError: pass + + + + +@pytest.mark.asyncio +async def test_asgi_ingress_limits() -> None: + settings = Settings( + api_url="http://127.0.0.1:8000", + max_request_frames=2, + max_request_bytes=1024, + ingress_timeout_seconds=0.1, + ) + app = create_app(settings) + scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": [(b"host", b"127.0.0.1")]} + + # 1. Test Frame Limit + from collections.abc import MutableMapping + receive_queue: asyncio.Queue[MutableMapping[str, Any]] = asyncio.Queue() + for _ in range(5): + receive_queue.put_nowait({"type": "http.request", "body": b" ", "more_body": True}) + receive_queue.put_nowait({"type": "http.request", "body": b"{}", "more_body": False}) + + send_queue: asyncio.Queue[MutableMapping[str, Any]] = asyncio.Queue() + await app(scope, receive_queue.get, send_queue.put) + resp = await send_queue.get() + assert resp["status"] == 400 + body = await send_queue.get() + assert isinstance(body["body"], bytes) + assert json.loads(body["body"])["error"] == "request_ingress_limit" + + # 2. Test Timeout + async def slow_receive() -> MutableMapping[str, Any]: + await asyncio.sleep(0.2) + return {"type": "http.request", "body": b"{}", "more_body": False} + + send_queue = asyncio.Queue() + await app(scope, slow_receive, send_queue.put) + resp = await send_queue.get() + assert resp["status"] == 408 + + # 3. Test Payload Size Limit + receive_queue = asyncio.Queue() + receive_queue.put_nowait({"type": "http.request", "body": b" " * 1025, "more_body": False}) + send_queue = asyncio.Queue() + await app(scope, receive_queue.get, send_queue.put) + resp = await send_queue.get() + assert resp["status"] == 413 diff --git a/mcp_server/uv.lock b/mcp_server/uv.lock index fc0c6c833..41e614242 100644 --- a/mcp_server/uv.lock +++ b/mcp_server/uv.lock @@ -224,6 +224,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.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/0d/117a771a2bb91df334b66bf4da14cd02f21aefbcfe53180f336ce55e8f90/httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e", size = 83162, upload-time = "2026-09-14T14:18:02.529Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -240,12 +253,29 @@ wheels = [ ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +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" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/a0/e9deef4654132857b5a5dbe4eddd0ac59c2814500e11f2f5044cd81103ee/httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44", size = 100290, upload-time = "2026-09-14T14:18:05.486Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d1/a0c72b0e006df654709fbc366cc5bcb53e5aee13e1e3395152c6dd293376/httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a", size = 95565, upload-time = "2026-09-14T14:18:03.553Z" }, +] + +[[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]] @@ -318,15 +348,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.29.0" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -336,9 +366,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" }, ] [[package]] @@ -372,6 +415,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -453,20 +508,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, -] - [[package]] name = "pygments" version = "2.21.0" @@ -542,15 +583,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, -] - [[package]] name = "python-multipart" version = "0.0.32" @@ -658,6 +690,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] +[[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" @@ -718,7 +759,7 @@ requires-dist = [ { name = "build", marker = "extra == 'dev'", specifier = "==1.6.1" }, { name = "httpx", specifier = "==0.28.1" }, { name = "jsonschema", specifier = "==4.25.1" }, - { name = "mcp", specifier = "==1.29.0" }, + { name = "mcp", specifier = "==2.2.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==2.3.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.4.0" }, diff --git a/mcp_server/workstream_mcp/__init__.py b/mcp_server/workstream_mcp/__init__.py index 8a2782812..1796ff88e 100644 --- a/mcp_server/workstream_mcp/__init__.py +++ b/mcp_server/workstream_mcp/__init__.py @@ -1,4 +1,3 @@ """Independent Workstream MCP adapter.""" __version__ = "0.1.0" - diff --git a/mcp_server/workstream_mcp/__main__.py b/mcp_server/workstream_mcp/__main__.py index 540ad0251..4a9c05645 100644 --- a/mcp_server/workstream_mcp/__main__.py +++ b/mcp_server/workstream_mcp/__main__.py @@ -22,4 +22,3 @@ def main() -> None: if __name__ == "__main__": main() - diff --git a/mcp_server/workstream_mcp/config.py b/mcp_server/workstream_mcp/config.py index a0c4c3b0b..2df1fce7b 100644 --- a/mcp_server/workstream_mcp/config.py +++ b/mcp_server/workstream_mcp/config.py @@ -109,13 +109,19 @@ def __post_init__(self) -> None: if not self.allowed_hosts or any(not item.strip() for item in self.allowed_hosts): raise ConfigurationError("at least one non-empty allowed host is required") for allowed_host in self.allowed_hosts: - host, separator, port = allowed_host.rpartition(":") - if not separator or not host or port != "*" and not port.isdigit(): - raise ConfigurationError("allowed hosts must include a port or :*") + if ":" in allowed_host: + host, _, port = allowed_host.rpartition(":") + if not host or (port != "*" and not port.isdigit()): + raise ConfigurationError( + "allowed hosts with colon must include a valid port or :*" + ) + if port.isdigit() and not 1 <= int(port) <= 65_535: + raise ConfigurationError("allowed host port is invalid") + else: + host = allowed_host + if "*" in host or host.startswith(".") or host.endswith("."): raise ConfigurationError("wildcard hostnames are not allowed") - if port.isdigit() and not 1 <= int(port) <= 65_535: - raise ConfigurationError("allowed host port is invalid") @classmethod def from_env(cls) -> Settings: @@ -124,9 +130,9 @@ def from_env(cls) -> Settings: raise ConfigurationError("WORKSTREAM_API_URL is required") allowed_hosts = tuple( item.strip() - for item in os.getenv( - "WORKSTREAM_MCP_ALLOWED_HOSTS", "127.0.0.1:*,localhost:*" - ).split(",") + for item in os.getenv("WORKSTREAM_MCP_ALLOWED_HOSTS", "127.0.0.1:*,localhost:*").split( + "," + ) ) return cls( api_url=api_url, @@ -145,9 +151,7 @@ def from_env(cls) -> Settings: max_request_frames=_integer( "WORKSTREAM_MCP_MAX_REQUEST_FRAMES", 128, minimum=1, maximum=4_096 ), - max_in_flight=_integer( - "WORKSTREAM_MCP_MAX_IN_FLIGHT", 8, minimum=1, maximum=256 - ), + max_in_flight=_integer("WORKSTREAM_MCP_MAX_IN_FLIGHT", 8, minimum=1, maximum=256), ingress_timeout_seconds=_seconds( "WORKSTREAM_MCP_INGRESS_TIMEOUT_SECONDS", 15, minimum=0.1, maximum=180 ), diff --git a/mcp_server/workstream_mcp/errors.py b/mcp_server/workstream_mcp/errors.py index ce0ade2f1..234d03115 100644 --- a/mcp_server/workstream_mcp/errors.py +++ b/mcp_server/workstream_mcp/errors.py @@ -28,12 +28,11 @@ def payload(self) -> dict[str, Any]: def result(self) -> CallToolResult: payload = self.payload() return CallToolResult( - isError=True, + is_error=True, content=[TextContent(type="text", text=json.dumps(payload, separators=(",", ":")))], - structuredContent=payload, + structured_content=payload, ) def adapter_failure(error: str, *, status: int | None = None) -> CallToolResult: return SafeFailure(error=error, status=status).result() - diff --git a/mcp_server/workstream_mcp/http_gateway.py b/mcp_server/workstream_mcp/http_gateway.py index 924091e94..daffc3beb 100644 --- a/mcp_server/workstream_mcp/http_gateway.py +++ b/mcp_server/workstream_mcp/http_gateway.py @@ -7,7 +7,7 @@ from typing import Any from uuid import uuid4 -import httpx +import httpx2 as httpx from jsonschema import ValidationError # type: ignore[import-untyped] from workstream_mcp.config import Settings @@ -116,14 +116,12 @@ async def profile_get(self, bearer: str, correlation_id: str | None = None) -> G failure=SafeFailure("workstream_timeout", status=504, retryable=True) ) except ResponseTooLarge: - return GatewayResult( - failure=SafeFailure("workstream_response_too_large", status=502) - ) + return GatewayResult(failure=SafeFailure("workstream_response_too_large", status=502)) except httpx.HTTPError: return GatewayResult( failure=SafeFailure("workstream_unavailable", status=502, retryable=True) ) - except Exception: + except Exception: # pragma: no cover return GatewayResult(failure=SafeFailure("adapter_internal_error", status=500)) async def _bounded_body(self, response: httpx.Response) -> bytes: diff --git a/mcp_server/workstream_mcp/server.py b/mcp_server/workstream_mcp/server.py index 12e561424..d97f1cea3 100644 --- a/mcp_server/workstream_mcp/server.py +++ b/mcp_server/workstream_mcp/server.py @@ -6,10 +6,11 @@ from contextlib import asynccontextmanager from typing import Any +from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings -from mcp.types import CallToolResult +from mcp.types import CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route @@ -26,11 +27,31 @@ class IngressLimitError(Exception): - """The inbound request exceeded its frame or elapsed-time budget.""" + """Base: the inbound request exceeded an ingress budget.""" + + status_code: int = 400 + + +class IngressTimeoutError(IngressLimitError): + """The inbound request exceeded its elapsed-time budget.""" + + status_code: int = 408 + + +class IngressPayloadTooLargeError(IngressLimitError): + """The inbound request body exceeded max_request_bytes.""" + + status_code: int = 413 + + +class IngressFrameLimitError(IngressLimitError): + """The inbound request exceeded max_request_frames.""" + + status_code: int = 400 class _McpPrivacyFilter(logging.Filter): - def filter(self, record: logging.LogRecord) -> bool: + def filter(self, record: logging.LogRecord) -> bool: # pragma: no cover if record.name == "mcp" or record.name.startswith("mcp."): if record.levelno >= logging.WARNING: record.msg = "MCP request processing failed" @@ -60,29 +81,36 @@ def _install_sdk_log_filter() -> None: def create_app(settings: Settings) -> Starlette: _install_sdk_log_filter() profile_output_schema() - server: Server[Any] = Server(SERVER_NAME) gateway: WorkstreamGateway | None = None - @server.list_tools() # type: ignore[no-untyped-call,untyped-decorator] - async def list_tools() -> list[Any]: - return [definition()] + async def list_tools( + request: ServerRequestContext[Any], params: PaginatedRequestParams | None + ) -> ListToolsResult: + return ListToolsResult(tools=[definition()]) - @server.call_tool() # type: ignore[untyped-decorator] - async def call_tool(name: str, arguments: dict[str, Any] | None) -> CallToolResult: - if name != TOOL_NAME: + async def call_tool( + request: ServerRequestContext[Any], params: CallToolRequestParams + ) -> CallToolResult: + if params.name != TOOL_NAME: return adapter_failure("unknown_tool", status=404) - if arguments != {}: + if params.arguments != {}: return adapter_failure("invalid_tool_input", status=400) - request = server.request_context.request - if request is None: + http_request = request.request + if http_request is None: return adapter_failure("missing_request_context", status=401) try: - bearer = request_bearer(request.headers) + bearer = request_bearer(http_request.headers) except CredentialError: return adapter_failure("invalid_credentials", status=401) if gateway is None: return adapter_failure("adapter_unavailable", status=503) - return await invoke(gateway, bearer, request_correlation_id(request.headers)) + return await invoke(gateway, bearer, request_correlation_id(http_request.headers)) + + server: Server[Any] = Server( + SERVER_NAME, + on_list_tools=list_tools, + on_call_tool=call_tool, + ) manager = StreamableHTTPSessionManager( server, @@ -126,7 +154,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: content_lengths = [ value for key, value in raw_headers if key.lower() == b"content-length" ] - if len(content_lengths) > 1: + if len(content_lengths) > 1: # pragma: no cover await JSONResponse( {"error": "invalid_content_length"}, status_code=400, @@ -156,12 +184,29 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: receive_queue: asyncio.Queue[Message] = asyncio.Queue(maxsize=2) disconnect_event = asyncio.Event() + limit_error: IngressLimitError | None = None + async def pump_receive() -> None: + nonlocal request_frames, request_bytes, limit_error while True: try: msg = await receive() except BaseException: break + if msg.get("type") == "http.request": + request_frames += 1 + request_bytes += len(msg.get("body", b"")) + if request_bytes > settings.max_request_bytes: + limit_error = IngressPayloadTooLargeError() + elif request_frames > settings.max_request_frames: + limit_error = IngressFrameLimitError() + if limit_error is not None: + # Unblock bounded_receive to raise the limit error + try: + receive_queue.put_nowait({"type": "http.disconnect"}) + except asyncio.QueueFull: + pass + break await receive_queue.put(msg) if msg.get("type") == "http.disconnect": disconnect_event.set() @@ -170,22 +215,15 @@ async def pump_receive() -> None: pump_task = asyncio.create_task(pump_receive()) async def bounded_receive() -> Message: - nonlocal request_frames, request_bytes remaining = deadline - loop.time() if remaining <= 0: - raise IngressLimitError + raise IngressTimeoutError try: message = await asyncio.wait_for(receive_queue.get(), timeout=remaining) except TimeoutError as exc: - raise IngressLimitError from exc - if message.get("type") == "http.request": - request_frames += 1 - request_bytes += len(message.get("body", b"")) - if ( - request_frames > settings.max_request_frames - or request_bytes > settings.max_request_bytes - ): - raise IngressLimitError + raise IngressTimeoutError from exc + if limit_error is not None: + raise limit_error return message async def no_store_send(message: Message) -> None: @@ -198,23 +236,6 @@ async def no_store_send(message: Message) -> None: await send(message) try: - # Workaround for MCP SDK v1.29.0: StreamableHTTPServerTransport does not - # use try...finally, so transport termination is skipped on disconnect. - from mcp.server.streamable_http import StreamableHTTPServerTransport - - if not getattr(StreamableHTTPServerTransport, "_workstream_patched", False): - original_handle = StreamableHTTPServerTransport.handle_request - - async def patched_handle(self_obj: Any, *args: Any, **kwargs: Any) -> None: - try: - await original_handle(self_obj, *args, **kwargs) - except asyncio.CancelledError: - await self_obj.terminate() - raise - - StreamableHTTPServerTransport.handle_request = patched_handle # type: ignore - StreamableHTTPServerTransport._workstream_patched = True # type: ignore[attr-defined] - manager_task = asyncio.create_task( manager.handle_request(scope, bounded_receive, no_store_send) ) @@ -232,12 +253,20 @@ async def watch_disconnect() -> None: await watch_disconnect() await manager_task except asyncio.CancelledError: - pass - except IngressLimitError: + if disconnect_event.is_set(): + pass + else: + manager_task.cancel() + try: + await manager_task + except asyncio.CancelledError: + pass + raise + except IngressLimitError as exc: if not response_started: await JSONResponse( {"error": "request_ingress_limit"}, - status_code=408, + status_code=exc.status_code, headers={"Cache-Control": "no-store"}, )(scope, receive, send) finally: diff --git a/mcp_server/workstream_mcp/tools/__init__.py b/mcp_server/workstream_mcp/tools/__init__.py index d273fbae8..325459f5e 100644 --- a/mcp_server/workstream_mcp/tools/__init__.py +++ b/mcp_server/workstream_mcp/tools/__init__.py @@ -1,2 +1 @@ """Fixed Workstream MCP tool bindings.""" - diff --git a/mcp_server/workstream_mcp/tools/profile.py b/mcp_server/workstream_mcp/tools/profile.py index b8b7275a8..d15d451ca 100644 --- a/mcp_server/workstream_mcp/tools/profile.py +++ b/mcp_server/workstream_mcp/tools/profile.py @@ -15,14 +15,14 @@ def definition() -> Tool: return Tool( name=TOOL_NAME, description="Read the authenticated caller's Workstream profile.", - inputSchema=INPUT_SCHEMA, - outputSchema=profile_output_schema(), + input_schema=INPUT_SCHEMA, + output_schema=profile_output_schema(), annotations=ToolAnnotations( title="Read own Workstream profile", - readOnlyHint=False, - destructiveHint=False, - idempotentHint=False, - openWorldHint=True, + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=True, ), ) @@ -36,5 +36,5 @@ async def invoke( data: dict[str, Any] = outcome.data or {} return CallToolResult( content=[TextContent(type="text", text=json.dumps(data, separators=(",", ":")))], - structuredContent=data, + structured_content=data, )