From 049dd063192c26b5043e5f5af2f176fb243769a9 Mon Sep 17 00:00:00 2001 From: Rafael Poyiadzi Date: Wed, 25 Feb 2026 11:46:22 +0000 Subject: [PATCH 1/2] Fix _submission_ui_json RuntimeError in no-auth HTTP mode Skip ownership recording (store_task_owner) when require_auth is False, since there is no authenticated user in --no-auth mode. Co-Authored-By: Claude Opus 4.6 --- everyrow-mcp/src/everyrow_mcp/config.py | 6 ++ everyrow-mcp/src/everyrow_mcp/server.py | 2 + everyrow-mcp/src/everyrow_mcp/tool_helpers.py | 7 +- everyrow-mcp/tests/test_mcp_e2e.py | 77 +++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/everyrow-mcp/src/everyrow_mcp/config.py b/everyrow-mcp/src/everyrow_mcp/config.py index 339412c0..68de7cce 100644 --- a/everyrow-mcp/src/everyrow_mcp/config.py +++ b/everyrow-mcp/src/everyrow_mcp/config.py @@ -123,6 +123,12 @@ class Settings(BaseSettings): everyrow_api_key: str | None = Field(default=None, repr=False) + require_auth: bool = Field( + default=True, + description="Require OAuth authentication in HTTP mode. " + "Set to False via --no-auth for local development.", + ) + @property def is_http(self) -> bool: return self.transport == "streamable-http" diff --git a/everyrow-mcp/src/everyrow_mcp/server.py b/everyrow-mcp/src/everyrow_mcp/server.py index 2a659583..8463e418 100644 --- a/everyrow-mcp/src/everyrow_mcp/server.py +++ b/everyrow-mcp/src/everyrow_mcp/server.py @@ -78,6 +78,8 @@ def main(): os.environ["EVERYROW_MCP_SERVER"] = "1" transport = Transport.HTTP if input_args.http else Transport.STDIO settings.transport = transport.value + if input_args.no_auth: + settings.require_auth = False mcp._mcp_server.instructions = get_instructions(is_http=input_args.http) # tools.py registers everyrow_results_stdio by default. diff --git a/everyrow-mcp/src/everyrow_mcp/tool_helpers.py b/everyrow-mcp/src/everyrow_mcp/tool_helpers.py index 1f18515c..73961650 100644 --- a/everyrow-mcp/src/everyrow_mcp/tool_helpers.py +++ b/everyrow-mcp/src/everyrow_mcp/tool_helpers.py @@ -92,12 +92,13 @@ async def _submission_ui_json( poll_token = secrets.token_urlsafe(32) await redis_store.store_task_token(task_id, token) - # Record task owner for cross-user access checks (HTTP mode only). + # Record task owner for cross-user access checks (HTTP auth mode only). # This MUST succeed — downstream ownership checks deny access when no # owner is recorded, so a silent failure here would lock the user out - # of their own task. + # of their own task. Skipped in no-auth mode where there is no + # authenticated user. user_id = "" - if settings.is_http: + if settings.is_http and settings.require_auth: access_token = get_access_token() if not access_token or not access_token.client_id: raise RuntimeError( diff --git a/everyrow-mcp/tests/test_mcp_e2e.py b/everyrow-mcp/tests/test_mcp_e2e.py index 1eeb17ca..330373b7 100644 --- a/everyrow-mcp/tests/test_mcp_e2e.py +++ b/everyrow-mcp/tests/test_mcp_e2e.py @@ -86,6 +86,41 @@ def _http_state(fake_redis): )(everyrow_results_stdio) +@pytest.fixture +def _noauth_http_state(fake_redis): + """Configure settings for HTTP no-auth mode and patch Redis. + + Like _http_state but with require_auth=False and no access-token patching, + matching the --no-auth server mode. + """ + mcp_app._tool_manager.remove_tool("everyrow_results") + mcp_app.tool( + name="everyrow_results", + structured_output=False, + annotations=_RESULTS_ANNOTATIONS, + meta=_RESULTS_META, + )(everyrow_results_http) + + with ( + override_settings( + transport="streamable-http", + upload_secret="test-secret", + require_auth=False, + ), + patch.object(redis_store, "get_redis_client", return_value=fake_redis), + ): + yield + + # Restore stdio variant + mcp_app._tool_manager.remove_tool("everyrow_results") + mcp_app.tool( + name="everyrow_results", + structured_output=False, + annotations=_RESULTS_ANNOTATIONS, + meta=_RESULTS_META, + )(everyrow_results_stdio) + + @asynccontextmanager async def mcp_client(): """MCP ClientSession connected to the server via in-memory transport. @@ -370,6 +405,48 @@ async def test_completed_progress_suggests_results(self, _http_state): human_text = result.content[-1].text assert "everyrow_results" in human_text + @pytest.mark.asyncio + async def test_submit_task_noauth_http(self, _noauth_http_state): + """Task submission works in no-auth HTTP mode (no access token).""" + task_id = str(uuid4()) + mock_task = _mock_task(task_id) + _, fake_create_session = _mock_session() + + async with mcp_client() as session: + with ( + patch( + "everyrow_mcp.tools._get_client", + return_value=MagicMock(token="fake-token"), + ), + patch( + "everyrow_mcp.tools.screen_async", + new_callable=AsyncMock, + return_value=mock_task, + ), + patch( + "everyrow_mcp.tools.create_session", + side_effect=fake_create_session, + ), + ): + result = await session.call_tool( + "everyrow_screen", + { + "params": { + "task": "Filter for engineering roles", + "data": [ + {"company": "Acme", "role": "Engineer"}, + ], + } + }, + ) + + assert not result.isError + # HTTP mode returns 2 content items: widget JSON + human text + assert len(result.content) == 2 + widget = json.loads(result.content[0].text) + assert widget["task_id"] == task_id + assert widget["status"] == "submitted" + # ── TestMcpE2ERealApi — real API tests ──────────────────────── _skip_unless_integration = pytest.mark.skipif( From c562a5ee4890efbba90d5b54868191d7a64c26ce Mon Sep 17 00:00:00 2001 From: Rafael Poyiadzi Date: Wed, 25 Feb 2026 11:49:48 +0000 Subject: [PATCH 2/2] Skip widget JSON in no-auth HTTP mode, not just the ownership check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget is never rendered in no-auth mode (no Claude Desktop), so skip it entirely in create_tool_response. _submission_ui_json keeps its strict auth requirement — it is only called in auth HTTP mode now. The task token is still stored for progress polling. Co-Authored-By: Claude Opus 4.6 --- everyrow-mcp/src/everyrow_mcp/tool_helpers.py | 16 ++++++++++------ everyrow-mcp/tests/test_mcp_e2e.py | 15 +++++++++------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/everyrow-mcp/src/everyrow_mcp/tool_helpers.py b/everyrow-mcp/src/everyrow_mcp/tool_helpers.py index 73961650..f222f710 100644 --- a/everyrow-mcp/src/everyrow_mcp/tool_helpers.py +++ b/everyrow-mcp/src/everyrow_mcp/tool_helpers.py @@ -92,13 +92,12 @@ async def _submission_ui_json( poll_token = secrets.token_urlsafe(32) await redis_store.store_task_token(task_id, token) - # Record task owner for cross-user access checks (HTTP auth mode only). + # Record task owner for cross-user access checks (HTTP mode only). # This MUST succeed — downstream ownership checks deny access when no # owner is recorded, so a silent failure here would lock the user out - # of their own task. Skipped in no-auth mode where there is no - # authenticated user. + # of their own task. user_id = "" - if settings.is_http and settings.require_auth: + if settings.is_http: access_token = get_access_token() if not access_token or not access_token.client_id: raise RuntimeError( @@ -134,11 +133,13 @@ async def create_tool_response( """Build the standard submission response for a tool. Returns human-readable text in all modes, plus a widget JSON - prepended in HTTP mode. + prepended in authenticated HTTP mode. In no-auth HTTP mode the + widget is skipped (no Claude Desktop to render it) but the task + token is still stored so the progress tool can poll the API. """ text = _submission_text(label, session_url, task_id) main_content = TextContent(type="text", text=text) - if settings.is_http: + if settings.is_http and settings.require_auth: ui_json = await _submission_ui_json( session_url=session_url, task_id=task_id, @@ -147,6 +148,9 @@ async def create_tool_response( mcp_server_url=mcp_server_url, ) return [TextContent(type="text", text=ui_json), main_content] + if settings.is_http: + # No-auth HTTP: store the API token for progress polling, skip widget. + await redis_store.store_task_token(task_id, token) return [main_content] diff --git a/everyrow-mcp/tests/test_mcp_e2e.py b/everyrow-mcp/tests/test_mcp_e2e.py index 330373b7..367354f4 100644 --- a/everyrow-mcp/tests/test_mcp_e2e.py +++ b/everyrow-mcp/tests/test_mcp_e2e.py @@ -407,7 +407,11 @@ async def test_completed_progress_suggests_results(self, _http_state): @pytest.mark.asyncio async def test_submit_task_noauth_http(self, _noauth_http_state): - """Task submission works in no-auth HTTP mode (no access token).""" + """Task submission works in no-auth HTTP mode (no access token). + + No widget JSON is returned (no Claude Desktop to render it), + only the human-readable text. + """ task_id = str(uuid4()) mock_task = _mock_task(task_id) _, fake_create_session = _mock_session() @@ -441,11 +445,10 @@ async def test_submit_task_noauth_http(self, _noauth_http_state): ) assert not result.isError - # HTTP mode returns 2 content items: widget JSON + human text - assert len(result.content) == 2 - widget = json.loads(result.content[0].text) - assert widget["task_id"] == task_id - assert widget["status"] == "submitted" + # No-auth mode: text only, no widget JSON + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert task_id in result.content[0].text # ── TestMcpE2ERealApi — real API tests ────────────────────────