mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: chatgpt tools for search and fetch (#305)
Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com> Signed-off-by: Drew Cain <groksrc@gmail.com> Co-authored-by: Drew Cain <groksrc@gmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com> Co-authored-by: Drew Cain <groksrc@users.noreply.github.com>
This commit is contained in:
@@ -24,6 +24,8 @@ from basic_memory.mcp.tools.project_management import (
|
||||
create_memory_project,
|
||||
delete_project,
|
||||
)
|
||||
# ChatGPT-compatible tools
|
||||
from basic_memory.mcp.tools.chatgpt_tools import search, fetch
|
||||
|
||||
__all__ = [
|
||||
"build_context",
|
||||
@@ -32,12 +34,14 @@ __all__ = [
|
||||
"delete_note",
|
||||
"delete_project",
|
||||
"edit_note",
|
||||
"fetch",
|
||||
"list_directory",
|
||||
"list_memory_projects",
|
||||
"move_note",
|
||||
"read_content",
|
||||
"read_note",
|
||||
"recent_activity",
|
||||
"search",
|
||||
"search_notes",
|
||||
"sync_status",
|
||||
"view_note",
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""ChatGPT-compatible MCP tools for Basic Memory.
|
||||
|
||||
These adapters expose Basic Memory's search/fetch functionality using the exact
|
||||
tool names and response structure OpenAI's MCP clients expect: each call returns
|
||||
a list containing a single `{"type": "text", "text": "{...json...}"}` item.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.schemas.search import SearchResponse
|
||||
|
||||
|
||||
def _format_search_results_for_chatgpt(results: SearchResponse) -> List[Dict[str, Any]]:
|
||||
"""Format search results according to ChatGPT's expected schema.
|
||||
|
||||
Returns a list of result objects with id, title, and url fields.
|
||||
"""
|
||||
formatted_results = []
|
||||
|
||||
for result in results.results:
|
||||
formatted_result = {
|
||||
"id": result.permalink or f"doc-{len(formatted_results)}",
|
||||
"title": result.title if result.title and result.title.strip() else "Untitled",
|
||||
"url": result.permalink or ""
|
||||
}
|
||||
formatted_results.append(formatted_result)
|
||||
|
||||
return formatted_results
|
||||
|
||||
|
||||
def _format_document_for_chatgpt(
|
||||
content: str, identifier: str, title: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Format document content according to ChatGPT's expected schema.
|
||||
|
||||
Returns a document object with id, title, text, url, and metadata fields.
|
||||
"""
|
||||
# Extract title from markdown content if not provided
|
||||
if not title and isinstance(content, str):
|
||||
lines = content.split('\n')
|
||||
if lines and lines[0].startswith('# '):
|
||||
title = lines[0][2:].strip()
|
||||
else:
|
||||
title = identifier.split('/')[-1].replace('-', ' ').title()
|
||||
|
||||
# Ensure title is never None
|
||||
if not title:
|
||||
title = "Untitled Document"
|
||||
|
||||
# Handle error cases
|
||||
if isinstance(content, str) and content.startswith("# Note Not Found"):
|
||||
return {
|
||||
"id": identifier,
|
||||
"title": title or "Document Not Found",
|
||||
"text": content,
|
||||
"url": identifier,
|
||||
"metadata": {"error": "Document not found"}
|
||||
}
|
||||
|
||||
return {
|
||||
"id": identifier,
|
||||
"title": title or "Untitled Document",
|
||||
"text": content,
|
||||
"url": identifier,
|
||||
"metadata": {"format": "markdown"}
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Search for content across the knowledge base"
|
||||
)
|
||||
async def search(
|
||||
query: str,
|
||||
context: Context | None = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""ChatGPT/OpenAI MCP search adapter returning a single text content item.
|
||||
|
||||
Args:
|
||||
query: Search query (full-text syntax supported by `search_notes`)
|
||||
context: Optional FastMCP context passed through for auth/session data
|
||||
|
||||
Returns:
|
||||
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
|
||||
where the JSON body contains `results`, `total_count`, and echo of `query`.
|
||||
"""
|
||||
logger.info(f"ChatGPT search request: query='{query}'")
|
||||
|
||||
try:
|
||||
# Call underlying search_notes with sensible defaults for ChatGPT
|
||||
results = await search_notes.fn(
|
||||
query=query,
|
||||
project=None, # Let project resolution happen automatically
|
||||
page=1,
|
||||
page_size=10, # Reasonable default for ChatGPT consumption
|
||||
search_type="text", # Default to full-text search
|
||||
context=context
|
||||
)
|
||||
|
||||
# Handle string error responses from search_notes
|
||||
if isinstance(results, str):
|
||||
logger.warning(f"Search failed with error: {results[:100]}...")
|
||||
search_results = {
|
||||
"results": [],
|
||||
"error": "Search failed",
|
||||
"error_details": results[:500] # Truncate long error messages
|
||||
}
|
||||
else:
|
||||
# Format successful results for ChatGPT
|
||||
formatted_results = _format_search_results_for_chatgpt(results)
|
||||
search_results = {
|
||||
"results": formatted_results,
|
||||
"total_count": len(results.results), # Use actual count from results
|
||||
"query": query
|
||||
}
|
||||
logger.info(f"Search completed: {len(formatted_results)} results returned")
|
||||
|
||||
# Return in MCP content array format as required by OpenAI
|
||||
return [
|
||||
{
|
||||
"type": "text",
|
||||
"text": json.dumps(search_results, ensure_ascii=False)
|
||||
}
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ChatGPT search failed for query '{query}': {e}")
|
||||
error_results = {
|
||||
"results": [],
|
||||
"error": "Internal search error",
|
||||
"error_message": str(e)[:200]
|
||||
}
|
||||
return [
|
||||
{
|
||||
"type": "text",
|
||||
"text": json.dumps(error_results, ensure_ascii=False)
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Fetch the full contents of a search result document"
|
||||
)
|
||||
async def fetch(
|
||||
id: str,
|
||||
context: Context | None = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""ChatGPT/OpenAI MCP fetch adapter returning a single text content item.
|
||||
|
||||
Args:
|
||||
id: Document identifier (permalink, title, or memory URL)
|
||||
context: Optional FastMCP context passed through for auth/session data
|
||||
|
||||
Returns:
|
||||
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
|
||||
where the JSON body includes `id`, `title`, `text`, `url`, and metadata.
|
||||
"""
|
||||
logger.info(f"ChatGPT fetch request: id='{id}'")
|
||||
|
||||
try:
|
||||
# Call underlying read_note function
|
||||
content = await read_note.fn(
|
||||
identifier=id,
|
||||
project=None, # Let project resolution happen automatically
|
||||
page=1,
|
||||
page_size=10, # Default pagination
|
||||
context=context
|
||||
)
|
||||
|
||||
# Format the document for ChatGPT
|
||||
document = _format_document_for_chatgpt(content, id)
|
||||
|
||||
logger.info(f"Fetch completed: id='{id}', content_length={len(document.get('text', ''))}")
|
||||
|
||||
# Return in MCP content array format as required by OpenAI
|
||||
return [
|
||||
{
|
||||
"type": "text",
|
||||
"text": json.dumps(document, ensure_ascii=False)
|
||||
}
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ChatGPT fetch failed for id '{id}': {e}")
|
||||
error_document = {
|
||||
"id": id,
|
||||
"title": "Fetch Error",
|
||||
"text": f"Failed to fetch document: {str(e)[:200]}",
|
||||
"url": id,
|
||||
"metadata": {"error": "Fetch failed"}
|
||||
}
|
||||
return [
|
||||
{
|
||||
"type": "text",
|
||||
"text": json.dumps(error_document, ensure_ascii=False)
|
||||
}
|
||||
]
|
||||
@@ -25,7 +25,7 @@ async def read_note(
|
||||
page_size: int = 10,
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
"""Read a markdown note from the knowledge base.
|
||||
"""Return the raw markdown for a note, or guidance text if no match is found.
|
||||
|
||||
Finds and retrieves a note by its title, permalink, or content search,
|
||||
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:
|
||||
"""Format a helpful message when no note was found."""
|
||||
return dedent(f"""
|
||||
# Note Not Found in {project}: "{identifier}"
|
||||
|
||||
|
||||
I couldn't find any notes matching "{identifier}". Here are some suggestions:
|
||||
|
||||
|
||||
## Check Identifier Type
|
||||
- If you provided a title, try using the exact permalink instead
|
||||
- If you provided a permalink, check for typos or try a broader search
|
||||
|
||||
|
||||
## Search Instead
|
||||
Try searching for related content:
|
||||
```
|
||||
search_notes(project="{project}", query="{identifier}")
|
||||
```
|
||||
|
||||
|
||||
## Recent Activity
|
||||
Check recently modified notes:
|
||||
```
|
||||
recent_activity(timeframe="7d")
|
||||
```
|
||||
|
||||
|
||||
## Create New Note
|
||||
This might be a good opportunity to create a new note on this topic:
|
||||
```
|
||||
@@ -198,13 +198,13 @@ def format_not_found_message(project: str | None, identifier: str) -> str:
|
||||
title="{identifier.capitalize()}",
|
||||
content='''
|
||||
# {identifier.capitalize()}
|
||||
|
||||
|
||||
## Overview
|
||||
[Your content here]
|
||||
|
||||
|
||||
## Observations
|
||||
- [category] [Observation about {identifier}]
|
||||
|
||||
|
||||
## Relations
|
||||
- relates_to [[Related Topic]]
|
||||
''',
|
||||
@@ -218,9 +218,9 @@ def format_related_results(project: str | None, identifier: str, results) -> str
|
||||
"""Format a helpful message with related results when an exact match wasn't found."""
|
||||
message = dedent(f"""
|
||||
# Note Not Found in {project}: "{identifier}"
|
||||
|
||||
|
||||
I couldn't find an exact match for "{identifier}", but I found some related notes:
|
||||
|
||||
|
||||
""")
|
||||
|
||||
for i, result in enumerate(results):
|
||||
@@ -228,24 +228,24 @@ def format_related_results(project: str | None, identifier: str, results) -> str
|
||||
## {i + 1}. {result.title}
|
||||
- **Type**: {result.type.value}
|
||||
- **Permalink**: {result.permalink}
|
||||
|
||||
|
||||
You can read this note with:
|
||||
```
|
||||
read_note(project="{project}", {result.permalink}")
|
||||
```
|
||||
|
||||
|
||||
""")
|
||||
|
||||
message += dedent(f"""
|
||||
## Try More Specific Lookup
|
||||
For exact matches, try using the full permalink from one of the results above.
|
||||
|
||||
|
||||
## Search For More Results
|
||||
To see more related content:
|
||||
```
|
||||
search_notes(project="{project}", query="{identifier}")
|
||||
```
|
||||
|
||||
|
||||
## Create New Note
|
||||
If none of these match what you're looking for, consider creating a new note:
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user