Skip to content
Closed
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
2 changes: 1 addition & 1 deletion everyrow-mcp/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
},
{
"name": "everyrow_progress",
"description": "Check progress of a running task. Blocks briefly to limit the polling rate."
"description": "Check progress of a running task."
},
{
"name": "everyrow_results",
Expand Down
4 changes: 3 additions & 1 deletion everyrow-mcp/src/everyrow_mcp/http_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,9 @@ def _wrapped():
# Pure-ASGI middlewares — outermost wraps first.
# SecurityHeaders → BodySizeLimit → Starlette app
asgi_app = BodySizeLimitMiddleware(
app, max_bytes=settings.max_upload_size_bytes
app,
max_bytes=settings.max_upload_size_bytes,
path_prefixes=("/api/uploads/", "/mcp"),
)
asgi_app = SecurityHeadersMiddleware(asgi_app)
return asgi_app
Expand Down
10 changes: 6 additions & 4 deletions everyrow-mcp/src/everyrow_mcp/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,22 +98,24 @@ class BodySizeLimitMiddleware:
when the limit is exceeded — even for chunked transfer-encoding requests
that lack a Content-Length header.

Only active on paths matching ``path_prefix``.
Active on paths matching any of the given ``path_prefixes``.
"""

def __init__(
self,
app: ASGIApp,
*,
max_bytes: int,
path_prefix: str = "/api/uploads/",
path_prefixes: tuple[str, ...] = ("/api/uploads/",),
) -> None:
self._app = app
self._max_bytes = max_bytes
self._path_prefix = path_prefix
self._path_prefixes = path_prefixes

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or not scope["path"].startswith(self._path_prefix):
if scope["type"] != "http" or not any(
scope["path"].startswith(p) for p in self._path_prefixes
):
await self._app(scope, receive, send)
return

Expand Down
10 changes: 6 additions & 4 deletions everyrow-mcp/src/everyrow_mcp/tool_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,15 +280,17 @@ def progress_message(self, task_id: str) -> str:
return f"{completed_msg}\n{next_call}"
return f"Task {self.status.value}. Report the error to the user."

retry = redis_store.PROGRESS_POLL_DELAY

if self.is_screen:
return dedent(f"""\
Screen running ({self.elapsed_s}s elapsed).
Immediately call everyrow_progress(task_id='{task_id}').""")
Screen running ({self.elapsed_s}s elapsed). retry_after_seconds={retry}
Wait {retry} seconds, then call everyrow_progress(task_id='{task_id}').""")

fail_part = f", {self.failed} failed" if self.failed else ""
return dedent(f"""\
Running: {self.completed}/{self.total} complete, {self.running} running{fail_part} ({self.elapsed_s}s elapsed)
Immediately call everyrow_progress(task_id='{task_id}').""")
Running: {self.completed}/{self.total} complete, {self.running} running{fail_part} ({self.elapsed_s}s elapsed) retry_after_seconds={retry}
Wait {retry} seconds, then call everyrow_progress(task_id='{task_id}').""")


def write_initial_task_state(
Expand Down
12 changes: 4 additions & 8 deletions everyrow-mcp/src/everyrow_mcp/tools.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""MCP tool functions for the everyrow MCP server."""

import asyncio
import json
import logging
from pathlib import Path
Expand Down Expand Up @@ -732,11 +731,11 @@ async def everyrow_progress(
params: ProgressInput,
ctx: EveryRowContext,
) -> list[TextContent]:
"""Check progress of a running task. Blocks briefly to limit the polling rate.
"""Check progress of a running task.

