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..f222f710 100644 --- a/everyrow-mcp/src/everyrow_mcp/tool_helpers.py +++ b/everyrow-mcp/src/everyrow_mcp/tool_helpers.py @@ -133,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, @@ -146,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 1eeb17ca..367354f4 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,51 @@ 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). + + 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() + + 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 + # 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 ──────────────────────── _skip_unless_integration = pytest.mark.skipif(