Skip to content

Commit f40ab31

Browse files
phernandezgroksrcgithub-actions[bot]
authored
feat: chatgpt tools for search and fetch (#305)
Signed-off-by: Paul Hernandez <[email protected]> Signed-off-by: Drew Cain <[email protected]> Co-authored-by: Drew Cain <[email protected]> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez <[email protected]> Co-authored-by: Drew Cain <[email protected]>
1 parent bcf7f40 commit f40ab31

File tree

7 files changed

+912
-16
lines changed

7 files changed

+912
-16
lines changed

.github/workflows/claude-code-review.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
pull-requests: write
2525
issues: read
2626
id-token: write
27-
27+
2828
steps:
2929
- name: Checkout repository
3030
uses: actions/checkout@v4
@@ -36,7 +36,9 @@ jobs:
3636
uses: anthropics/claude-code-action@v1
3737
with:
3838
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
39+
github_token: ${{ secrets.GITHUB_TOKEN }}
3940
track_progress: true # Enable visual progress tracking
41+
allowed_bots: '*'
4042
prompt: |
4143
Review this Basic Memory PR against our team checklist:
4244

src/basic_memory/mcp/tools/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
create_memory_project,
2525
delete_project,
2626
)
27+
# ChatGPT-compatible tools
28+
from basic_memory.mcp.tools.chatgpt_tools import search, fetch
2729