After receiving a status update, immediately call everyrow_progress again
unless the task is completed or failed. The tool handles pacing internally.
Do not add commentary between progress calls, just call again immediately.
After receiving a status update, wait at least ``retry_after_seconds``
before calling everyrow_progress again (unless the task is completed or
failed). Do not add commentary between progress calls.
"""
client = _get_client(ctx)
task_id = params.task_id
Expand All @@ -754,9 +753,6 @@ async def everyrow_progress(
)
]

# Block server-side before polling — controls the cadence
await asyncio.sleep(redis_store.PROGRESS_POLL_DELAY)

try:
status_response = handle_response(
await get_task_status_tasks_task_id_status_get.asyncio(
Expand Down
2 changes: 0 additions & 2 deletions everyrow-mcp/tests/test_mcp_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,6 @@ async def test_call_progress_tool(self, _http_state):
new_callable=AsyncMock,
return_value=status_resp,
),
patch("everyrow_mcp.redis_store.PROGRESS_POLL_DELAY", 0),
):
result = await session.call_tool(
"everyrow_progress",
Expand Down Expand Up @@ -358,7 +357,6 @@ async def test_completed_progress_suggests_results(self, _http_state):
new_callable=AsyncMock,
return_value=status_resp,
),
patch("everyrow_mcp.redis_store.PROGRESS_POLL_DELAY", 0),
):
result = await session.call_tool(
"everyrow_progress",
Expand Down
37 changes: 37 additions & 0 deletions everyrow-mcp/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,40 @@ def test_exact_limit_passes(self):
client = TestClient(app)
resp = client.put("/api/uploads/abc", content=b"x" * 10)
assert resp.status_code == 200

def test_multiple_path_prefixes(self):
"""BodySizeLimitMiddleware enforces limits on all configured paths."""

async def _handler(request: Request):
body = await request.body()
return PlainTextResponse(f"received {len(body)} bytes")

inner = Starlette(
routes=[
Route("/api/uploads/{upload_id}", _handler, methods=["PUT"]),
Route("/mcp", _handler, methods=["POST"]),
Route("/other", _handler, methods=["POST"]),
],
)
app = BodySizeLimitMiddleware(
inner,
max_bytes=10,
path_prefixes=("/api/uploads/", "/mcp"),
)
client = TestClient(app)

# Upload path — enforced
resp = client.put("/api/uploads/abc", content=b"x" * 50)
assert resp.status_code == 413

# MCP path — enforced
resp = client.post("/mcp", content=b"x" * 50)
assert resp.status_code == 413

# MCP path — under limit passes
resp = client.post("/mcp", content=b"x" * 5)
assert resp.status_code == 200

# Other path — not enforced
resp = client.post("/other", content=b"x" * 50)
assert resp.status_code == 200
5 changes: 0 additions & 5 deletions everyrow-mcp/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,6 @@ async def test_progress_api_error(self):
new_callable=AsyncMock,
side_effect=RuntimeError("API error"),
),
patch("everyrow_mcp.tools.asyncio.sleep", new_callable=AsyncMock),
):
params = ProgressInput(task_id=task_id)
result = await everyrow_progress(params, ctx)
Expand Down Expand Up @@ -532,7 +531,6 @@ async def test_progress_running_task(self):
new_callable=AsyncMock,
return_value=status_response,
),
patch("everyrow_mcp.tools.asyncio.sleep", new_callable=AsyncMock),
patch("everyrow_mcp.tools.write_initial_task_state"),
):
params = ProgressInput(task_id=task_id)
Expand Down Expand Up @@ -567,7 +565,6 @@ async def test_progress_completed_task(self):
new_callable=AsyncMock,
return_value=status_response,
),
patch("everyrow_mcp.tools.asyncio.sleep", new_callable=AsyncMock),
patch("everyrow_mcp.tools.write_initial_task_state"),
):
params = ProgressInput(task_id=task_id)
Expand Down Expand Up @@ -1467,7 +1464,6 @@ async def test_progress_stdio_returns_single_content(self):
new_callable=AsyncMock,
return_value=status_response,
),
patch("everyrow_mcp.tools.asyncio.sleep", new_callable=AsyncMock),
patch("everyrow_mcp.tools.write_initial_task_state"),
):
result = await everyrow_progress(ProgressInput(task_id=task_id), ctx)
Expand All @@ -1492,7 +1488,6 @@ async def test_progress_http_returns_text_only(self):
new_callable=AsyncMock,
return_value=status_response,
),
patch("everyrow_mcp.tools.asyncio.sleep", new_callable=AsyncMock),
patch("everyrow_mcp.tools.write_initial_task_state"),
patch(
"everyrow_mcp.tools._check_task_ownership",
Expand Down
8 changes: 0 additions & 8 deletions everyrow-mcp/tests/test_stdio_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,6 @@ async def test_running_status(self):
new_callable=AsyncMock,
return_value=status_resp,
),
patch("everyrow_mcp.tools.asyncio.sleep", new_callable=AsyncMock),
patch("everyrow_mcp.tools.write_initial_task_state"),
):
result = await everyrow_progress(ProgressInput(task_id=task_id), ctx)
Expand All @@ -368,7 +367,6 @@ async def test_completed_status(self):
new_callable=AsyncMock,
return_value=status_resp,
),
patch("everyrow_mcp.tools.asyncio.sleep", new_callable=AsyncMock),
patch("everyrow_mcp.tools.write_initial_task_state"),
):
result = await everyrow_progress(ProgressInput(task_id=task_id), ctx)
Expand Down Expand Up @@ -399,7 +397,6 @@ async def test_completed_screen_status(self):
new_callable=AsyncMock,
return_value=status_resp,
),
patch("everyrow_mcp.tools.asyncio.sleep", new_callable=AsyncMock),
patch("everyrow_mcp.tools.write_initial_task_state"),
):
result = await everyrow_progress(ProgressInput(task_id=task_id), ctx)
Expand Down Expand Up @@ -427,7 +424,6 @@ async def test_running_screen_status(self):
new_callable=AsyncMock,
return_value=status_resp,
),
patch("everyrow_mcp.tools.asyncio.sleep", new_callable=AsyncMock),
patch("everyrow_mcp.tools.write_initial_task_state"),
):
result = await everyrow_progress(ProgressInput(task_id=task_id), ctx)
Expand All @@ -448,7 +444,6 @@ async def test_error_status(self):
new_callable=AsyncMock,
side_effect=RuntimeError("API timeout"),
),
patch("everyrow_mcp.tools.asyncio.sleep", new_callable=AsyncMock),
):
result = await everyrow_progress(ProgressInput(task_id=task_id), ctx)

Expand All @@ -473,7 +468,6 @@ async def test_failed_task_with_error_message(self):
new_callable=AsyncMock,
return_value=status_resp,
),
patch("everyrow_mcp.tools.asyncio.sleep", new_callable=AsyncMock),
patch("everyrow_mcp.tools.write_initial_task_state"),
):
result = await everyrow_progress(ProgressInput(task_id=task_id), ctx)
Expand Down Expand Up @@ -661,7 +655,6 @@ async def test_progress_http_returns_text_only(self):
new_callable=AsyncMock,
return_value=status_resp,
),
patch("everyrow_mcp.tools.asyncio.sleep", new_callable=AsyncMock),
patch("everyrow_mcp.tools.write_initial_task_state"),
patch(
"everyrow_mcp.tools._check_task_ownership",
Expand Down Expand Up @@ -689,7 +682,6 @@ async def test_progress_http_completed_no_output_path_hint(self):
new_callable=AsyncMock,
return_value=status_resp,
),
patch("everyrow_mcp.tools.asyncio.sleep", new_callable=AsyncMock),
patch("everyrow_mcp.tools.write_initial_task_state"),
patch(
"everyrow_mcp.tools._check_task_ownership",
Expand Down