diff --git a/mcp-tools.public.json b/mcp-tools.public.json index dc792ab..0acea66 100644 --- a/mcp-tools.public.json +++ b/mcp-tools.public.json @@ -4,11 +4,11 @@ "environment_variable": "AUTH0_JWT_TOKEN", "type": "bearer" }, - "generated_at": "2026-07-28T16:40:28-04:00", + "generated_at": "2026-08-31T23:21:36-04:00", "manifest_version": 1, "source": { "repository": "Subconscious-ai/ghostshell", - "revision": "c445240ebc06b172244c106f44ff3373d880c9eb" + "revision": "17cb7d9e2ee860d3fabe0f3740a1a6b2d41a925e" }, "tool_count": 15, "tools": [ diff --git a/server/tools/_core/handlers.py b/server/tools/_core/handlers.py index c92bd93..f516d73 100644 --- a/server/tools/_core/handlers.py +++ b/server/tools/_core/handlers.py @@ -1,5 +1,6 @@ """Unified tool handlers shared between local and remote modes.""" +import json import logging from datetime import datetime from typing import Any, Dict, Optional, cast @@ -34,6 +35,34 @@ # ============================================================================= +def _parse_response_payload(response: httpx.Response) -> Dict[str, Any]: + """Parse regular JSON or Rehoboam's progress-plus-result SSE envelope.""" + content_type = response.headers.get("content-type", "").lower() + if "text/event-stream" not in content_type: + try: + return cast(Dict[str, Any], response.json()) + except ValueError as exc: + raise ValidationError("Backend returned a non-JSON response") from exc + + result: Any = None + for line in response.text.splitlines(): + if not line.startswith("data:"): + continue + raw_event = line[len("data:") :].strip() + if not raw_event: + continue + try: + event = json.loads(raw_event) + except json.JSONDecodeError as exc: + raise ValidationError("Backend returned malformed SSE data") from exc + if isinstance(event, dict) and event.get("type") == "result": + result = event.get("data") + + if not isinstance(result, dict): + raise ValidationError("Backend SSE response did not include a final result") + return cast(Dict[str, Any], result) + + @with_retry(max_retries=MAX_RETRIES, base_delay=RETRY_DELAY) async def _api_request( method: str, @@ -106,7 +135,7 @@ async def _api_request( raise ValidationError(f"Request failed: {response.status_code}") response.raise_for_status() - return cast(Dict[str, Any], response.json()) + return _parse_response_payload(response) except httpx.ConnectError as e: logger.error(f"Connection error: {e}") diff --git a/tests/test_response_parsing.py b/tests/test_response_parsing.py new file mode 100644 index 0000000..1834067 --- /dev/null +++ b/tests/test_response_parsing.py @@ -0,0 +1,41 @@ +import httpx +import pytest + +from server.tools._core.exceptions import ValidationError +from server.tools._core.handlers import _parse_response_payload + + +def test_parse_response_payload_preserves_json(): + response = httpx.Response(200, json={"is_causal": True}) + + assert _parse_response_payload(response) == {"is_causal": True} + + +def test_parse_response_payload_returns_final_sse_result_data(): + body = "\n\n".join( + [ + 'data: {"type":"progress","step":"checking"}', + 'data: {"type":"result","data":{"is_causal":true,"suggestions":[]}}', + ] + ) + response = httpx.Response( + 200, + text=body, + headers={"content-type": "text/event-stream; charset=utf-8"}, + ) + + assert _parse_response_payload(response) == { + "is_causal": True, + "suggestions": [], + } + + +def test_parse_response_payload_rejects_sse_without_result(): + response = httpx.Response( + 200, + text='data: {"type":"progress","step":"checking"}\n\n', + headers={"content-type": "text/event-stream"}, + ) + + with pytest.raises(ValidationError, match="final result"): + _parse_response_payload(response)