2830
__all__ = [
2931
"build_context",
@@ -32,12 +34,14 @@
3234
"delete_note",
3335
"delete_project",
3436
"edit_note",
37+
"fetch",
3538
"list_directory",
3639
"list_memory_projects",
3740
"move_note",
3841
"read_content",
3942
"read_note",
4043
"recent_activity",
44+
"search",
4145
"search_notes",
4246
"sync_status",
4347
"view_note",
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""ChatGPT-compatible MCP tools for Basic Memory.
2+
3+
These adapters expose Basic Memory's search/fetch functionality using the exact
4+
tool names and response structure OpenAI's MCP clients expect: each call returns
5+
a list containing a single `{"type": "text", "text": "{...json...}"}` item.
6+
"""
7+
8+
import json
9+
from typing import Any, Dict, List, Optional
10+
from loguru import logger
11+
from fastmcp import Context
12+
13+
from basic_memory.mcp.server import mcp
14+
from basic_memory.mcp.tools.search import search_notes
15+
from basic_memory.mcp.tools.read_note import read_note
16+
from basic_memory.schemas.search import SearchResponse
17+
18+
19+
def _format_search_results_for_chatgpt(results: SearchResponse) -> List[Dict[str, Any]]:
20+
"""Format search results according to ChatGPT's expected schema.
21+
22+
Returns a list of result objects with id, title, and url fields.
23+
"""
24+
formatted_results = []
25+
26+
for result in results.results:
27+
formatted_result = {
28+
"id": result.permalink or f"doc-{len(formatted_results)}",
29+
"title": result.title if result.title and result.title.strip() else "Untitled",
30+
"url": result.permalink or ""
31+
}
32+
formatted_results.append(formatted_result)
33+
34+
return formatted_results
35+
36+
37+
def _format_document_for_chatgpt(
38+
content: str, identifier: str, title: Optional[str] = None
39+
) -> Dict[str, Any]:
40+
"""Format document content according to ChatGPT's expected schema.
41+
42+
Returns a document object with id, title, text, url, and metadata fields.
43+
"""
44+
# Extract title from markdown content if not provided
45+
if not title and isinstance(content, str):
46+
lines = content.split('\n')
47+
if lines and lines[0].startswith('# '):
48+
title = lines[0][2:].strip()
49+
else:
50+
title = identifier.split('/')[-1].replace('-', ' ').title()
51+
52+
# Ensure title is never None
53+
if not title:
54+
title = "Untitled Document"
55+
56+
# Handle error cases
57+
if isinstance(content, str) and content.startswith("# Note Not Found"):
58+
return {
59+
"id": identifier,
60+
"title": title or "Document Not Found",
61+
"text": content,
62+
"url": identifier,
63+
"metadata": {"error": "Document not found"}
64+
}
65+
66+
return {
67+
"id": identifier,
68+
"title": title or "Untitled Document",
69+
"text": content,
70+
"url": identifier,
71+
"metadata": {"format": "markdown"}
72+
}
73+
74+
75+
@mcp.tool(
76+
description="Search for content across the knowledge base"
77+
)
78+
async def search(
79+
query: str,
80+
context: Context | None = None,
81+
) -> List[Dict[str, Any]]:
82+
"""ChatGPT/OpenAI MCP search adapter returning a single text content item.
83+
84+
Args:
85+
query: Search query (full-text syntax supported by `search_notes`)
86+
context: Optional FastMCP context passed through for auth/session data
87+
88+
Returns:
89+
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
90+
where the JSON body contains `results`, `total_count`, and echo of `query`.
91+
"""
92+
logger.info(f"ChatGPT search request: query='{query}'")
93+
94+
try:
95+
# Call underlying search_notes with sensible defaults for ChatGPT
96+
results = await search_notes.fn(
97+
query=query,
98+
project=None, # Let project resolution happen automatically
99+
page=1,
100+
page_size=10, # Reasonable default for ChatGPT consumption
101+
search_type="text", # Default to full-text search
102+
context=context
103+
)
104+
105+
# Handle string error responses from search_notes
106+
if isinstance(results, str):
107+
logger.warning(f"Search failed with error: {results[:100]}...")
108+
search_results = {
109+
"results": [],
110+
"error": "Search failed",
111+
"error_details": results[:500] # Truncate long error messages
112+
}
113+
else:
114+
# Format successful results for ChatGPT
115+
formatted_results = _format_search_results_for_chatgpt(results)
116+
search_results = {
117+
"results": formatted_results,
118+
"total_count": len(results.results), # Use actual count from results
119+
"query": query
120+
}
121+
logger.info(f"Search completed: {len(formatted_results)} results returned")
122+
123+
# Return in MCP content array format as required by OpenAI
124+
return [
125+
{
126+
"type": "text",
127+
"text": json.dumps(search_results, ensure_ascii=False)
128+
}
129+
]
130+
131+
except Exception as e:
132+
logger.error(f"ChatGPT search failed for query '{query}': {e}")
133+
error_results = {
134+
"results": [],
135+
"error": "Internal search error",
136+
"error_message": str(e)[:200]
137+
}
138+
return [
139+
{
140+
"type": "text",
141+
"text": json.dumps(error_results, ensure_ascii=False)
142+
}
143+
]
144+
145+
146+
@mcp.tool(
147+
description="Fetch the full contents of a search result document"
148+
)
149+
async def fetch(
150+
id: str,
151+
context: Context | None = None,
152+
) -> List[Dict[str, Any]]:
153+
"""ChatGPT/OpenAI MCP fetch adapter returning a single text content item.
154+
155+
Args:
156+
id: Document identifier (permalink, title, or memory URL)
157+
context: Optional FastMCP context passed through for auth/session data
158+
159+
Returns:
160+
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
161+
where the JSON body includes `id`, `title`, `text`, `url`, and metadata.
162+
"""
163+
logger.info(f"ChatGPT fetch request: id='{id}'")
164+
165+
try:
166+
# Call underlying read_note function
167+
content = await read_note.fn(
168+
identifier=id,
169+
project=None, # Let project resolution happen automatically
170+
page=1,
171+
page_size=10, # Default pagination
172+
context=context
173+
)
174+
175+
# Format the document for ChatGPT
176+
document = _format_document_for_chatgpt(content, id)
177+
178+
logger.info(f"Fetch completed: id='{id}', content_length={len(document.get('text', ''))}")
179+
180+
# Return in MCP content array format as required by OpenAI
181+
return [
182+
{
183+
"type": "text",
184+
"text": json.dumps(document, ensure_ascii=False)
185+
}
186+
]
187+
188+
except Exception as e:
189+
logger.error(f"ChatGPT fetch failed for id '{id}': {e}")
190+
error_document = {
191+
"id": id,
192+
"title": "Fetch Error",
193+
"text": f"Failed to fetch document: {str(e)[:200]}",
194+
"url": id,
195+
"metadata": {"error": "Fetch failed"}
196+
}
197+
return [
198+
{
199+
"type": "text",
200+
"text": json.dumps(error_document, ensure_ascii=False)
201+
}
202+
]

src/basic_memory/mcp/tools/read_note.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ async def read_note(
2525
page_size: int = 10,
2626
context: Context | None = None,
2727
) -> str:
28-
"""Read a markdown note from the knowledge base.
28+
"""Return the raw markdown for a note, or guidance text if no match is found.
2929
3030
Finds and retrieves a note by its title, permalink, or content search,
3131
returning the raw markdown content including observations, relations, and metadata.
@@ -171,25 +171,25 @@ def format_not_found_message(project: str | None, identifier: str) -> str:
171171
"""Format a helpful message when no note was found."""
172172
return dedent(f"""
173173
# Note Not Found in {project}: "{identifier}"
174-
174+
175175
I couldn't find any notes matching "{identifier}". Here are some suggestions:
176-
176+
177177
## Check Identifier Type
178178
- If you provided a title, try using the exact permalink instead
179179
- If you provided a permalink, check for typos or try a broader search
180-
180+
181181
## Search Instead
182182
Try searching for related content:
183183
```
184184
search_notes(project="{project}", query="{identifier}")
185185
```
186-
186+
187187
## Recent Activity
188188
Check recently modified notes:
189189
```
190190
recent_activity(timeframe="7d")
191191
```
192-
192+
193193
## Create New Note
194194
This might be a good opportunity to create a new note on this topic:
195195
```
@@ -198,13 +198,13 @@ def format_not_found_message(project: str | None, identifier: str) -> str:
198198
title="{identifier.capitalize()}",
199199
content='''
200200
# {identifier.capitalize()}
201-
201+
202202
## Overview
203203
[Your content here]
204-
204+
205205
## Observations
206206
- [category] [Observation about {identifier}]
207-
207+
208208
## Relations
209209
- relates_to [[Related Topic]]
210210
''',
@@ -218,34 +218,34 @@ def format_related_results(project: str | None, identifier: str, results) -> str
218218
"""Format a helpful message with related results when an exact match wasn't found."""
219219
message = dedent(f"""
220220
# Note Not Found in {project}: "{identifier}"
221-
221+
222222
I couldn't find an exact match for "{identifier}", but I found some related notes:
223-
223+
224224
""")
225225

226226
for i, result in enumerate(results):
227227
message += dedent(f"""
228228
## {i + 1}. {result.title}
229229
- **Type**: {result.type.value}
230230
- **Permalink**: {result.permalink}
231-
231+
232232
You can read this note with:
233233
```
234234
read_note(project="{project}", {result.permalink}")
235235
```
236-
236+
237237
""")
238238

239239
message += dedent(f"""
240240
## Try More Specific Lookup
241241
For exact matches, try using the full permalink from one of the results above.
242-
242+
243243
## Search For More Results
244244
To see more related content:
245245
```
246246
search_notes(project="{project}", query="{identifier}")
247247
```
248-
248+
249249
## Create New Note
250250
If none of these match what you're looking for, consider creating a new note:
251251
```

test-int/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
122122
env="test",
123123
projects=projects,
124124
default_project="test-project",
125+
default_project_mode=True,
125126
update_permalinks_on_move=True,
126127
)
127128
return app_config

0 commit comments

Comments
 (0)