Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions everyrow-mcp/pyrightconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"venvPath": "..",
"venv": ".venv",
"pythonVersion": "3.13",
"include": ["src", "tests"],
"exclude": ["scripts"],
"extraPaths": [
"src",
"../src"
],
"reportUnusedCallResult": false,
"reportUnknownVariableType": false
}
2 changes: 1 addition & 1 deletion everyrow-mcp/src/everyrow_mcp/http_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
4 changes: 2 additions & 2 deletions everyrow-mcp/src/everyrow_mcp/result_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down
3 changes: 2 additions & 1 deletion everyrow-mcp/src/everyrow_mcp/uploads.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import shlex
import time
from io import BytesIO
from typing import Any
from uuid import uuid4

import pandas as pd
Expand Down Expand Up @@ -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).
Expand Down
7 changes: 4 additions & 3 deletions everyrow-mcp/src/everyrow_mcp/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
38 changes: 38 additions & 0 deletions everyrow-mcp/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion everyrow-mcp/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
3 changes: 2 additions & 1 deletion everyrow-mcp/tests/test_http_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
47 changes: 26 additions & 21 deletions everyrow-mcp/tests/test_http_real.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
import json
import os
import re
from unittest.mock import patch
from typing import Any
from unittest.mock import MagicMock, patch

import httpx
import pandas as pd
Expand Down Expand Up @@ -51,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

Expand Down Expand Up @@ -109,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)
Expand All @@ -130,15 +139,17 @@ 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")


async def poll_via_rest(
client: httpx.AsyncClient,
task_id: str,
poll_token: str,
max_polls: int = 30,
) -> dict:
max_polls: int = 60,
) -> dict[str, Any]:
"""Poll /api/progress via REST endpoint until complete."""
for _ in range(max_polls):
resp = await client.get(
Expand Down Expand Up @@ -172,15 +183,15 @@ 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)
ctx = make_test_context(everyrow_client, mcp_server_url="http://testserver")
result = await everyrow_screen(
ScreenInput(
task="Filter for remote positions with salary > $100k",
input_csv=jobs_csv,
data=jobs_data,
),
ctx,
)
Expand Down Expand Up @@ -241,20 +252,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": {
Expand Down Expand Up @@ -323,14 +328,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,
)
Expand Down
5 changes: 3 additions & 2 deletions everyrow-mcp/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down
12 changes: 6 additions & 6 deletions everyrow-mcp/tests/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions everyrow-mcp/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion everyrow-mcp/tests/test_stdio_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(),
Expand Down
Loading