From a524fc77cc7d5b456df30ce30a5e2a8d7af7a468 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 6 Jul 2026 02:29:19 +0200 Subject: [PATCH] feat(mcp): add authenticated REST client --- .../src/surfsense_mcp/core/__init__.py | 1 + .../src/surfsense_mcp/core/client.py | 100 ++++++++++++++++++ .../src/surfsense_mcp/core/errors.py | 12 +++ 3 files changed, 113 insertions(+) create mode 100644 surfsense_mcp/src/surfsense_mcp/core/__init__.py create mode 100644 surfsense_mcp/src/surfsense_mcp/core/client.py create mode 100644 surfsense_mcp/src/surfsense_mcp/core/errors.py diff --git a/surfsense_mcp/src/surfsense_mcp/core/__init__.py b/surfsense_mcp/src/surfsense_mcp/core/__init__.py new file mode 100644 index 000000000..4580e4ce7 --- /dev/null +++ b/surfsense_mcp/src/surfsense_mcp/core/__init__.py @@ -0,0 +1 @@ +"""Cross-feature infrastructure: transport, workspace resolution, output shaping.""" diff --git a/surfsense_mcp/src/surfsense_mcp/core/client.py b/surfsense_mcp/src/surfsense_mcp/core/client.py new file mode 100644 index 000000000..b61de5221 --- /dev/null +++ b/surfsense_mcp/src/surfsense_mcp/core/client.py @@ -0,0 +1,100 @@ +"""Authenticated transport to a SurfSense backend's REST API. + +Sends requests to fully-formed paths, returns parsed JSON, and turns any +transport or HTTP failure into a readable ``ToolError``. +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from .errors import ToolError + +_FAILURE_HINTS: dict[int, str] = { + 401: "Authentication failed — check that SURFSENSE_PAT is a valid, unexpired token.", + 402: "The workspace is out of credits for this operation.", + 403: ( + "Access denied — the token lacks permission, or API access is disabled " + "for this workspace (enable it in SurfSense workspace settings)." + ), + 404: "The requested resource was not found.", + 429: "Rate limited by the backend — retry after a short pause.", +} + + +class SurfSenseClient: + """Issues authenticated requests against ``{base_url}{api_prefix}``.""" + + def __init__(self, *, api_base: str, pat: str, timeout: float) -> None: + self._api_base = api_base + self._http = httpx.AsyncClient( + base_url=api_base, + headers={ + "Authorization": f"Bearer {pat}", + "Accept": "application/json", + }, + timeout=timeout, + ) + + async def request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: Any | None = None, + data: dict[str, Any] | None = None, + files: Any | None = None, + ) -> Any: + """Send a request and return the parsed body, or raise ``ToolError``.""" + try: + response = await self._http.request( + method, path, params=params, json=json, data=data, files=files + ) + except httpx.RequestError as exc: + raise ToolError( + f"Could not reach SurfSense at {self._api_base}: {exc}. " + "Confirm the backend is running and SURFSENSE_BASE_URL is correct." + ) from exc + + if response.is_success: + return self._parse_body(response) + raise ToolError(self._explain_failure(response)) + + async def aclose(self) -> None: + await self._http.aclose() + + @staticmethod + def _parse_body(response: httpx.Response) -> Any: + if not response.content: + return None + try: + return response.json() + except ValueError: + return response.text + + @classmethod + def _explain_failure(cls, response: httpx.Response) -> str: + """Turn an error response into one actionable sentence for the model.""" + detail = cls._extract_detail(response) + hint = _FAILURE_HINTS.get(response.status_code) + if detail and hint: + return f"{hint} (server said: {detail})" + if detail: + return f"SurfSense returned {response.status_code}: {detail}" + return hint or f"SurfSense returned HTTP {response.status_code}." + + @staticmethod + def _extract_detail(response: httpx.Response) -> str | None: + try: + body = response.json() + except ValueError: + return response.text.strip() or None + if isinstance(body, dict): + detail = body.get("detail", body) + if isinstance(detail, dict): + return detail.get("message") or str(detail) + return str(detail) + return str(body) diff --git a/surfsense_mcp/src/surfsense_mcp/core/errors.py b/surfsense_mcp/src/surfsense_mcp/core/errors.py new file mode 100644 index 000000000..428def846 --- /dev/null +++ b/surfsense_mcp/src/surfsense_mcp/core/errors.py @@ -0,0 +1,12 @@ +"""The single failure type tools raise to speak plainly to the model. + +A failed tool call should tell the model what to do next, not leak a stack +trace. Anything the caller could act on — no workspace selected, an unknown id, +a rejected request — is raised as ``ToolError`` with a sentence safe to surface. +""" + +from __future__ import annotations + + +class ToolError(Exception): + """A user-actionable failure whose message is meant for the model to read."""