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,7 +24,7 @@ jobs:
|
||||
pull-requests: write
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -36,7 +36,9 @@ jobs:
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
track_progress: true # Enable visual progress tracking
|
||||
allowed_bots: '*'
|
||||
prompt: |
|
||||
Review this Basic Memory PR against our team checklist:
|
||||
|
||||
|
||||
@@ -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:
|
||||
```
|
||||
|
||||
@@ -122,6 +122,7 @@ def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
|
||||
env="test",
|
||||
projects=projects,
|
||||
default_project="test-project",
|
||||
default_project_mode=True,
|
||||
update_permalinks_on_move=True,
|
||||
)
|
||||
return app_config
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
"""
|
||||
Integration tests for ChatGPT-compatible MCP tools.
|
||||
|
||||
Tests the complete flow of search and fetch tools designed for ChatGPT integration,
|
||||
ensuring they properly wrap Basic Memory's MCP tools and return OpenAI-compatible
|
||||
MCP content array format.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
def extract_mcp_json_content(mcp_result):
|
||||
"""
|
||||
Helper to extract JSON content from MCP CallToolResult.
|
||||
|
||||
FastMCP auto-serializes our List[Dict[str, Any]] return values, so we need to:
|
||||
1. Get the content list from the CallToolResult
|
||||
2. Parse the JSON string in the text field (which is our serialized list)
|
||||
3. Extract the actual JSON from the MCP content array structure
|
||||
"""
|
||||
content_list = mcp_result.content
|
||||
mcp_content_list = json.loads(content_list[0].text)
|
||||
return json.loads(mcp_content_list[0]["text"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chatgpt_search_basic(mcp_server, app, test_project):
|
||||
"""Test basic ChatGPT search functionality with MCP content array format."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create test notes for searching
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Machine Learning Fundamentals",
|
||||
"folder": "ai",
|
||||
"content": (
|
||||
"# Machine Learning Fundamentals\n\n"
|
||||
"Introduction to ML concepts and algorithms."
|
||||
),
|
||||
"tags": "ml,ai,fundamentals",
|
||||
},
|
||||
)
|
||||
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Deep Learning with PyTorch",
|
||||
"folder": "ai",
|
||||
"content": (
|
||||
"# Deep Learning with PyTorch\n\n"
|
||||
"Building neural networks using PyTorch framework."
|
||||
),
|
||||
"tags": "pytorch,deep-learning,ai",
|
||||
},
|
||||
)
|
||||
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Data Visualization Guide",
|
||||
"folder": "data",
|
||||
"content": (
|
||||
"# Data Visualization Guide\n\n"
|
||||
"Creating charts and graphs for data analysis."
|
||||
),
|
||||
"tags": "visualization,data,charts",
|
||||
},
|
||||
)
|
||||
|
||||
# Test ChatGPT search tool
|
||||
search_result = await client.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "Machine Learning",
|
||||
},
|
||||
)
|
||||
|
||||
# Extract JSON content from MCP result
|
||||
results_json = extract_mcp_json_content(search_result)
|
||||
assert "results" in results_json
|
||||
assert len(results_json["results"]) > 0
|
||||
|
||||
# Check result structure
|
||||
first_result = results_json["results"][0]
|
||||
assert "id" in first_result
|
||||
assert "title" in first_result
|
||||
assert "url" in first_result
|
||||
|
||||
# Verify correct content found
|
||||
titles = [r["title"] for r in results_json["results"]]
|
||||
assert "Machine Learning Fundamentals" in titles
|
||||
assert "Data Visualization Guide" not in titles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chatgpt_search_empty_results(mcp_server, app, test_project):
|
||||
"""Test ChatGPT search with no matching results."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Search for non-existent content
|
||||
search_result = await client.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "NonExistentTopic12345",
|
||||
},
|
||||
)
|
||||
|
||||
# Extract JSON content from MCP result
|
||||
results_json = extract_mcp_json_content(search_result)
|
||||
assert "results" in results_json
|
||||
assert len(results_json["results"]) == 0
|
||||
assert results_json["query"] == "NonExistentTopic12345"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chatgpt_search_with_boolean_operators(mcp_server, app, test_project):
|
||||
"""Test ChatGPT search with boolean operators."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create test notes
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Python Web Frameworks",
|
||||
"folder": "dev",
|
||||
"content": (
|
||||
"# Python Web Frameworks\n\n"
|
||||
"Comparing Django and Flask for web development."
|
||||
),
|
||||
"tags": "python,web,frameworks",
|
||||
},
|
||||
)
|
||||
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "JavaScript Frameworks",
|
||||
"folder": "dev",
|
||||
"content": "# JavaScript Frameworks\n\nReact, Vue, and Angular comparison.",
|
||||
"tags": "javascript,web,frameworks",
|
||||
},
|
||||
)
|
||||
|
||||
# Test with AND operator
|
||||
search_result = await client.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "Python AND frameworks",
|
||||
},
|
||||
)
|
||||
|
||||
results_json = extract_mcp_json_content(search_result)
|
||||
titles = [r["title"] for r in results_json["results"]]
|
||||
assert "Python Web Frameworks" in titles
|
||||
assert "JavaScript Frameworks" not in titles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chatgpt_fetch_document(mcp_server, app, test_project):
|
||||
"""Test ChatGPT fetch tool for retrieving full document content."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create a test note
|
||||
note_content = """# Advanced Python Techniques
|
||||
|
||||
## Overview
|
||||
This document covers advanced Python programming techniques.
|
||||
|
||||
## Topics Covered
|
||||
- Decorators
|
||||
- Context Managers
|
||||
- Metaclasses
|
||||
- Async/Await patterns
|
||||
|
||||
## Code Examples
|
||||
```python
|
||||
def my_decorator(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
```
|
||||
"""
|
||||
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Advanced Python Techniques",
|
||||
"folder": "programming",
|
||||
"content": note_content,
|
||||
"tags": "python,advanced,programming",
|
||||
},
|
||||
)
|
||||
|
||||
# Fetch the document using its title
|
||||
fetch_result = await client.call_tool(
|
||||
"fetch",
|
||||
{
|
||||
"id": "Advanced Python Techniques",
|
||||
},
|
||||
)
|
||||
|
||||
# Extract JSON content from MCP result
|
||||
document_json = extract_mcp_json_content(fetch_result)
|
||||
assert "id" in document_json
|
||||
assert "title" in document_json
|
||||
assert "text" in document_json
|
||||
assert "url" in document_json
|
||||
assert "metadata" in document_json
|
||||
|
||||
# Verify content
|
||||
assert document_json["title"] == "Advanced Python Techniques"
|
||||
assert "Decorators" in document_json["text"]
|
||||
assert "Context Managers" in document_json["text"]
|
||||
assert "def my_decorator" in document_json["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chatgpt_fetch_by_permalink(mcp_server, app, test_project):
|
||||
"""Test ChatGPT fetch using permalink identifier."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create a note with known content
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Test Document",
|
||||
"folder": "test",
|
||||
"content": "# Test Document\n\nThis is test content for permalink fetching.",
|
||||
"tags": "test",
|
||||
},
|
||||
)
|
||||
|
||||
# First search to get the permalink
|
||||
search_result = await client.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "Test Document",
|
||||
},
|
||||
)
|
||||
|
||||
results_json = extract_mcp_json_content(search_result)
|
||||
assert len(results_json["results"]) > 0
|
||||
permalink = results_json["results"][0]["id"]
|
||||
|
||||
# Fetch using the permalink
|
||||
fetch_result = await client.call_tool(
|
||||
"fetch",
|
||||
{
|
||||
"id": permalink,
|
||||
},
|
||||
)
|
||||
|
||||
# Verify the fetched document
|
||||
document_json = extract_mcp_json_content(fetch_result)
|
||||
assert document_json["id"] == permalink
|
||||
assert "Test Document" in document_json["title"]
|
||||
assert "test content for permalink fetching" in document_json["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chatgpt_fetch_nonexistent_document(mcp_server, app, test_project):
|
||||
"""Test ChatGPT fetch with non-existent document ID."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Try to fetch a non-existent document
|
||||
fetch_result = await client.call_tool(
|
||||
"fetch",
|
||||
{
|
||||
"id": "NonExistentDocument12345",
|
||||
},
|
||||
)
|
||||
|
||||
# Extract JSON content from MCP result
|
||||
document_json = extract_mcp_json_content(fetch_result)
|
||||
|
||||
# Should have document structure even for errors
|
||||
assert "id" in document_json
|
||||
assert "title" in document_json
|
||||
assert "text" in document_json
|
||||
|
||||
# Check for error indication
|
||||
assert document_json["id"] == "NonExistentDocument12345"
|
||||
assert "Not Found" in document_json["text"] or "not found" in document_json["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chatgpt_fetch_with_empty_title(mcp_server, app, test_project):
|
||||
"""Test ChatGPT fetch handles documents with empty or missing titles."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create a note without a title in the content
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "untitled-note",
|
||||
"folder": "misc",
|
||||
"content": "This is content without a markdown header.\n\nJust plain text.",
|
||||
"tags": "misc",
|
||||
},
|
||||
)
|
||||
|
||||
# Fetch the document
|
||||
fetch_result = await client.call_tool(
|
||||
"fetch",
|
||||
{
|
||||
"id": "untitled-note",
|
||||
},
|
||||
)
|
||||
|
||||
# Parse JSON response
|
||||
document_json = extract_mcp_json_content(fetch_result)
|
||||
|
||||
# Should have a title even if content doesn't have one
|
||||
assert "title" in document_json
|
||||
assert document_json["title"] != ""
|
||||
assert document_json["title"] is not None
|
||||
assert "content without a markdown header" in document_json["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chatgpt_search_pagination_default(mcp_server, app, test_project):
|
||||
"""Test that ChatGPT search uses reasonable pagination defaults."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create more than 10 notes to test pagination
|
||||
for i in range(15):
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": f"Test Note {i}",
|
||||
"folder": "bulk",
|
||||
"content": f"# Test Note {i}\n\nThis is test content number {i}.",
|
||||
"tags": "test,bulk",
|
||||
},
|
||||
)
|
||||
|
||||
# Search should return max 10 results by default
|
||||
search_result = await client.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "Test Note",
|
||||
},
|
||||
)
|
||||
|
||||
results_json = extract_mcp_json_content(search_result)
|
||||
|
||||
# Should have at most 10 results (the default page_size)
|
||||
assert len(results_json["results"]) <= 10
|
||||
assert results_json["total_count"] <= 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chatgpt_tools_error_handling(mcp_server, app, test_project):
|
||||
"""Test error handling in ChatGPT tools returns proper MCP format."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Test search with invalid query (if validation exists)
|
||||
# Using empty query to potentially trigger an error
|
||||
search_result = await client.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "", # Empty query might cause an error
|
||||
},
|
||||
)
|
||||
|
||||
# Should still return MCP content array format
|
||||
assert hasattr(search_result, 'content')
|
||||
content_list = search_result.content
|
||||
assert isinstance(content_list, list)
|
||||
assert len(content_list) == 1
|
||||
assert content_list[0].type == "text"
|
||||
|
||||
# Should be valid JSON even on error
|
||||
results_json = extract_mcp_json_content(search_result)
|
||||
assert "results" in results_json # Should have results key even if empty
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chatgpt_integration_workflow(mcp_server, app, test_project):
|
||||
"""Test complete workflow: search then fetch, as ChatGPT would use it."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Step 1: Create multiple documents
|
||||
docs = [
|
||||
{
|
||||
"title": "API Design Best Practices",
|
||||
"content": (
|
||||
"# API Design Best Practices\n\n"
|
||||
"RESTful API design principles and patterns."
|
||||
),
|
||||
"tags": "api,rest,design",
|
||||
},
|
||||
{
|
||||
"title": "GraphQL vs REST",
|
||||
"content": "# GraphQL vs REST\n\nComparing GraphQL and REST API architectures.",
|
||||
"tags": "api,graphql,rest",
|
||||
},
|
||||
{
|
||||
"title": "Database Design Patterns",
|
||||
"content": (
|
||||
"# Database Design Patterns\n\n"
|
||||
"Common database design patterns and anti-patterns."
|
||||
),
|
||||
"tags": "database,design,patterns",
|
||||
},
|
||||
]
|
||||
|
||||
for doc in docs:
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": doc["title"],
|
||||
"folder": "architecture",
|
||||
"content": doc["content"],
|
||||
"tags": doc["tags"],
|
||||
},
|
||||
)
|
||||
|
||||
# Step 2: Search for API-related content (as ChatGPT would)
|
||||
search_result = await client.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "API",
|
||||
},
|
||||
)
|
||||
|
||||
results_json = extract_mcp_json_content(search_result)
|
||||
assert len(results_json["results"]) >= 2
|
||||
|
||||
# Step 3: Fetch one of the search results (as ChatGPT would)
|
||||
first_result_id = results_json["results"][0]["id"]
|
||||
fetch_result = await client.call_tool(
|
||||
"fetch",
|
||||
{
|
||||
"id": first_result_id,
|
||||
},
|
||||
)
|
||||
|
||||
document_json = extract_mcp_json_content(fetch_result)
|
||||
|
||||
# Verify the fetched document matches search result
|
||||
assert document_json["id"] == first_result_id
|
||||
assert "API" in document_json["text"] or "api" in document_json["text"].lower()
|
||||
|
||||
# Verify document has expected structure
|
||||
assert document_json["metadata"]["format"] == "markdown"
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Tests for ChatGPT-compatible MCP tools."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from basic_memory.schemas.search import SearchResponse, SearchResult, SearchItemType
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_successful_results():
|
||||
"""Test search with successful results returns proper MCP content array format."""
|
||||
# Mock successful search results
|
||||
mock_results = SearchResponse(
|
||||
results=[
|
||||
SearchResult(
|
||||
title="Test Document 1",
|
||||
permalink="docs/test-doc-1",
|
||||
content="This is test content for document 1",
|
||||
type=SearchItemType.ENTITY,
|
||||
score=1.0,
|
||||
file_path="/test/docs/test-doc-1.md"
|
||||
),
|
||||
SearchResult(
|
||||
title="Test Document 2",
|
||||
permalink="docs/test-doc-2",
|
||||
content="This is test content for document 2",
|
||||
type=SearchItemType.ENTITY,
|
||||
score=0.9,
|
||||
file_path="/test/docs/test-doc-2.md"
|
||||
)
|
||||
],
|
||||
current_page=1,
|
||||
page_size=10
|
||||
)
|
||||
|
||||
with patch(
|
||||
'basic_memory.mcp.tools.chatgpt_tools.search_notes.fn',
|
||||
new_callable=AsyncMock
|
||||
) as mock_search:
|
||||
mock_search.return_value = mock_results
|
||||
|
||||
# Import and call the actual function
|
||||
from basic_memory.mcp.tools.chatgpt_tools import search
|
||||
result = await search.fn("test query")
|
||||
|
||||
# Verify MCP content array format
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "text"
|
||||
|
||||
# Parse the JSON content
|
||||
content = json.loads(result[0]["text"])
|
||||
assert "results" in content
|
||||
assert "query" in content
|
||||
|
||||
# Verify result structure
|
||||
assert len(content["results"]) == 2
|
||||
assert content["query"] == "test query"
|
||||
|
||||
# Verify individual result format
|
||||
result_item = content["results"][0]
|
||||
assert result_item["id"] == "docs/test-doc-1"
|
||||
assert result_item["title"] == "Test Document 1"
|
||||
assert result_item["url"] == "docs/test-doc-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_error_response():
|
||||
"""Test search when underlying search_notes returns error string."""
|
||||
error_message = "# Search Failed - Invalid Syntax\n\nThe search query contains errors..."
|
||||
|
||||
with patch(
|
||||
'basic_memory.mcp.tools.chatgpt_tools.search_notes.fn',
|
||||
new_callable=AsyncMock
|
||||
) as mock_search:
|
||||
mock_search.return_value = error_message
|
||||
|
||||
from basic_memory.mcp.tools.chatgpt_tools import search
|
||||
result = await search.fn("invalid query")
|
||||
|
||||
# Verify MCP content array format
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "text"
|
||||
|
||||
# Parse the JSON content
|
||||
content = json.loads(result[0]["text"])
|
||||
assert content["results"] == []
|
||||
assert content["error"] == "Search failed"
|
||||
assert "error_details" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_successful_document():
|
||||
"""Test fetch with successful document retrieval."""
|
||||
document_content = """# Test Document
|
||||
|
||||
This is the content of a test document.
|
||||
|
||||
## Section 1
|
||||
Some content here.
|
||||
|
||||
## Observations
|
||||
- [observation] This is a test observation
|
||||
|
||||
## Relations
|
||||
- relates_to [[Another Document]]
|
||||
"""
|
||||
|
||||
with patch(
|
||||
'basic_memory.mcp.tools.chatgpt_tools.read_note.fn',
|
||||
new_callable=AsyncMock
|
||||
) as mock_read:
|
||||
mock_read.return_value = document_content
|
||||
|
||||
from basic_memory.mcp.tools.chatgpt_tools import fetch
|
||||
result = await fetch.fn("docs/test-document")
|
||||
|
||||
# Verify MCP content array format
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "text"
|
||||
|
||||
# Parse the JSON content
|
||||
content = json.loads(result[0]["text"])
|
||||
assert content["id"] == "docs/test-document"
|
||||
assert content["title"] == "Test Document" # Extracted from markdown
|
||||
assert content["text"] == document_content
|
||||
assert content["url"] == "docs/test-document"
|
||||
assert content["metadata"]["format"] == "markdown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_document_not_found():
|
||||
"""Test fetch when document is not found."""
|
||||
error_content = """# Note Not Found: "nonexistent-doc"
|
||||
|
||||
I couldn't find any notes matching "nonexistent-doc". Here are some suggestions:
|
||||
|
||||
## Check Identifier Type
|
||||
- If you provided a title, try using the exact permalink instead
|
||||
"""
|
||||
|
||||
with patch(
|
||||
'basic_memory.mcp.tools.chatgpt_tools.read_note.fn',
|
||||
new_callable=AsyncMock
|
||||
) as mock_read:
|
||||
mock_read.return_value = error_content
|
||||
|
||||
from basic_memory.mcp.tools.chatgpt_tools import fetch
|
||||
result = await fetch.fn("nonexistent-doc")
|
||||
|
||||
# Verify MCP content array format
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "text"
|
||||
|
||||
# Parse the JSON content
|
||||
content = json.loads(result[0]["text"])
|
||||
assert content["id"] == "nonexistent-doc"
|
||||
assert content["text"] == error_content
|
||||
assert content["metadata"]["error"] == "Document not found"
|
||||
|
||||
|
||||
def test_format_search_results_for_chatgpt():
|
||||
"""Test search results formatting."""
|
||||
from basic_memory.mcp.tools.chatgpt_tools import _format_search_results_for_chatgpt
|
||||
|
||||
mock_results = SearchResponse(
|
||||
results=[
|
||||
SearchResult(
|
||||
title="Document One",
|
||||
permalink="docs/doc-one",
|
||||
content="Content for document one",
|
||||
type=SearchItemType.ENTITY,
|
||||
score=1.0,
|
||||
file_path="/test/docs/doc-one.md"
|
||||
),
|
||||
SearchResult(
|
||||
title="", # Test empty title handling
|
||||
permalink="docs/untitled",
|
||||
content="Content without title",
|
||||
type=SearchItemType.ENTITY,
|
||||
score=0.8,
|
||||
file_path="/test/docs/untitled.md"
|
||||
)
|
||||
],
|
||||
current_page=1,
|
||||
page_size=10
|
||||
)
|
||||
|
||||
formatted = _format_search_results_for_chatgpt(mock_results)
|
||||
|
||||
assert len(formatted) == 2
|
||||
assert formatted[0]["id"] == "docs/doc-one"
|
||||
assert formatted[0]["title"] == "Document One"
|
||||
assert formatted[0]["url"] == "docs/doc-one"
|
||||
|
||||
# Test empty title handling
|
||||
assert formatted[1]["title"] == "Untitled"
|
||||
|
||||
|
||||
def test_format_document_for_chatgpt():
|
||||
"""Test document formatting."""
|
||||
from basic_memory.mcp.tools.chatgpt_tools import _format_document_for_chatgpt
|
||||
|
||||
content = "# Test Document\n\nThis is test content."
|
||||
result = _format_document_for_chatgpt(content, "docs/test")
|
||||
|
||||
assert result["id"] == "docs/test"
|
||||
assert result["title"] == "Test Document"
|
||||
assert result["text"] == content
|
||||
assert result["url"] == "docs/test"
|
||||
assert result["metadata"]["format"] == "markdown"
|
||||
|
||||
|
||||
def test_format_document_error_handling():
|
||||
"""Test document formatting with error content."""
|
||||
from basic_memory.mcp.tools.chatgpt_tools import _format_document_for_chatgpt
|
||||
|
||||
error_content = "# Note Not Found: \"missing-doc\"\n\nDocument not found."
|
||||
result = _format_document_for_chatgpt(error_content, "missing-doc", "Missing Doc")
|
||||
|
||||
assert result["id"] == "missing-doc"
|
||||
assert result["title"] == "Missing Doc"
|
||||
assert result["text"] == error_content
|
||||
assert result["metadata"]["error"] == "Document not found"
|
||||
Reference in New Issue
Block a user