mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
feat(mcp): add workspace resolution context
This commit is contained in:
parent
a524fc77cc
commit
439796ea15
1 changed files with 132 additions and 0 deletions
132
surfsense_mcp/src/surfsense_mcp/core/workspace_context.py
Normal file
132
surfsense_mcp/src/surfsense_mcp/core/workspace_context.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""Active-workspace state and natural-language resolution of a workspace.
|
||||
|
||||
Every workspace-scoped tool takes a workspace by name or id, or omits it to use
|
||||
the active one. This keeps ids out of the conversation: the model (or user)
|
||||
speaks a name, we resolve it, and remember the choice for later calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .client import SurfSenseClient
|
||||
from .errors import ToolError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Workspace:
|
||||
"""A SurfSense workspace (the product's "search space")."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_owner: bool = False
|
||||
member_count: int = 1
|
||||
|
||||
|
||||
class WorkspaceContext:
|
||||
"""Resolves workspace references and tracks the active selection."""
|
||||
|
||||
def __init__(
|
||||
self, client: SurfSenseClient, *, preferred_reference: str | None = None
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._preferred_reference = preferred_reference
|
||||
self._active: Workspace | None = None
|
||||
|
||||
@property
|
||||
def active(self) -> Workspace | None:
|
||||
return self._active
|
||||
|
||||
def remember(self, workspace: Workspace) -> Workspace:
|
||||
"""Make ``workspace`` the default for later scoped calls."""
|
||||
self._active = workspace
|
||||
return workspace
|
||||
|
||||
async def fetch_all(self) -> list[Workspace]:
|
||||
"""List every workspace the token can access."""
|
||||
rows = await self._client.request("GET", "/workspaces")
|
||||
return [_to_workspace(row) for row in rows or []]
|
||||
|
||||
async def resolve(self, reference: str | int | None) -> Workspace:
|
||||
"""Resolve a name/id (or the active/preferred default) to a workspace."""
|
||||
if reference is None or (isinstance(reference, str) and not reference.strip()):
|
||||
return await self._resolve_default()
|
||||
return self.remember(await self._match(reference))
|
||||
|
||||
async def _resolve_default(self) -> Workspace:
|
||||
if self._active is not None:
|
||||
return self._active
|
||||
if self._preferred_reference:
|
||||
return self.remember(await self._match(self._preferred_reference))
|
||||
return self.remember(await self._only_workspace_or_prompt())
|
||||
|
||||
async def _only_workspace_or_prompt(self) -> Workspace:
|
||||
workspaces = await self.fetch_all()
|
||||
if len(workspaces) == 1:
|
||||
return workspaces[0]
|
||||
if not workspaces:
|
||||
raise ToolError(
|
||||
"No accessible workspaces. Confirm the token's account has a "
|
||||
"workspace with API access enabled."
|
||||
)
|
||||
raise ToolError(
|
||||
"No workspace selected. Choose one first with surfsense_select_workspace, "
|
||||
f"or pass 'workspace'. Available: {_name_list(workspaces)}."
|
||||
)
|
||||
|
||||
async def _match(self, reference: str | int) -> Workspace:
|
||||
workspaces = await self.fetch_all()
|
||||
as_id = _as_int(reference)
|
||||
if as_id is not None:
|
||||
found = next((w for w in workspaces if w.id == as_id), None)
|
||||
if found is None:
|
||||
raise ToolError(
|
||||
f"No workspace with id {as_id}. Available: {_name_list(workspaces)}."
|
||||
)
|
||||
return found
|
||||
return _match_by_name(str(reference), workspaces)
|
||||
|
||||
|
||||
def _match_by_name(reference: str, workspaces: list[Workspace]) -> Workspace:
|
||||
"""Match on name: exact, then case-insensitive, then unique substring."""
|
||||
needle = reference.strip()
|
||||
exact = [w for w in workspaces if w.name == needle]
|
||||
if exact:
|
||||
return exact[0]
|
||||
lowered = needle.casefold()
|
||||
insensitive = [w for w in workspaces if w.name.casefold() == lowered]
|
||||
if insensitive:
|
||||
return insensitive[0]
|
||||
partial = [w for w in workspaces if lowered in w.name.casefold()]
|
||||
if len(partial) == 1:
|
||||
return partial[0]
|
||||
if len(partial) > 1:
|
||||
raise ToolError(
|
||||
f"'{reference}' matches several workspaces: {_name_list(partial)}. "
|
||||
"Use a more specific name or the id."
|
||||
)
|
||||
raise ToolError(
|
||||
f"No workspace named '{reference}'. Available: {_name_list(workspaces)}."
|
||||
)
|
||||
|
||||
|
||||
def _to_workspace(row: dict) -> Workspace:
|
||||
return Workspace(
|
||||
id=row["id"],
|
||||
name=row["name"],
|
||||
description=row.get("description"),
|
||||
is_owner=row.get("is_owner", False),
|
||||
member_count=row.get("member_count", 1),
|
||||
)
|
||||
|
||||
|
||||
def _as_int(reference: str | int) -> int | None:
|
||||
if isinstance(reference, int):
|
||||
return reference
|
||||
text = reference.strip()
|
||||
return int(text) if text.isdigit() else None
|
||||
|
||||
|
||||
def _name_list(workspaces: list[Workspace]) -> str:
|
||||
return ", ".join(f"{w.name} (id {w.id})" for w in workspaces)
|
||||
Loading…
Add table
Add a link
Reference in a new issue