mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
feat(mcp): add authenticated REST client
This commit is contained in:
parent
eda0249aec
commit
a524fc77cc
3 changed files with 113 additions and 0 deletions
1
surfsense_mcp/src/surfsense_mcp/core/__init__.py
Normal file
1
surfsense_mcp/src/surfsense_mcp/core/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Cross-feature infrastructure: transport, workspace resolution, output shaping."""
|
||||
100
surfsense_mcp/src/surfsense_mcp/core/client.py
Normal file
100
surfsense_mcp/src/surfsense_mcp/core/client.py
Normal file
|
|
@ -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)
|
||||
12
surfsense_mcp/src/surfsense_mcp/core/errors.py
Normal file
12
surfsense_mcp/src/surfsense_mcp/core/errors.py
Normal file
|
|
@ -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."""
|
||||
Loading…
Add table
Add a link
Reference in a new issue