From 57376247e6da928f3e7bdb94306aa680f9a4cfb7 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sat, 18 Jul 2026 01:39:37 -0500 Subject: [PATCH] feat: complete SDK and web API surface parity - SDK download_export, get_news_subscription, search max_results, health/ready - Web getNewsSubscription and document search max_results - Streamable MCP workspace isolation regression test --- apps/web/src/api.ts | 6 ++- docs/feature-parity.md | 7 +++- packages/sdk/src/synthora/sdk/client.py | 30 ++++++++++++-- tests/test_isolation.py | 51 +++++++++++++++++++++++ tests/test_sdk.py | 54 +++++++++++++++++++++++-- 5 files changed, 138 insertions(+), 10 deletions(-) diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index ab1a2e5..540a2f6 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -378,6 +378,8 @@ export const api = { method: "POST", body: JSON.stringify({ query, cadence }), }), + getNewsSubscription: (id: string) => + request(`/api/v1/news/subscriptions/${id}`), deleteNewsSubscription: (id: string) => request<{ deleted: boolean; id: string }>( `/api/v1/news/subscriptions/${id}`, @@ -446,12 +448,12 @@ export const api = { request<{ deleted: boolean; id: string }>(`/api/v1/documents/${id}`, { method: "DELETE", }), - searchDocuments: (query: string) => + searchDocuments: (query: string, maxResults = 5) => request<{ results: Array> }>( "/api/v1/documents/search", { method: "POST", - body: JSON.stringify({ query }), + body: JSON.stringify({ query, max_results: maxResults }), }, ), }; diff --git a/docs/feature-parity.md b/docs/feature-parity.md index 3c1daba..df825f2 100644 --- a/docs/feature-parity.md +++ b/docs/feature-parity.md @@ -61,7 +61,7 @@ See also [parity-audit.md](parity-audit.md). | LLM provider abstraction + think-tag handling | ✅ | 11 providers | | Research history + export md/html/pdf | ✅ | API + web buttons | | Docker Compose self-host | ✅ | `docker-compose.yml` | -| Python SDK | ✅ | `packages/sdk` | +| Python SDK | ✅ | `packages/sdk` — full REST mirror incl. upload, export download, news GET | | Document library + RAG (`collection` engine) | ✅ | documents API + `document_index` | | Provider settings persistence | ✅ | `/api/v1/settings` + Settings UI; resolvers prefer workspace overlay then env; GET responses redact secrets | | MCP server exposing Synthora tools | ✅ | `/api/v1/mcp/tools/*` REST + `/mcp` streamable HTTP; optional ``config`` on ``start_research`` | @@ -120,6 +120,11 @@ tools; env-driven MCP DNS rebinding protection ``max_concurrent_research_units``, ``max_researcher_iterations``, and ``max_react_tool_calls``; SDK/MCP/isolation regression tests. +Closed on ``feat/sdk-api-completeness``: SDK ``download_export`` (authenticated +bytes), ``get_news_subscription``, ``search_documents(max_results=...)``, +``health``/``ready``; web client ``getNewsSubscription`` and search +``max_results``; streamable MCP workspace isolation test. + No known functional gaps remain beyond explicit non-goals below. Chat remains session-scoped ``fast_research`` with prior-report memory — diff --git a/packages/sdk/src/synthora/sdk/client.py b/packages/sdk/src/synthora/sdk/client.py index 1135a7e..98403e5 100644 --- a/packages/sdk/src/synthora/sdk/client.py +++ b/packages/sdk/src/synthora/sdk/client.py @@ -113,6 +113,16 @@ def get_discourse(self, run_id: str) -> list[dict]: def export_url(self, run_id: str, fmt: str = "markdown") -> str: return f"{self.base_url}/api/v1/research/{run_id}/export?format={fmt}" + def download_export(self, run_id: str, fmt: str = "markdown") -> bytes: + """Download export bytes with auth (session mode safe).""" + resp = self._client.get( + f"/api/v1/research/{run_id}/export", + params={"format": fmt}, + headers=self._headers(), + ) + resp.raise_for_status() + return resp.content + def list_pipelines(self) -> list[dict]: return self._get("/api/v1/pipelines")["pipelines"] @@ -199,16 +209,20 @@ def upload_document( def delete_document(self, document_id: str) -> dict: return self._delete(f"/api/v1/documents/{document_id}") - def search_documents(self, query: str) -> list[dict]: - return self._post("/api/v1/documents/search", {"query": query}).get( - "results", [] - ) + def search_documents(self, query: str, *, max_results: int = 5) -> list[dict]: + return self._post( + "/api/v1/documents/search", + {"query": query, "max_results": max_results}, + ).get("results", []) # -- news ---------------------------------------------------------------- def list_news_subscriptions(self) -> list[dict]: return self._get("/api/v1/news/subscriptions")["subscriptions"] + def get_news_subscription(self, subscription_id: str) -> dict: + return self._get(f"/api/v1/news/subscriptions/{subscription_id}") + def create_news_subscription(self, query: str, *, cadence: str = "daily") -> dict: return self._post( "/api/v1/news/subscriptions", {"query": query, "cadence": cadence} @@ -262,6 +276,14 @@ def mcp_tools_call(self, name: str, arguments: Optional[dict] = None) -> dict: {"name": name, "arguments": arguments or {}}, ) + # -- ops ----------------------------------------------------------------- + + def health(self) -> dict: + return self._get("/health") + + def ready(self) -> dict: + return self._get("/ready") + # -- plumbing ---------------------------------------------------------- def _headers(self) -> dict: diff --git a/tests/test_isolation.py b/tests/test_isolation.py index 74fda19..cf4e5fc 100644 --- a/tests/test_isolation.py +++ b/tests/test_isolation.py @@ -176,6 +176,57 @@ def test_session_auth_workspace_and_ws_isolation(platform): ) assert alice_mcp.status_code == 200 assert json.loads(alice_mcp.json()["content"])["run_id"] == run_id + + # Streamable MCP must also reject cross-workspace status reads. + stream_headers = { + **alice_h, + "Accept": "application/json, text/event-stream", + } + init = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "iso-test", "version": "0.1"}, + }, + }, + headers=stream_headers, + ) + assert init.status_code == 200 + session_id = init.headers.get("mcp-session-id") + bob_stream_h = { + **bob_h, + "Accept": "application/json, text/event-stream", + } + if session_id: + bob_stream_h["Mcp-Session-Id"] = session_id + bob_stream = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "get_run_status", + "arguments": {"run_id": run_id}, + }, + }, + headers=bob_stream_h, + ) + assert bob_stream.status_code == 200 + bob_text = next( + ( + b["text"] + for b in bob_stream.json()["result"]["content"] + if b.get("type") == "text" + ), + "", + ) + assert "run not found" in json.loads(bob_text)["error"] finally: settings.auth_mode = "none" settings.secret_key = "change-me" diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 4bf5b27..534b431 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -8,7 +8,7 @@ import pytest from synthora.sdk.client import SynthoraClient -from tests.test_platform import fake_run_config +from tests.test_platform import fake_run_config, make_executor pytest_plugins = ("tests.test_platform",) @@ -18,6 +18,10 @@ def __init__(self, response) -> None: self._response = response self.status_code = response.status_code + @property + def content(self) -> bytes: + return self._response.content + def raise_for_status(self) -> None: if self.status_code >= 400: self._response.raise_for_status() @@ -32,8 +36,16 @@ class _TestHttpClient: def __init__(self, test_client) -> None: self._client = test_client - def get(self, path: str, *, headers: Optional[dict] = None) -> _TestResponse: - return _TestResponse(self._client.get(path, headers=headers or {})) + def get( + self, + path: str, + *, + headers: Optional[dict] = None, + params: Optional[dict] = None, + ) -> _TestResponse: + return _TestResponse( + self._client.get(path, headers=headers or {}, params=params or {}) + ) def post( self, @@ -121,3 +133,39 @@ def test_sdk_mcp_tools_call(sdk): payload = json.loads(started["content"]) assert payload["run_id"] assert payload["status"] == "queued" + + +def test_sdk_get_news_subscription(sdk): + sub = sdk.create_news_subscription("climate tech", cadence="daily") + fetched = sdk.get_news_subscription(sub["id"]) + assert fetched["id"] == sub["id"] + assert fetched["query"] == "climate tech" + + +def test_sdk_search_documents_max_results(sdk): + sdk.create_document("Alpha", "alpha unique token one two three") + sdk.create_document("Beta", "beta unique token four five six") + hits = sdk.search_documents("unique token", max_results=1) + assert len(hits) == 1 + + +def test_sdk_download_export(platform, sdk): + client, app = platform + run_id = client.post( + "/api/v1/research", + json={ + "question": "Export via SDK?", + "pipeline_id": "fast_research", + "config": fake_run_config(), + }, + ).json()["run_id"] + client.portal.call(make_executor(app).execute, run_id) + markdown = sdk.download_export(run_id, "markdown") + assert b"Integration Report" in markdown + html = sdk.download_export(run_id, "html") + assert b"<" in html + + +def test_sdk_health_and_ready(sdk): + assert sdk.health()["status"] == "ok" + assert sdk.ready()["status"] == "ready"