From 5b9943afce28e9563a4d41c0735b5ceeaa7be776 Mon Sep 17 00:00:00 2001 From: Rafael Poyiadzi Date: Tue, 24 Feb 2026 18:04:19 +0000 Subject: [PATCH 1/6] Add basedpyright check to MCP deploy workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add basedpyright as a CI step in the checks job, matching the convention used in other deploy workflows. Fix all type errors in src/ to pass the check: redis ping awaitable typing, nullable client_id assertions, dict type annotations, frozenset→set for pydantic exclude, and UUID wrapping for get_session_url. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/deploy-mcp.yaml | 6 ++++-- everyrow-mcp/pyproject.toml | 5 +++-- everyrow-mcp/src/everyrow_mcp/app.py | 4 ++-- everyrow-mcp/src/everyrow_mcp/auth.py | 6 +++++- everyrow-mcp/src/everyrow_mcp/http_config.py | 5 +++-- everyrow-mcp/src/everyrow_mcp/result_store.py | 11 ++++++----- everyrow-mcp/src/everyrow_mcp/routes.py | 2 +- everyrow-mcp/src/everyrow_mcp/tools.py | 2 +- 8 files changed, 25 insertions(+), 16 deletions(-) diff --git a/.github/workflows/deploy-mcp.yaml b/.github/workflows/deploy-mcp.yaml index b9570ef2..8ea1255d 100644 --- a/.github/workflows/deploy-mcp.yaml +++ b/.github/workflows/deploy-mcp.yaml @@ -27,8 +27,8 @@ on: - "uv.lock" env: - GKE_ZONE: ${{ vars.BETA_CLUSTER_REGION }} - GKE_NAME: ${{ vars.BETA_CLUSTER_NAME }} + GKE_ZONE: ${{ secrets.BETA_CLUSTER_REGION }} + GKE_NAME: ${{ secrets.BETA_CLUSTER_NAME }} MCP_IMAGE_NAME: ${{ vars.GCP_REGISTRY_NAME }}/${{ vars.GCP_PROJECT_NAME }}/delphos/everyrow-mcp permissions: @@ -75,6 +75,8 @@ jobs: run: uv sync --directory everyrow-mcp - name: Run ruff run: uv run --directory everyrow-mcp ruff check + - name: Run basedpyright + run: uv run --directory everyrow-mcp basedpyright --project . - name: Run tests run: uv run --directory everyrow-mcp pytest tests diff --git a/everyrow-mcp/pyproject.toml b/everyrow-mcp/pyproject.toml index 8d37510c..2a7c5633 100644 --- a/everyrow-mcp/pyproject.toml +++ b/everyrow-mcp/pyproject.toml @@ -36,9 +36,10 @@ dev = [ ] [tool.basedpyright] -venvPath = "." +venvPath = ".." venv = ".venv" -include = ["src", "tests"] +include = ["src"] +extraPaths = ["src", "../src"] typeCheckingMode = "standard" [tool.ruff] diff --git a/everyrow-mcp/src/everyrow_mcp/app.py b/everyrow-mcp/src/everyrow_mcp/app.py index e30e8906..7b83a7f1 100644 --- a/everyrow-mcp/src/everyrow_mcp/app.py +++ b/everyrow-mcp/src/everyrow_mcp/app.py @@ -50,7 +50,7 @@ async def http_lifespan(_server: FastMCP): across sessions. Process exit handles cleanup. """ redis_client = get_redis_client() - await redis_client.ping() + await redis_client.ping() # pyright: ignore[reportGeneralTypeIssues] def _http_client_factory() -> AuthenticatedClient: access_token = get_access_token() @@ -73,7 +73,7 @@ def _http_client_factory() -> AuthenticatedClient: async def no_auth_http_lifespan(_server: FastMCP): """HTTP no-auth mode: singleton client from API key, verify Redis.""" redis_client = get_redis_client() - await redis_client.ping() + await redis_client.ping() # pyright: ignore[reportGeneralTypeIssues] with _create_sdk_client() as client: response = await get_billing(client=client) diff --git a/everyrow-mcp/src/everyrow_mcp/auth.py b/everyrow-mcp/src/everyrow_mcp/auth.py index b94bdf67..6985ea53 100644 --- a/everyrow-mcp/src/everyrow_mcp/auth.py +++ b/everyrow-mcp/src/everyrow_mcp/auth.py @@ -346,6 +346,7 @@ async def authorize( state = secrets.token_urlsafe(32) supabase_verifier = secrets.token_urlsafe(32) + assert client.client_id is not None pending = PendingAuth( client_id=client.client_id, params=params, @@ -364,10 +365,11 @@ async def handle_start(self, request: Request) -> RedirectResponse: request, "start", request.path_params.get("state") ) + state = request.path_params.get("state", "") response = RedirectResponse(url=pending.supabase_redirect_url, status_code=302) response.set_cookie( key="mcp_auth_state", - value=request.path_params.get("state"), + value=state, max_age=settings.pending_auth_ttl, httponly=True, samesite="lax", @@ -472,6 +474,7 @@ async def exchange_authorization_code( client: OAuthClientInformationFull, authorization_code: EveryRowAuthorizationCode, ) -> OAuthToken: + assert client.client_id is not None return await self._issue_token_response( access_token=authorization_code.supabase_access_token, client_id=client.client_id, @@ -519,6 +522,7 @@ async def exchange_refresh_token( value=refresh_token.model_dump_json(), ) raise + assert client.client_id is not None return await self._issue_token_response( access_token=supa_tokens.access_token, client_id=client.client_id, diff --git a/everyrow-mcp/src/everyrow_mcp/http_config.py b/everyrow-mcp/src/everyrow_mcp/http_config.py index 478b8009..a72cf339 100644 --- a/everyrow-mcp/src/everyrow_mcp/http_config.py +++ b/everyrow-mcp/src/everyrow_mcp/http_config.py @@ -49,6 +49,7 @@ def configure_http_mode( ) redis_client = get_redis_client() + auth_provider: EveryRowAuthProvider | None = None if no_auth: lifespan = no_auth_http_lifespan else: @@ -65,7 +66,7 @@ def configure_http_mode( mcp.settings.port = port _register_widgets(mcp, mcp_server_url) - _register_routes(mcp, redis_client, auth_provider if not no_auth else None) + _register_routes(mcp, redis_client, auth_provider) _add_middleware(mcp, redis_client, rate_limit=not no_auth) @@ -103,7 +104,7 @@ def _register_routes( async def _health(_request: Request) -> Response: try: - await redis.ping() + await redis.ping() # pyright: ignore[reportGeneralTypeIssues] except Exception: return JSONResponse( {"status": "unhealthy", "redis": "unreachable"}, status_code=503 diff --git a/everyrow-mcp/src/everyrow_mcp/result_store.py b/everyrow-mcp/src/everyrow_mcp/result_store.py index 4dced821..72bc3a97 100644 --- a/everyrow-mcp/src/everyrow_mcp/result_store.py +++ b/everyrow-mcp/src/everyrow_mcp/result_store.py @@ -16,6 +16,7 @@ import json import logging import math +from typing import Any import pandas as pd from mcp.types import TextContent @@ -26,7 +27,7 @@ logger = logging.getLogger(__name__) -def _sanitize_records(records: list[dict]) -> list[dict]: +def _sanitize_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]: """Replace NaN/Inf float values with None for valid JSON serialization. pandas ``to_dict(orient="records")`` preserves ``float('nan')`` which @@ -54,9 +55,9 @@ def _estimate_tokens(text: str) -> int: def clamp_page_to_budget( - preview_records: list[dict], + preview_records: list[dict[str, Any]], page_size: int, -) -> tuple[list[dict], int]: +) -> tuple[list[dict[str, Any]], int]: estimated = _estimate_tokens(json.dumps(preview_records)) if estimated <= settings.token_budget: return preview_records, page_size @@ -86,7 +87,7 @@ def clamp_page_to_budget( def _build_result_response( task_id: str, csv_url: str, - preview_records: list[dict], + preview_records: list[dict[str, Any]], total: int, columns: list[str], offset: int, @@ -242,7 +243,7 @@ async def try_store_result( columns = list(df.columns) # Store base metadata - meta = {"total": total, "columns": columns} + meta: dict[str, Any] = {"total": total, "columns": columns} if session_url: meta["session_url"] = session_url await redis_store.store_result_meta(task_id, json.dumps(meta)) diff --git a/everyrow-mcp/src/everyrow_mcp/routes.py b/everyrow-mcp/src/everyrow_mcp/routes.py index 16a8d64b..a5f85ec5 100644 --- a/everyrow-mcp/src/everyrow_mcp/routes.py +++ b/everyrow-mcp/src/everyrow_mcp/routes.py @@ -89,7 +89,7 @@ async def api_progress(request: Request) -> Response: await redis_store.pop_task_token(task_id) return JSONResponse( - ts.model_dump(mode="json", exclude=_UI_EXCLUDE), headers=cors + ts.model_dump(mode="json", exclude=set(_UI_EXCLUDE)), headers=cors ) except Exception: logger.exception("Progress poll failed for task %s", task_id) diff --git a/everyrow-mcp/src/everyrow_mcp/tools.py b/everyrow-mcp/src/everyrow_mcp/tools.py index e5b60318..01b19407 100644 --- a/everyrow-mcp/src/everyrow_mcp/tools.py +++ b/everyrow-mcp/src/everyrow_mcp/tools.py @@ -679,7 +679,7 @@ async def everyrow_results_http( # ── Fetch from API ──────────────────────────────────────────── try: df, session_id = await _fetch_task_result(client, task_id) - session_url = get_session_url(session_id) if session_id else "" + session_url = get_session_url(UUID(session_id)) if session_id else "" except TaskNotReady as e: return [ TextContent( From 80b9ddd8b79e49b9851d14ef779d1be417267cb7 Mon Sep 17 00:00:00 2001 From: Rafael Poyiadzi Date: Tue, 24 Feb 2026 18:08:43 +0000 Subject: [PATCH 2/6] Revert GKE env vars back to vars (not secrets) Co-Authored-By: Claude Opus 4.6 --- .github/workflows/deploy-mcp.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-mcp.yaml b/.github/workflows/deploy-mcp.yaml index 8ea1255d..2d2dc00a 100644 --- a/.github/workflows/deploy-mcp.yaml +++ b/.github/workflows/deploy-mcp.yaml @@ -27,8 +27,8 @@ on: - "uv.lock" env: - GKE_ZONE: ${{ secrets.BETA_CLUSTER_REGION }} - GKE_NAME: ${{ secrets.BETA_CLUSTER_NAME }} + GKE_ZONE: ${{ vars.BETA_CLUSTER_REGION }} + GKE_NAME: ${{ vars.BETA_CLUSTER_NAME }} MCP_IMAGE_NAME: ${{ vars.GCP_REGISTRY_NAME }}/${{ vars.GCP_PROJECT_NAME }}/delphos/everyrow-mcp permissions: From b52532cd22c20c2bc834f155f6172e142f2c4b9a Mon Sep 17 00:00:00 2001 From: Rafael Poyiadzi Date: Tue, 24 Feb 2026 18:13:39 +0000 Subject: [PATCH 3/6] Change _UI_EXCLUDE from frozenset to set Avoids per-call set() allocation in routes.py. Co-Authored-By: Claude Opus 4.6 --- everyrow-mcp/src/everyrow_mcp/routes.py | 2 +- everyrow-mcp/src/everyrow_mcp/tool_helpers.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/everyrow-mcp/src/everyrow_mcp/routes.py b/everyrow-mcp/src/everyrow_mcp/routes.py index a5f85ec5..16a8d64b 100644 --- a/everyrow-mcp/src/everyrow_mcp/routes.py +++ b/everyrow-mcp/src/everyrow_mcp/routes.py @@ -89,7 +89,7 @@ async def api_progress(request: Request) -> Response: await redis_store.pop_task_token(task_id) return JSONResponse( - ts.model_dump(mode="json", exclude=set(_UI_EXCLUDE)), headers=cors + ts.model_dump(mode="json", exclude=_UI_EXCLUDE), headers=cors ) except Exception: logger.exception("Progress poll failed for task %s", task_id) diff --git a/everyrow-mcp/src/everyrow_mcp/tool_helpers.py b/everyrow-mcp/src/everyrow_mcp/tool_helpers.py index b0206dfa..e5912d66 100644 --- a/everyrow-mcp/src/everyrow_mcp/tool_helpers.py +++ b/everyrow-mcp/src/everyrow_mcp/tool_helpers.py @@ -132,9 +132,7 @@ async def create_tool_response( return [main_content] -_UI_EXCLUDE = frozenset( - {"is_terminal", "is_screen", "task_type", "error", "started_at"} -) +_UI_EXCLUDE: set[str] = {"is_terminal", "is_screen", "task_type", "error", "started_at"} class TaskState(BaseModel): From e392b84730773d934aa1b51bd8283e5cd0277271 Mon Sep 17 00:00:00 2001 From: Rafael Poyiadzi Date: Tue, 24 Feb 2026 18:28:10 +0000 Subject: [PATCH 4/6] Fix basedpyright type errors in tests and add tests to include Add tests back to basedpyright include and fix all type errors: - Add isinstance(_, TextContent) guards before .text access on MCP content unions - Fix async generator return type annotations in fixtures - Use AnyUrl() for redirect_uri/redirect_uris in test_auth - Add None assertions before json.loads on nullable Redis returns - Cast FakeRequest calls with pyright ignore comments - Fix UUID vs str type in _mock_status_response Co-Authored-By: Claude Opus 4.6 --- everyrow-mcp/pyproject.toml | 2 +- everyrow-mcp/tests/conftest.py | 5 +++-- everyrow-mcp/tests/test_auth.py | 21 +++++++++++--------- everyrow-mcp/tests/test_http_transport.py | 23 +++++++++++++++------- everyrow-mcp/tests/test_mcp_e2e.py | 16 +++++++++++++-- everyrow-mcp/tests/test_result_store.py | 2 ++ everyrow-mcp/tests/test_routes.py | 24 +++++++++++------------ everyrow-mcp/tests/test_server.py | 2 +- everyrow-mcp/tests/test_state_redis.py | 13 ++++++------ everyrow-mcp/tests/test_stdio_content.py | 9 +++++++++ 10 files changed, 77 insertions(+), 40 deletions(-) diff --git a/everyrow-mcp/pyproject.toml b/everyrow-mcp/pyproject.toml index 2a7c5633..09f5d0af 100644 --- a/everyrow-mcp/pyproject.toml +++ b/everyrow-mcp/pyproject.toml @@ -38,7 +38,7 @@ dev = [ [tool.basedpyright] venvPath = ".." venv = ".venv" -include = ["src"] +include = ["src", "tests"] extraPaths = ["src", "../src"] typeCheckingMode = "standard" diff --git a/everyrow-mcp/tests/conftest.py b/everyrow-mcp/tests/conftest.py index 745826e3..57596e8c 100644 --- a/everyrow-mcp/tests/conftest.py +++ b/everyrow-mcp/tests/conftest.py @@ -4,6 +4,7 @@ # Set env vars for HttpSettings before any everyrow imports import os +from collections.abc import AsyncGenerator os.environ.setdefault("EVERYROW_API_KEY", "test-api-key") os.environ.setdefault("SUPABASE_URL", "https://test.supabase.co") @@ -64,7 +65,7 @@ def _redis_server(): @pytest.fixture -async def fake_redis(_redis_server) -> aioredis.Redis: +async def fake_redis(_redis_server) -> AsyncGenerator[aioredis.Redis, None]: """A real Redis client, flushed after each test.""" r = aioredis.Redis(host="localhost", port=_REDIS_PORT, decode_responses=True) await r.flushdb() @@ -102,7 +103,7 @@ def override_settings(**overrides): @pytest.fixture -async def everyrow_client(): +async def everyrow_client() -> AsyncGenerator[object, None]: """Provide a real everyrow SDK client for integration tests.""" with create_client() as client: yield client diff --git a/everyrow-mcp/tests/test_auth.py b/everyrow-mcp/tests/test_auth.py index fd515ea2..8b817c91 100644 --- a/everyrow-mcp/tests/test_auth.py +++ b/everyrow-mcp/tests/test_auth.py @@ -15,6 +15,7 @@ ) from mcp.server.auth.provider import AccessToken, AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull +from pydantic import AnyUrl from everyrow_mcp.auth import ( EveryRowAuthorizationCode, @@ -53,6 +54,7 @@ def mock_redis(): async def _setex(*args, name=None, time=None, value=None): # noqa: ARG001 key = name if name is not None else args[0] val = value if value is not None else args[2] if len(args) > 2 else None + assert val is not None store[key] = val async def _exists(key): @@ -89,7 +91,7 @@ def verifier(rsa_keypair, mock_redis): def _make_jwt( private_key, - claims: dict | None = None, + claims: dict[str, str | int] | None = None, *, remove_claims: list[str] | None = None, ) -> str: @@ -362,6 +364,7 @@ async def _set(key, value): async def _setex(*args, name=None, time=None, value=None): # noqa: ARG001 key = name if name is not None else args[0] val = value if value is not None else args[2] if len(args) > 2 else None + assert val is not None store[key] = val async def _get(key): @@ -414,7 +417,7 @@ def test_client(): """A minimal OAuthClientInformationFull for tests.""" return OAuthClientInformationFull( client_id="test-client-id", - redirect_uris=["https://example.com/callback"], + redirect_uris=[AnyUrl("https://example.com/callback")], ) @@ -427,7 +430,7 @@ async def test_auth_code_consumed_atomically(self, provider, test_client): auth_code_obj = EveryRowAuthorizationCode( code=auth_code_str, client_id="test-client-id", - redirect_uri="https://example.com/callback", + redirect_uri=AnyUrl("https://example.com/callback"), redirect_uri_provided_explicitly=True, code_challenge="test-challenge", scopes=["read"], @@ -563,7 +566,7 @@ async def test_redirect_uri_mismatch_rejected(self, provider, test_client): params = AuthorizationParams( state="s1", scopes=["read"], - redirect_uri="https://evil.example.com/callback", + redirect_uri=AnyUrl("https://evil.example.com/callback"), code_challenge="challenge", redirect_uri_provided_explicitly=True, ) @@ -576,7 +579,7 @@ async def test_matching_redirect_uri_accepted(self, provider, test_client): params = AuthorizationParams( state="s1", scopes=["read"], - redirect_uri="https://example.com/callback", + redirect_uri=AnyUrl("https://example.com/callback"), code_challenge="challenge", redirect_uri_provided_explicitly=True, ) @@ -653,7 +656,7 @@ async def test_auth_code_client_id_mismatch(self, provider): auth_code_obj = EveryRowAuthorizationCode( code=auth_code_str, client_id="other-client-id", - redirect_uri="https://example.com/callback", + redirect_uri=AnyUrl("https://example.com/callback"), redirect_uri_provided_explicitly=True, code_challenge="test-challenge", scopes=["read"], @@ -669,7 +672,7 @@ async def test_auth_code_client_id_mismatch(self, provider): wrong_client = OAuthClientInformationFull( client_id="wrong-client-id", - redirect_uris=["https://example.com/callback"], + redirect_uris=[AnyUrl("https://example.com/callback")], ) result = await provider.load_authorization_code(wrong_client, auth_code_str) assert result is None @@ -692,7 +695,7 @@ async def test_refresh_token_client_id_mismatch(self, provider): wrong_client = OAuthClientInformationFull( client_id="wrong-client-id", - redirect_uris=["https://example.com/callback"], + redirect_uris=[AnyUrl("https://example.com/callback")], ) result = await provider.load_refresh_token(wrong_client, "rt-mismatch") assert result is None @@ -731,7 +734,7 @@ async def test_auth_code_expired_rejected(self, provider, test_client): auth_code_obj = EveryRowAuthorizationCode( code=auth_code_str, client_id="test-client-id", - redirect_uri="https://example.com/callback", + redirect_uri=AnyUrl("https://example.com/callback"), redirect_uri_provided_explicitly=True, code_challenge="test-challenge", scopes=["read"], diff --git a/everyrow-mcp/tests/test_http_transport.py b/everyrow-mcp/tests/test_http_transport.py index 97d61b14..c87516c2 100644 --- a/everyrow-mcp/tests/test_http_transport.py +++ b/everyrow-mcp/tests/test_http_transport.py @@ -28,13 +28,16 @@ import subprocess import sys import time +from collections.abc import Generator from contextlib import asynccontextmanager +from typing import Any import httpx import pytest import redis from mcp.client.session import ClientSession from mcp.client.streamable_http import streamablehttp_client +from mcp.types import TextContent # Skip all tests unless opted in pytestmark = pytest.mark.skipif( @@ -77,7 +80,7 @@ def _flush_redis_db(db: int = REDIS_TEST_DB) -> None: @pytest.fixture(scope="session") -def mcp_server() -> str: +def mcp_server() -> Generator[str, None, None]: """Start the MCP server subprocess on a random port with Redis DB 15. Yields the base URL (e.g. http://127.0.0.1:PORT). @@ -166,7 +169,7 @@ async def poll_via_mcp( result = await session.call_tool( "everyrow_progress", {"params": {"task_id": task_id}} ) - texts = [b.text for b in result.content if hasattr(b, "text")] + texts = [b.text for b in result.content if isinstance(b, TextContent)] human_text = texts[-1] if texts else "" print(f" Poll {i + 1}: {human_text.splitlines()[0]}") @@ -180,10 +183,10 @@ async def poll_via_mcp( raise TimeoutError(f"Task {task_id} did not complete within {max_polls} polls") -def parse_widget_json(content_blocks) -> dict: +def parse_widget_json(content_blocks) -> dict[str, Any]: """Parse the first TextContent block as JSON (the widget data).""" for block in content_blocks: - if hasattr(block, "text"): + if isinstance(block, TextContent): try: return json.loads(block.text) except json.JSONDecodeError: @@ -256,7 +259,10 @@ async def test_agent_submit_poll_results( ) # Fail fast on tool errors - first_text = submit_result.content[0].text if submit_result.content else "" + first_block = submit_result.content[0] if submit_result.content else None + first_text = ( + first_block.text if isinstance(first_block, TextContent) else "" + ) assert not first_text.startswith("Error"), f"Tool error: {first_text}" # Parse widget JSON from the first content block @@ -276,7 +282,9 @@ async def test_agent_submit_poll_results( "everyrow_results", {"params": {"task_id": task_id, "page_size": 1}}, ) - results_texts = [b.text for b in results_resp.content if hasattr(b, "text")] + results_texts = [ + b.text for b in results_resp.content if isinstance(b, TextContent) + ] results_widget = parse_widget_json(results_resp.content) print(f"\nResults page 1 widget: {json.dumps(results_widget, indent=2)}") @@ -306,7 +314,7 @@ async def test_agent_submit_poll_results( ) results_widget2 = parse_widget_json(results_resp2.content) results_texts2 = [ - b.text for b in results_resp2.content if hasattr(b, "text") + b.text for b in results_resp2.content if isinstance(b, TextContent) ] print(f"\nResults page 2 widget: {json.dumps(results_widget2, indent=2)}") @@ -328,6 +336,7 @@ async def test_agent_submit_poll_results( reader = csv.DictReader(io.StringIO(csv_response.text)) rows = list(reader) assert len(rows) == 2, f"Expected 2 CSV rows, got {len(rows)}" + assert reader.fieldnames is not None assert "headquarters" in reader.fieldnames, ( f"Expected 'headquarters' column, got columns: {reader.fieldnames}" ) diff --git a/everyrow-mcp/tests/test_mcp_e2e.py b/everyrow-mcp/tests/test_mcp_e2e.py index 795e0028..7a934ef1 100644 --- a/everyrow-mcp/tests/test_mcp_e2e.py +++ b/everyrow-mcp/tests/test_mcp_e2e.py @@ -16,7 +16,7 @@ from contextlib import asynccontextmanager from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch -from uuid import uuid4 +from uuid import UUID, uuid4 import pandas as pd import pytest @@ -27,6 +27,7 @@ from everyrow.generated.models.task_status_response import TaskStatusResponse from mcp.server.fastmcp.server import lifespan_wrapper from mcp.shared.memory import create_connected_server_and_client_session +from mcp.types import TextContent # Import tools module to trigger @mcp.tool() registration on the FastMCP instance import everyrow_mcp.tools # noqa: F401 @@ -104,7 +105,7 @@ def _mock_status_response( running: int = 2, ) -> TaskStatusResponse: return TaskStatusResponse( - task_id=task_id or uuid4(), + task_id=UUID(task_id) if task_id else uuid4(), session_id=uuid4(), status=TaskStatus(status), task_type=PublicTaskType.AGENT, @@ -189,10 +190,12 @@ async def test_call_screen_tool(self, _http_state, tmp_path): assert not result.isError # HTTP mode returns 2 content items: widget JSON + human text assert len(result.content) == 2 + assert isinstance(result.content[0], TextContent) widget = json.loads(result.content[0].text) assert widget["task_id"] == task_id assert widget["status"] == "submitted" assert "progress_url" in widget + assert isinstance(result.content[1], TextContent) assert task_id in result.content[1].text @pytest.mark.asyncio @@ -226,6 +229,7 @@ async def test_call_progress_tool(self, _http_state): ) assert not result.isError + assert isinstance(result.content[-1], TextContent) human_text = result.content[-1].text assert "5/10" in human_text assert "running" in human_text.lower() or "Running" in human_text @@ -288,6 +292,7 @@ async def test_call_agent_tool(self, _http_state, tmp_path): assert not result.isError assert len(result.content) == 2 + assert isinstance(result.content[0], TextContent) widget = json.loads(result.content[0].text) assert widget["task_id"] == task_id @@ -323,6 +328,7 @@ async def test_completed_progress_suggests_results(self, _http_state): ) assert not result.isError + assert isinstance(result.content[-1], TextContent) human_text = result.content[-1].text assert "everyrow_results" in human_text @@ -369,6 +375,7 @@ async def test_screen_pipeline(self, _real_client, jobs_csv): ) assert not submit_result.isError + assert isinstance(submit_result.content[0], TextContent) task_id = _extract_task_id(submit_result.content[0].text) print(f"\nSubmitted screen task: {task_id}") @@ -384,6 +391,7 @@ async def test_screen_pipeline(self, _real_client, jobs_csv): ) assert not progress_result.isError + assert isinstance(progress_result.content[-1], TextContent) text = progress_result.content[-1].text print(f" Progress: {text.splitlines()[0]}") @@ -417,6 +425,7 @@ async def test_screen_pipeline(self, _real_client, jobs_csv): ) assert not results.isError + assert isinstance(results.content[-1], TextContent) print(f" Results: {results.content[-1].text}") @pytest.mark.asyncio @@ -451,6 +460,7 @@ async def test_agent_pipeline(self, _real_client, tmp_path): ) assert not submit_result.isError + assert isinstance(submit_result.content[0], TextContent) task_id = _extract_task_id(submit_result.content[0].text) print(f"\nSubmitted agent task: {task_id}") @@ -466,6 +476,7 @@ async def test_agent_pipeline(self, _real_client, tmp_path): ) assert not progress_result.isError + assert isinstance(progress_result.content[-1], TextContent) text = progress_result.content[-1].text print(f" Progress: {text.splitlines()[0]}") @@ -499,4 +510,5 @@ async def test_agent_pipeline(self, _real_client, tmp_path): ) assert not results.isError + assert isinstance(results.content[-1], TextContent) print(f" Results: {results.content[-1].text}") diff --git a/everyrow-mcp/tests/test_result_store.py b/everyrow-mcp/tests/test_result_store.py index 1175c97b..3335d7c1 100644 --- a/everyrow-mcp/tests/test_result_store.py +++ b/everyrow-mcp/tests/test_result_store.py @@ -258,6 +258,7 @@ async def test_preserves_session_url_from_meta(self, _http_state): result = await try_cached_result(task_id, 0, 10, mcp_server_url=FAKE_SERVER_URL) + assert result is not None widget = json.loads(result[0].text) assert widget["session_url"] == "https://everyrow.io/sessions/xyz" @@ -334,6 +335,7 @@ async def test_includes_session_url_in_meta(self, sample_df, _http_state): ) meta_raw = await redis_store.get_result_meta(task_id) + assert meta_raw is not None meta = json.loads(meta_raw) assert meta["session_url"] == "https://everyrow.io/sessions/abc" diff --git a/everyrow-mcp/tests/test_routes.py b/everyrow-mcp/tests/test_routes.py index e8416743..4470a255 100644 --- a/everyrow-mcp/tests/test_routes.py +++ b/everyrow-mcp/tests/test_routes.py @@ -82,7 +82,7 @@ class TestApiProgress: @pytest.mark.asyncio async def test_options_returns_204(self): req = FakeRequest(method="OPTIONS", path_params={"task_id": "abc"}) - resp = await api_progress(req) + resp = await api_progress(req) # pyright: ignore[reportArgumentType] assert resp.status_code == 204 assert resp.headers["Access-Control-Allow-Origin"] == settings.mcp_server_url @@ -96,9 +96,9 @@ async def test_invalid_poll_token_returns_403(self): path_params={"task_id": task_id}, query_params={"token": "wrong-token"}, ) - resp = await api_progress(req) + resp = await api_progress(req) # pyright: ignore[reportArgumentType] assert resp.status_code == 403 - body = json.loads(resp.body.decode()) + body = json.loads(bytes(resp.body).decode()) assert body["error"] == "Unauthorized" @pytest.mark.asyncio @@ -109,7 +109,7 @@ async def test_missing_poll_token_returns_403(self): path_params={"task_id": task_id}, query_params={}, ) - resp = await api_progress(req) + resp = await api_progress(req) # pyright: ignore[reportArgumentType] assert resp.status_code == 403 @pytest.mark.asyncio @@ -123,9 +123,9 @@ async def test_missing_task_token_returns_404(self): path_params={"task_id": task_id}, query_params={"token": poll_token}, ) - resp = await api_progress(req) + resp = await api_progress(req) # pyright: ignore[reportArgumentType] assert resp.status_code == 404 - body = json.loads(resp.body.decode()) + body = json.loads(bytes(resp.body).decode()) assert body["error"] == "Unknown task" @pytest.mark.asyncio @@ -149,10 +149,10 @@ async def test_valid_progress_returns_status(self): new_callable=AsyncMock, return_value=status_resp, ): - resp = await api_progress(req) + resp = await api_progress(req) # pyright: ignore[reportArgumentType] assert resp.status_code == 200 - body = json.loads(resp.body.decode()) + body = json.loads(bytes(resp.body).decode()) assert body["status"] == "running" assert body["completed"] == 3 assert body["total"] == 10 @@ -181,10 +181,10 @@ async def test_completed_task_pops_tokens(self): new_callable=AsyncMock, return_value=status_resp, ): - resp = await api_progress(req) + resp = await api_progress(req) # pyright: ignore[reportArgumentType] assert resp.status_code == 200 - body = json.loads(resp.body.decode()) + body = json.loads(bytes(resp.body).decode()) assert body["status"] == "completed" # Task token cleaned up; poll token kept for CSV download @@ -208,8 +208,8 @@ async def test_api_error_returns_500(self): new_callable=AsyncMock, side_effect=RuntimeError("API down"), ): - resp = await api_progress(req) + resp = await api_progress(req) # pyright: ignore[reportArgumentType] assert resp.status_code == 500 - body = json.loads(resp.body.decode()) + body = json.loads(bytes(resp.body).decode()) assert body["error"] == "Internal server error" diff --git a/everyrow-mcp/tests/test_server.py b/everyrow-mcp/tests/test_server.py index 1db29097..d30545bf 100644 --- a/everyrow-mcp/tests/test_server.py +++ b/everyrow-mcp/tests/test_server.py @@ -1124,7 +1124,7 @@ class TestResultsInputValidation: def test_stdio_requires_output_path(self): """Test that StdioResultsInput requires output_path.""" with pytest.raises(ValidationError): - StdioResultsInput(task_id="00000000-0000-0000-0000-000000000000") + StdioResultsInput(task_id="00000000-0000-0000-0000-000000000000") # pyright: ignore[reportCallIssue] def test_stdio_output_path_validated(self, tmp_path: Path): """Test that output_path is validated when provided.""" diff --git a/everyrow-mcp/tests/test_state_redis.py b/everyrow-mcp/tests/test_state_redis.py index cf0b5815..ac1ee471 100644 --- a/everyrow-mcp/tests/test_state_redis.py +++ b/everyrow-mcp/tests/test_state_redis.py @@ -98,12 +98,13 @@ async def test_different_offsets_are_independent(self): await redis_store.store_result_page("task-multi", 0, 10, page0) await redis_store.store_result_page("task-multi", 10, 10, page10) - assert json.loads(await redis_store.get_result_page("task-multi", 0, 10)) == [ - {"row": 0} - ] - assert json.loads(await redis_store.get_result_page("task-multi", 10, 10)) == [ - {"row": 10} - ] + raw_page0 = await redis_store.get_result_page("task-multi", 0, 10) + assert raw_page0 is not None + assert json.loads(raw_page0) == [{"row": 0}] + + raw_page10 = await redis_store.get_result_page("task-multi", 10, 10) + assert raw_page10 is not None + assert json.loads(raw_page10) == [{"row": 10}] @pytest.mark.asyncio async def test_get_missing_returns_none(self): diff --git a/everyrow-mcp/tests/test_stdio_content.py b/everyrow-mcp/tests/test_stdio_content.py index 8bb60a0e..8f092ea2 100644 --- a/everyrow-mcp/tests/test_stdio_content.py +++ b/everyrow-mcp/tests/test_stdio_content.py @@ -756,6 +756,7 @@ async def test_screen_pipeline_stdio_clean( assert len(submit.content) == 1, ( f"Stdio submit should return 1 item, got {len(submit.content)}" ) + assert isinstance(submit.content[0], TextContent) task_id = _extract_task_id(submit.content[0].text) print(f"\n Submitted screen: {task_id}") @@ -772,6 +773,7 @@ async def test_screen_pipeline_stdio_clean( ) assert len(progress.content) == 1 + assert isinstance(progress.content[0], TextContent) text = progress.content[0].text print(f" Progress: {text.splitlines()[0]}") @@ -797,6 +799,7 @@ async def test_screen_pipeline_stdio_clean( assert not results.isError _assert_mcp_result_clean(results, tool_name="screen results") assert len(results.content) == 1 + assert isinstance(results.content[0], TextContent) assert "Saved" in results.content[0].text assert output_file.exists() print(f" Results: {results.content[0].text}") @@ -837,6 +840,7 @@ async def test_agent_pipeline_stdio_clean(self, _real_stdio_client, tmp_path): assert not submit.isError _assert_mcp_result_clean(submit, tool_name="agent submit") assert len(submit.content) == 1 + assert isinstance(submit.content[0], TextContent) task_id = _extract_task_id(submit.content[0].text) print(f"\n Submitted agent: {task_id}") @@ -853,6 +857,7 @@ async def test_agent_pipeline_stdio_clean(self, _real_stdio_client, tmp_path): ) assert len(progress.content) == 1 + assert isinstance(progress.content[0], TextContent) text = progress.content[0].text print(f" Progress: {text.splitlines()[0]}") @@ -878,6 +883,7 @@ async def test_agent_pipeline_stdio_clean(self, _real_stdio_client, tmp_path): assert not results.isError _assert_mcp_result_clean(results, tool_name="agent results") assert len(results.content) == 1 + assert isinstance(results.content[0], TextContent) assert "Saved" in results.content[0].text assert output_file.exists() @@ -905,6 +911,7 @@ async def test_single_agent_pipeline_stdio_clean( assert not submit.isError _assert_mcp_result_clean(submit, tool_name="single_agent submit") assert len(submit.content) == 1 + assert isinstance(submit.content[0], TextContent) task_id = _extract_task_id(submit.content[0].text) print(f"\n Submitted single_agent: {task_id}") @@ -921,6 +928,7 @@ async def test_single_agent_pipeline_stdio_clean( ) assert len(progress.content) == 1 + assert isinstance(progress.content[0], TextContent) text = progress.content[0].text print(f" Progress: {text.splitlines()[0]}") @@ -946,5 +954,6 @@ async def test_single_agent_pipeline_stdio_clean( assert not results.isError _assert_mcp_result_clean(results, tool_name="single_agent results") assert len(results.content) == 1 + assert isinstance(results.content[0], TextContent) assert output_file.exists() print(f" Results: {results.content[0].text}") From 9e9df0f9f99844805db1e3d999165f0d5dd39d18 Mon Sep 17 00:00:00 2001 From: Rafael Poyiadzi Date: Tue, 24 Feb 2026 18:39:00 +0000 Subject: [PATCH 5/6] Remove unnecessary venvPath and extraPaths from basedpyright config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uv run handles the venv, and both packages are installed in site-packages — no extra paths needed. Co-Authored-By: Claude Opus 4.6 --- everyrow-mcp/pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/everyrow-mcp/pyproject.toml b/everyrow-mcp/pyproject.toml index 09f5d0af..04034454 100644 --- a/everyrow-mcp/pyproject.toml +++ b/everyrow-mcp/pyproject.toml @@ -36,10 +36,7 @@ dev = [ ] [tool.basedpyright] -venvPath = ".." -venv = ".venv" include = ["src", "tests"] -extraPaths = ["src", "../src"] typeCheckingMode = "standard" [tool.ruff] From 3965f62158a85dc81298c81acd8c06772dc60dd4 Mon Sep 17 00:00:00 2001 From: Rafael Poyiadzi Date: Tue, 24 Feb 2026 18:39:56 +0000 Subject: [PATCH 6/6] Restore venvPath/venv with corrected path for IDE support venvPath = ".." points to the workspace root where .venv lives. Needed for IDEs running basedpyright outside of uv run. Co-Authored-By: Claude Opus 4.6 --- everyrow-mcp/pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/everyrow-mcp/pyproject.toml b/everyrow-mcp/pyproject.toml index 04034454..cae06735 100644 --- a/everyrow-mcp/pyproject.toml +++ b/everyrow-mcp/pyproject.toml @@ -36,6 +36,8 @@ dev = [ ] [tool.basedpyright] +venvPath = ".." +venv = ".venv" include = ["src", "tests"] typeCheckingMode = "standard"