Skip to content

Commit 120dc77

Browse files
phernandezclaude
andcommitted
refactor: introduce typed internal API clients for MCP tools (#494)
Creates a new basic_memory/mcp/clients/ module with typed API clients: - KnowledgeClient: Entity CRUD operations (/knowledge/*) - SearchClient: Search operations (/search/*) - MemoryClient: Context building (/memory/*) - DirectoryClient: Directory listing (/directory/*) - ResourceClient: Resource reading (/resource/*) - ProjectClient: Project management (/projects/*) Each client: - Encapsulates API path construction (no hardcoded paths in tools) - Validates responses via Pydantic models - Uses existing call_* utilities for consistent error handling Refactored search_notes tool to use SearchClient as demonstration. Other tools can be migrated incrementally in follow-up work. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 84c2a11 commit 120dc77

11 files changed

Lines changed: 862 additions & 13 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Typed internal API clients for MCP tools.
2+
3+
These clients encapsulate API paths, error handling, and response validation.
4+
MCP tools become thin adapters that call these clients and format results.
5+
6+
Usage:
7+
from basic_memory.mcp.clients import KnowledgeClient, SearchClient
8+
9+
async with get_client() as http_client:
10+
knowledge = KnowledgeClient(http_client, project_id)
11+
entity = await knowledge.create_entity(entity_data)
12+
"""
13+
14+
from basic_memory.mcp.clients.knowledge import KnowledgeClient
15+
from basic_memory.mcp.clients.search import SearchClient
16+
from basic_memory.mcp.clients.memory import MemoryClient
17+
from basic_memory.mcp.clients.directory import DirectoryClient
18+
from basic_memory.mcp.clients.resource import ResourceClient
19+
from basic_memory.mcp.clients.project import ProjectClient
20+
21+
__all__ = [
22+
"KnowledgeClient",
23+
"SearchClient",
24+
"MemoryClient",
25+
"DirectoryClient",
26+
"ResourceClient",
27+
"ProjectClient",
28+
]
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Typed client for directory API operations.
2+
3+
Encapsulates all /v2/projects/{project_id}/directory/* endpoints.
4+
"""
5+
6+
from typing import Optional, Any
7+
8+
from httpx import AsyncClient
9+
10+
from basic_memory.mcp.tools.utils import call_get
11+
12+
13+
class DirectoryClient:
14+
"""Typed client for directory listing operations.
15+
16+
Centralizes:
17+
- API path construction for /v2/projects/{project_id}/directory/*
18+
- Response validation
19+
- Consistent error handling through call_* utilities
20+
21+
Usage:
22+
async with get_client() as http_client:
23+
client = DirectoryClient(http_client, project_id)
24+
nodes = await client.list("/", depth=2)
25+
"""
26+
27+
def __init__(self, http_client: AsyncClient, project_id: str):
28+
"""Initialize the directory client.
29+
30+
Args:
31+
http_client: HTTPX AsyncClient for making requests
32+
project_id: Project external_id (UUID) for API calls
33+
"""
34+
self.http_client = http_client
35+
self.project_id = project_id
36+
self._base_path = f"/v2/projects/{project_id}/directory"
37+
38+
async def list(
39+
self,
40+
dir_name: str = "/",
41+
*,
42+
depth: int = 1,
43+
file_name_glob: Optional[str] = None,
44+
) -> list[dict[str, Any]]:
45+
"""List directory contents.
46+
47+
Args:
48+
dir_name: Directory path to list (default: root)
49+
depth: How deep to traverse (default: 1)
50+
file_name_glob: Optional glob pattern to filter files
51+
52+
Returns:
53+
List of directory nodes with their contents
54+
55+
Raises:
56+
ToolError: If the request fails
57+
"""
58+
params: dict = {
59+
"dir_name": dir_name,
60+
"depth": depth,
61+
}
62+
if file_name_glob:
63+
params["file_name_glob"] = file_name_glob
64+
65+
response = await call_get(
66+
self.http_client,
67+
f"{self._base_path}/list",
68+
params=params,
69+
)
70+
return response.json()
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Typed client for knowledge/entity API operations.
2+
3+
Encapsulates all /v2/projects/{project_id}/knowledge/* endpoints.
4+
"""
5+
6+
from typing import Any
7+
8+
from httpx import AsyncClient
9+
10+
from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_patch, call_delete
11+
from basic_memory.schemas.response import EntityResponse, DeleteEntitiesResponse
12+
13+
14+
class KnowledgeClient:
15+
"""Typed client for knowledge graph entity operations.
16+
17+
Centralizes:
18+
- API path construction for /v2/projects/{project_id}/knowledge/*
19+
- Response validation via Pydantic models
20+
- Consistent error handling through call_* utilities
21+
22+
Usage:
23+
async with get_client() as http_client:
24+
client = KnowledgeClient(http_client, project_id)
25+
entity = await client.create_entity(entity_data)
26+
"""
27+
28+
def __init__(self, http_client: AsyncClient, project_id: str):
29+
"""Initialize the knowledge client.
30+
31+
Args:
32+
http_client: HTTPX AsyncClient for making requests
33+
project_id: Project external_id (UUID) for API calls
34+
"""
35+
self.http_client = http_client
36+
self.project_id = project_id
37+
self._base_path = f"/v2/projects/{project_id}/knowledge"
38+
39+
# --- Entity CRUD Operations ---
40+
41+
async def create_entity(self, entity_data: dict[str, Any]) -> EntityResponse:
42+
"""Create a new entity.
43+
44+
Args:
45+
entity_data: Entity data including title, content, folder, etc.
46+
47+
Returns:
48+
EntityResponse with created entity details
49+
50+
Raises:
51+
ToolError: If the request fails
52+
"""
53+
response = await call_post(
54+
self.http_client,
55+
f"{self._base_path}/entities",
56+
json=entity_data,
57+
)
58+
return EntityResponse.model_validate(response.json())
59+
60+
async def update_entity(self, entity_id: str, entity_data: dict[str, Any]) -> EntityResponse:
61+
"""Update an existing entity (full replacement).
62+
63+
Args:
64+
entity_id: Entity external_id (UUID)
65+
entity_data: Complete entity data for replacement
66+
67+
Returns:
68+
EntityResponse with updated entity details
69+
70+
Raises:
71+
ToolError: If the request fails
72+
"""
73+
response = await call_put(
74+
self.http_client,
75+
f"{self._base_path}/entities/{entity_id}",
76+
json=entity_data,
77+
)
78+
return EntityResponse.model_validate(response.json())
79+
80+
async def get_entity(self, entity_id: str) -> EntityResponse:
81+
"""Get an entity by ID.
82+
83+
Args:
84+
entity_id: Entity external_id (UUID)
85+
86+
Returns:
87+
EntityResponse with entity details
88+
89+
Raises:
90+
ToolError: If the entity is not found or request fails
91+
"""
92+
response = await call_get(
93+
self.http_client,
94+
f"{self._base_path}/entities/{entity_id}",
95+
)
96+
return EntityResponse.model_validate(response.json())
97+
98+
async def patch_entity(self, entity_id: str, patch_data: dict[str, Any]) -> EntityResponse:
99+
"""Partially update an entity.
100+
101+
Args:
102+
entity_id: Entity external_id (UUID)
103+
patch_data: Partial entity data to update
104+
105+
Returns:
106+
EntityResponse with updated entity details
107+
108+
Raises:
109+
ToolError: If the request fails
110+
"""
111+
response = await call_patch(
112+
self.http_client,
113+
f"{self._base_path}/entities/{entity_id}",
114+
json=patch_data,
115+
)
116+
return EntityResponse.model_validate(response.json())
117+
118+
async def delete_entity(self, entity_id: str) -> DeleteEntitiesResponse:
119+
"""Delete an entity.
120+
121+
Args:
122+
entity_id: Entity external_id (UUID)
123+
124+
Returns:
125+
DeleteEntitiesResponse confirming deletion
126+
127+
Raises:
128+
ToolError: If the entity is not found or request fails
129+
"""
130+
response = await call_delete(
131+
self.http_client,
132+
f"{self._base_path}/entities/{entity_id}",
133+
)
134+
return DeleteEntitiesResponse.model_validate(response.json())
135+
136+
async def move_entity(self, entity_id: str, destination: str) -> EntityResponse:
137+
"""Move an entity to a new location.
138+
139+
Args:
140+
entity_id: Entity external_id (UUID)
141+
destination: New file path for the entity
142+
143+
Returns:
144+
EntityResponse with updated entity details
145+
146+
Raises:
147+
ToolError: If the request fails
148+
"""
149+
response = await call_put(
150+
self.http_client,
151+
f"{self._base_path}/entities/{entity_id}/move",
152+
json={"destination": destination},
153+
)
154+
return EntityResponse.model_validate(response.json())
155+
156+
# --- Resolution ---
157+
158+
async def resolve_entity(self, identifier: str) -> str:
159+
"""Resolve a string identifier to an entity external_id.
160+
161+
Args:
162+
identifier: The identifier to resolve (permalink, title, or path)
163+
164+
Returns:
165+
The resolved entity external_id (UUID)
166+
167+
Raises:
168+
ToolError: If the identifier cannot be resolved
169+
"""
170+
response = await call_post(
171+
self.http_client,
172+
f"{self._base_path}/resolve",
173+
json={"identifier": identifier},
174+
)
175+
data = response.json()
176+
return data["external_id"]
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Typed client for memory/context API operations.
2+
3+
Encapsulates all /v2/projects/{project_id}/memory/* endpoints.
4+
"""
5+
6+
from typing import Optional
7+
8+
from httpx import AsyncClient
9+
10+
from basic_memory.mcp.tools.utils import call_get
11+
from basic_memory.schemas.memory import GraphContext
12+
13+
14+
class MemoryClient:
15+
"""Typed client for memory context operations.
16+
17+
Centralizes:
18+
- API path construction for /v2/projects/{project_id}/memory/*
19+
- Response validation via Pydantic models
20+
- Consistent error handling through call_* utilities
21+
22+
Usage:
23+
async with get_client() as http_client:
24+
client = MemoryClient(http_client, project_id)
25+
context = await client.build_context("memory://specs/search")
26+
"""
27+
28+
def __init__(self, http_client: AsyncClient, project_id: str):
29+
"""Initialize the memory client.
30+
31+
Args:
32+
http_client: HTTPX AsyncClient for making requests
33+
project_id: Project external_id (UUID) for API calls
34+
"""
35+
self.http_client = http_client
36+
self.project_id = project_id
37+
self._base_path = f"/v2/projects/{project_id}/memory"
38+
39+
async def build_context(
40+
self,
41+
path: str,
42+
*,
43+
depth: int = 1,
44+
timeframe: Optional[str] = None,
45+
page: int = 1,
46+
page_size: int = 10,
47+
max_related: int = 10,
48+
) -> GraphContext:
49+
"""Build context from a memory path.
50+
51+
Args:
52+
path: The path to build context for (without memory:// prefix)
53+
depth: How deep to traverse relations
54+
timeframe: Time filter (e.g., "7d", "1 week")
55+
page: Page number (1-indexed)
56+
page_size: Results per page
57+
max_related: Maximum related items per result
58+
59+
Returns:
60+
GraphContext with hierarchical results
61+
62+
Raises:
63+
ToolError: If the request fails
64+
"""
65+
params: dict = {
66+
"depth": depth,
67+
"page": page,
68+
"page_size": page_size,
69+
"max_related": max_related,
70+
}
71+
if timeframe:
72+
params["timeframe"] = timeframe
73+
74+
response = await call_get(
75+
self.http_client,
76+
f"{self._base_path}/{path}",
77+
params=params,
78+
)
79+
return GraphContext.model_validate(response.json())
80+
81+
async def recent(
82+
self,
83+
*,
84+
timeframe: str = "7d",
85+
depth: int = 1,
86+
types: Optional[list[str]] = None,
87+
page: int = 1,
88+
page_size: int = 10,
89+
) -> GraphContext:
90+
"""Get recent activity.
91+
92+
Args:
93+
timeframe: Time filter (e.g., "7d", "1 week", "2 days ago")
94+
depth: How deep to traverse relations
95+
types: Filter by item types
96+
page: Page number (1-indexed)
97+
page_size: Results per page
98+
99+
Returns:
100+
GraphContext with recent activity
101+
102+
Raises:
103+
ToolError: If the request fails
104+
"""
105+
params: dict = {
106+
"timeframe": timeframe,
107+
"depth": depth,
108+
"page": page,
109+
"page_size": page_size,
110+
}
111+
if types:
112+
# Join types as comma-separated string if provided
113+
params["type"] = ",".join(types) if isinstance(types, list) else types
114+
115+
response = await call_get(
116+
self.http_client,
117+
f"{self._base_path}/recent",
118+
params=params,
119+
)
120+
return GraphContext.model_validate(response.json())

0 commit comments

Comments
 (0)