From c5a77d25c0eb0692e74c0ed1394712987ead1745 Mon Sep 17 00:00:00 2001 From: Rafael Poyiadzi Date: Wed, 25 Feb 2026 13:22:27 +0000 Subject: [PATCH 1/2] Fix all basedpyright type errors in MCP server Resolve 47 basedpyright errors that were blocking CI deploys. Add pyrightconfig.json with explicit include paths to scope checks to src/ and tests/. Update integration tests to use the unified input API (data= instead of removed input_csv=). Co-Authored-By: Claude Opus 4.6 --- everyrow-mcp/pyrightconfig.json | 13 +++++++ everyrow-mcp/src/everyrow_mcp/http_config.py | 2 +- everyrow-mcp/src/everyrow_mcp/result_store.py | 4 +- everyrow-mcp/src/everyrow_mcp/uploads.py | 3 +- everyrow-mcp/src/everyrow_mcp/utils.py | 7 ++-- everyrow-mcp/tests/conftest.py | 38 +++++++++++++++++++ everyrow-mcp/tests/test_auth.py | 2 +- everyrow-mcp/tests/test_http_integration.py | 3 +- everyrow-mcp/tests/test_http_real.py | 19 ++++------ everyrow-mcp/tests/test_middleware.py | 5 ++- everyrow-mcp/tests/test_routes.py | 12 +++--- everyrow-mcp/tests/test_server.py | 5 ++- everyrow-mcp/tests/test_stdio_content.py | 3 +- everyrow-mcp/tests/test_uploads.py | 15 ++++---- 14 files changed, 92 insertions(+), 39 deletions(-) create mode 100644 everyrow-mcp/pyrightconfig.json diff --git a/everyrow-mcp/pyrightconfig.json b/everyrow-mcp/pyrightconfig.json new file mode 100644 index 00000000..1f8b9755 --- /dev/null +++ b/everyrow-mcp/pyrightconfig.json @@ -0,0 +1,13 @@ +{ + "venvPath": "..", + "venv": ".venv", + "pythonVersion": "3.13", + "include": ["src", "tests"], + "exclude": ["scripts"], + "extraPaths": [ + "src", + "../src" + ], + "reportUnusedCallResult": false, + "reportUnknownVariableType": false +} diff --git a/everyrow-mcp/src/everyrow_mcp/http_config.py b/everyrow-mcp/src/everyrow_mcp/http_config.py index 14fefd16..d335411d 100644 --- a/everyrow-mcp/src/everyrow_mcp/http_config.py +++ b/everyrow-mcp/src/everyrow_mcp/http_config.py @@ -221,4 +221,4 @@ def _wrapped(): asgi_app = SecurityHeadersMiddleware(asgi_app) return asgi_app - mcp.streamable_http_app = _wrapped + mcp.streamable_http_app = _wrapped # pyright: ignore[reportAttributeAccessIssue] diff --git a/everyrow-mcp/src/everyrow_mcp/result_store.py b/everyrow-mcp/src/everyrow_mcp/result_store.py index 331b3f75..e2988b22 100644 --- a/everyrow-mcp/src/everyrow_mcp/result_store.py +++ b/everyrow-mcp/src/everyrow_mcp/result_store.py @@ -234,7 +234,7 @@ async def try_cached_result( offset=min(offset, meta["total"]), page_size=effective_page_size, session_url=meta.get("session_url", ""), - poll_token=poll_token, + poll_token=poll_token or "", mcp_server_url=mcp_server_url, requested_page_size=page_size, ) @@ -298,7 +298,7 @@ async def try_store_result( offset=clamped_offset, page_size=effective_page_size, session_url=session_url, - poll_token=poll_token, + poll_token=poll_token or "", mcp_server_url=mcp_server_url, requested_page_size=page_size, ) diff --git a/everyrow-mcp/src/everyrow_mcp/uploads.py b/everyrow-mcp/src/everyrow_mcp/uploads.py index d8de53ba..412a811d 100644 --- a/everyrow-mcp/src/everyrow_mcp/uploads.py +++ b/everyrow-mcp/src/everyrow_mcp/uploads.py @@ -14,6 +14,7 @@ import shlex import time from io import BytesIO +from typing import Any from uuid import uuid4 import pandas as pd @@ -178,7 +179,7 @@ async def request_upload_url( async def _validate_upload( # noqa: PLR0911 request: Request, -) -> tuple[bytes, dict, None] | tuple[None, None, JSONResponse]: +) -> tuple[bytes, dict[str, Any], None] | tuple[None, None, JSONResponse]: """Validate upload signature, metadata, and body. Returns (body, metadata_dict, None) or (None, None, error). diff --git a/everyrow-mcp/src/everyrow_mcp/utils.py b/everyrow-mcp/src/everyrow_mcp/utils.py index 019aefec..7bd26e10 100644 --- a/everyrow-mcp/src/everyrow_mcp/utils.py +++ b/everyrow-mcp/src/everyrow_mcp/utils.py @@ -120,12 +120,13 @@ async def _resolve_and_validate(hostname: str) -> str: raise ValueError(f"Could not resolve hostname: {hostname}") for _, _, _, _, sockaddr in addrinfos: - if _is_blocked_ip(sockaddr[0]): - logger.warning("SSRF blocked: %s resolved to %s", hostname, sockaddr[0]) + addr = str(sockaddr[0]) + if _is_blocked_ip(addr): + logger.warning("SSRF blocked: %s resolved to %s", hostname, addr) raise ValueError(f"URL target is not permitted: {hostname}") # All addresses safe — return the first resolved IP for connection pinning - return addrinfos[0][4][0] + return str(addrinfos[0][4][0]) async def _validate_url_target(url: str) -> None: diff --git a/everyrow-mcp/tests/conftest.py b/everyrow-mcp/tests/conftest.py index 6c06c0f3..48b877b8 100644 --- a/everyrow-mcp/tests/conftest.py +++ b/everyrow-mcp/tests/conftest.py @@ -17,6 +17,7 @@ import time from contextlib import contextmanager from pathlib import Path +from typing import Any from unittest.mock import MagicMock import pandas as pd @@ -154,6 +155,43 @@ def jobs_csv(tmp_path: Path) -> str: return str(path) +@pytest.fixture +def jobs_data() -> list[dict[str, Any]]: + """Return jobs data as inline rows for tools using the unified input API.""" + return [ + { + "company": "Airtable", + "title": "Senior Engineer", + "salary": "$185000", + "location": "Remote", + }, + { + "company": "Vercel", + "title": "Lead Engineer", + "salary": "Competitive", + "location": "NYC", + }, + { + "company": "Notion", + "title": "Staff Engineer", + "salary": "$200000", + "location": "San Francisco", + }, + { + "company": "Linear", + "title": "Junior Developer", + "salary": "$85000", + "location": "Remote", + }, + { + "company": "Descript", + "title": "Principal Architect", + "salary": "$250000", + "location": "Remote", + }, + ] + + @pytest.fixture def companies_csv(tmp_path: Path) -> str: """Create a companies CSV for testing.""" diff --git a/everyrow-mcp/tests/test_auth.py b/everyrow-mcp/tests/test_auth.py index 7b3d799f..991df6db 100644 --- a/everyrow-mcp/tests/test_auth.py +++ b/everyrow-mcp/tests/test_auth.py @@ -862,7 +862,7 @@ async def test_handle_start_sets_host_prefixed_cookie( 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, ), diff --git a/everyrow-mcp/tests/test_http_integration.py b/everyrow-mcp/tests/test_http_integration.py index 88debeb2..6b72c139 100644 --- a/everyrow-mcp/tests/test_http_integration.py +++ b/everyrow-mcp/tests/test_http_integration.py @@ -13,6 +13,7 @@ import json import secrets from datetime import UTC, datetime +from io import StringIO from unittest.mock import AsyncMock, patch from uuid import uuid4 @@ -515,7 +516,7 @@ async def test_store_result_to_download(self, client: httpx.AsyncClient): assert dl_resp.headers["content-type"] == "text/csv; charset=utf-8" # Verify the downloaded CSV matches the original data - downloaded_df = pd.read_csv(pd.io.common.StringIO(dl_resp.text)) + downloaded_df = pd.read_csv(StringIO(dl_resp.text)) assert list(downloaded_df.columns) == ["name", "score"] assert len(downloaded_df) == 2 diff --git a/everyrow-mcp/tests/test_http_real.py b/everyrow-mcp/tests/test_http_real.py index ec49655b..93fe108c 100644 --- a/everyrow-mcp/tests/test_http_real.py +++ b/everyrow-mcp/tests/test_http_real.py @@ -22,6 +22,7 @@ import json import os import re +from typing import Any from unittest.mock import patch import httpx @@ -138,7 +139,7 @@ async def poll_via_rest( task_id: str, poll_token: str, max_polls: int = 30, -) -> dict: +) -> dict[str, Any]: """Poll /api/progress via REST endpoint until complete.""" for _ in range(max_polls): resp = await client.get( @@ -172,7 +173,7 @@ async def test_screen_end_to_end( self, client: httpx.AsyncClient, everyrow_client, - jobs_csv: str, + jobs_data: list[dict[str, Any]], ): """Submit a screen task, poll via REST, fetch results via MCP tool.""" # 1. Submit via MCP tool (in HTTP mode) @@ -180,7 +181,7 @@ async def test_screen_end_to_end( result = await everyrow_screen( ScreenInput( task="Filter for remote positions with salary > $100k", - input_csv=jobs_csv, + data=jobs_data, ), ctx, ) @@ -241,20 +242,14 @@ async def test_agent_end_to_end( self, client: httpx.AsyncClient, everyrow_client, - tmp_path, ): """Submit an agent task, poll via REST, verify results via MCP tool.""" - # Create small input (2 rows to minimize cost) - df = pd.DataFrame([{"name": "Anthropic"}, {"name": "OpenAI"}]) - input_csv = tmp_path / "companies.csv" - df.to_csv(input_csv, index=False) - # 1. Submit via MCP tool ctx = make_test_context(everyrow_client, mcp_server_url="http://testserver") result = await everyrow_agent( AgentInput( task="Find the company's headquarters city.", - input_csv=str(input_csv), + data=[{"name": "Anthropic"}, {"name": "OpenAI"}], response_schema={ "properties": { "headquarters": { @@ -323,14 +318,14 @@ async def test_dual_polling( self, client: httpx.AsyncClient, everyrow_client, - jobs_csv: str, + jobs_data: list[dict[str, Any]], ): """Submit a task and poll using both REST endpoint and MCP tool.""" ctx = make_test_context(everyrow_client, mcp_server_url="http://testserver") result = await everyrow_screen( ScreenInput( task="Filter for engineering roles", - input_csv=jobs_csv, + data=jobs_data, ), ctx, ) diff --git a/everyrow-mcp/tests/test_middleware.py b/everyrow-mcp/tests/test_middleware.py index 7bd80711..e5f77c1b 100644 --- a/everyrow-mcp/tests/test_middleware.py +++ b/everyrow-mcp/tests/test_middleware.py @@ -4,6 +4,7 @@ import time from contextlib import asynccontextmanager +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from starlette.applications import Starlette @@ -61,7 +62,7 @@ async def _ttl(key): # Pipeline: collects commands, executes them in order @asynccontextmanager async def _pipeline(): - commands: list[tuple] = [] + commands: list[tuple[Any, ...]] = [] pipe = MagicMock() @@ -200,7 +201,7 @@ async def _other(_request: Request): Route("/other", _other), ], ) - return BodySizeLimitMiddleware(inner, max_bytes=max_bytes) + return BodySizeLimitMiddleware(inner, max_bytes=max_bytes) # pyright: ignore[reportReturnType] class TestBodySizeLimitMiddleware: diff --git a/everyrow-mcp/tests/test_routes.py b/everyrow-mcp/tests/test_routes.py index 90b760a9..c6df7abc 100644 --- a/everyrow-mcp/tests/test_routes.py +++ b/everyrow-mcp/tests/test_routes.py @@ -32,9 +32,9 @@ def __init__( self, *, method: str = "GET", - path_params: dict | None = None, - query_params: dict | None = None, - headers: dict | None = None, + path_params: dict[str, str] | None = None, + query_params: dict[str, str] | None = None, + headers: dict[str, str] | None = None, ): self.method = method self.path_params = path_params or {} @@ -206,10 +206,10 @@ async def test_valid_progress_via_auth_header(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(resp.body.decode()) # pyright: ignore[reportAttributeAccessIssue] assert body["status"] == "running" assert body["completed"] == 3 assert body["total"] == 10 @@ -423,7 +423,7 @@ async def test_minted_token_works_for_download(self): ) dl_resp = await api_download(dl_req) # pyright: ignore[reportArgumentType] assert dl_resp.status_code == 200 - assert dl_resp.body.decode() == csv_text + assert dl_resp.body.decode() == csv_text # pyright: ignore[reportAttributeAccessIssue] class TestCorsHeaders: diff --git a/everyrow-mcp/tests/test_server.py b/everyrow-mcp/tests/test_server.py index ad7e4971..81d544af 100644 --- a/everyrow-mcp/tests/test_server.py +++ b/everyrow-mcp/tests/test_server.py @@ -8,6 +8,7 @@ from contextlib import asynccontextmanager from datetime import UTC, datetime from pathlib import Path +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from uuid import UUID, uuid4 @@ -308,7 +309,7 @@ def _make_task_status_response( def _make_task_result_response( - data: list[dict], + data: list[dict[str, Any]], *, task_id: UUID | None = None, ) -> TaskResultResponse: @@ -1033,7 +1034,7 @@ def test_cancel_input_validation(self): # Extra fields forbidden with pytest.raises(ValidationError): - CancelInput(task_id=valid_uuid, extra_field="x") # type: ignore[call-arg] + CancelInput(task_id=valid_uuid, extra_field="x") # type: ignore[call-arg] # pyright: ignore[reportCallIssue] class TestAgentInlineInput: diff --git a/everyrow-mcp/tests/test_stdio_content.py b/everyrow-mcp/tests/test_stdio_content.py index f9cada23..81c2de48 100644 --- a/everyrow-mcp/tests/test_stdio_content.py +++ b/everyrow-mcp/tests/test_stdio_content.py @@ -18,6 +18,7 @@ from contextlib import asynccontextmanager from datetime import UTC, datetime from pathlib import Path +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 @@ -185,7 +186,7 @@ def _make_status_response( ) -def _make_result_response(data: list[dict]) -> TaskResultResponse: +def _make_result_response(data: list[dict[str, Any]]) -> TaskResultResponse: items = [TaskResultResponseDataType0Item.from_dict(d) for d in data] return TaskResultResponse( task_id=uuid4(), diff --git a/everyrow-mcp/tests/test_uploads.py b/everyrow-mcp/tests/test_uploads.py index 8fa338e4..377849eb 100644 --- a/everyrow-mcp/tests/test_uploads.py +++ b/everyrow-mcp/tests/test_uploads.py @@ -4,6 +4,7 @@ import json import time +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -76,12 +77,12 @@ def test_empty_filename_rejected(self): def test_extra_fields_rejected(self): with pytest.raises(ValidationError): - RequestUploadUrlInput(filename="data.csv", extra="x") # type: ignore[call-arg] + RequestUploadUrlInput(filename="data.csv", extra="x") # type: ignore[call-arg] # pyright: ignore[reportCallIssue] def _capture_tool_fn(mock_mcp: MagicMock): """Register upload tool on a mock FastMCP and return the captured function.""" - captured: list = [] + captured: list[Any] = [] def capture_tool(**_kwargs): def decorator(fn): @@ -243,7 +244,7 @@ async def test_missing_token_returns_403(self, fake_redis): # noqa: ARG002 resp = await handle_upload(request) assert resp.status_code == 403 - body = json.loads(resp.body.decode()) + body = json.loads(resp.body.decode()) # pyright: ignore[reportAttributeAccessIssue] assert body["error"] == "Upload authorization missing" @pytest.mark.asyncio @@ -311,7 +312,7 @@ async def test_error_messages_are_generic(self, fake_redis): # noqa: ARG002 resp = await handle_upload(request) assert resp.status_code == 400 - body = json.loads(resp.body.decode()) + body = json.loads(resp.body.decode()) # pyright: ignore[reportAttributeAccessIssue] # Error message should be generic, not contain internal exception details assert "Could not parse CSV file" in body["error"] assert "Ensure it is valid UTF-8 CSV" in body["error"] @@ -352,7 +353,7 @@ async def test_artifact_creation_error_is_generic(self, fake_redis): # noqa: AR resp = await handle_upload(request) assert resp.status_code == 500 - body = json.loads(resp.body.decode()) + body = json.loads(resp.body.decode()) # pyright: ignore[reportAttributeAccessIssue] assert body["error"] == "Failed to create artifact. Please try again." assert "DB connection" not in body["error"] @@ -410,7 +411,7 @@ async def test_wrong_content_type_returns_415(self): resp = await handle_upload(request) assert resp.status_code == 415 - body = json.loads(resp.body.decode()) + body = json.loads(resp.body.decode()) # pyright: ignore[reportAttributeAccessIssue] assert "Unsupported Content-Type" in body["error"] @pytest.mark.asyncio @@ -481,7 +482,7 @@ async def test_rate_limited_upload_does_not_consume_url(self): resp = await handle_upload(request) assert resp.status_code == 429 - body = json.loads(resp.body.decode()) + body = json.loads(resp.body.decode()) # pyright: ignore[reportAttributeAccessIssue] assert "rate limit" in body["error"].lower() # pop_upload_meta must NOT have been called pop_mock.assert_not_called() From 5b7114200eb87ab04318d17de573cd6b38c1f776 Mon Sep 17 00:00:00 2001 From: Rafael Poyiadzi Date: Wed, 25 Feb 2026 14:00:36 +0000 Subject: [PATCH 2/2] Fix integration tests: ownership mocks, poll token extraction, sleep - Patch get_access_token in both tools.py and tool_helpers.py so ownership checks pass in HTTP-mode integration tests - Update extract_poll_token to read top-level poll_token field instead of parsing it from progress_url query string - Add missing asyncio.sleep(2) to poll_via_tool (was hammering API) - Increase default max_polls from 30 to 60 for real API latency Co-Authored-By: Claude Opus 4.6 --- everyrow-mcp/tests/test_http_real.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/everyrow-mcp/tests/test_http_real.py b/everyrow-mcp/tests/test_http_real.py index 93fe108c..97f52109 100644 --- a/everyrow-mcp/tests/test_http_real.py +++ b/everyrow-mcp/tests/test_http_real.py @@ -23,7 +23,7 @@ import os import re from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch import httpx import pandas as pd @@ -52,12 +52,21 @@ # ── Fixtures ─────────────────────────────────────────────────── +def _fake_access_token(): + """Return a mock access token for ownership checks.""" + tok = MagicMock() + tok.client_id = "integration-test-user" + return tok + + @pytest.fixture def _http_mode(fake_redis): """Configure settings for HTTP mode with the shared test Redis.""" with ( override_settings(transport="streamable-http", upload_secret="test-secret"), patch.object(redis_store, "get_redis_client", return_value=fake_redis), + patch("everyrow_mcp.tools.get_access_token", _fake_access_token), + patch("everyrow_mcp.tool_helpers.get_access_token", _fake_access_token), ): yield @@ -110,16 +119,15 @@ def extract_task_id(submit_text: str) -> str: def extract_poll_token(widget_json: str) -> str: - """Extract poll token from the progress_url in widget JSON.""" + """Extract poll token from widget JSON.""" data = json.loads(widget_json) - progress_url = data.get("progress_url", "") - match = re.search(r"token=([A-Za-z0-9_-]+)", progress_url) - if not match: - raise ValueError(f"No poll token in widget JSON: {widget_json}") - return match.group(1) + token = data.get("poll_token", "") + if not token: + raise ValueError(f"No poll_token in widget JSON: {widget_json}") + return token -async def poll_via_tool(task_id: str, ctx, max_polls: int = 30) -> str: +async def poll_via_tool(task_id: str, ctx, max_polls: int = 60) -> str: """Poll everyrow_progress via MCP tool until complete.""" for _ in range(max_polls): result = await everyrow_progress(ProgressInput(task_id=task_id), ctx) @@ -131,6 +139,8 @@ async def poll_via_tool(task_id: str, ctx, max_polls: int = 30) -> str: if "failed" in text.lower() or "revoked" in text.lower(): raise RuntimeError(f"Task failed: {text}") + await asyncio.sleep(2) + raise TimeoutError(f"Task {task_id} did not complete within {max_polls} polls") @@ -138,7 +148,7 @@ async def poll_via_rest( client: httpx.AsyncClient, task_id: str, poll_token: str, - max_polls: int = 30, + max_polls: int = 60, ) -> dict[str, Any]: """Poll /api/progress via REST endpoint until complete.""" for _ in range(max_polls):