Skip to content

Commit 58a52d7

Browse files
committed
fix(mcp): keep ChatGPT search as wrapper
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 58d1d43 commit 58a52d7

5 files changed

Lines changed: 419 additions & 228 deletions

File tree

src/basic_memory/mcp/tools/chatgpt_tools.py

Lines changed: 16 additions & 187 deletions
Original file line numberDiff line numberDiff line change
@@ -7,143 +7,16 @@
77

88
import json
99
from typing import Any, Dict, List, Optional, cast
10-
from uuid import UUID
1110

1211
from fastmcp import Context
1312
from loguru import logger
1413

1514
from basic_memory.mcp.server import mcp
16-
from basic_memory.mcp.tools.project_management import list_memory_projects
1715
from basic_memory.mcp.tools.read_note import read_note
1816
from basic_memory.mcp.tools.search import search_notes
1917
from basic_memory.schemas.search import SearchResponse, SearchResult
2018

2119

22-
def _valid_project_id(value: object) -> str | None:
23-
"""Return a UUID project id string when one is present."""
24-
if not isinstance(value, str) or not value.strip():
25-
return None
26-
try:
27-
return str(UUID(value))
28-
except ValueError:
29-
return None
30-
31-
32-
def _matches_constrained_project(project: dict[str, Any], constrained_project: object) -> bool:
33-
"""Return True when a project list row satisfies BASIC_MEMORY_MCP_PROJECT."""
34-
if not isinstance(constrained_project, str) or not constrained_project.strip():
35-
return True
36-
37-
candidates = {
38-
value
39-
for value in (
40-
project.get("name"),
41-
project.get("qualified_name"),
42-
project.get("external_id"),
43-
)
44-
if isinstance(value, str)
45-
}
46-
return constrained_project in candidates
47-
48-
49-
def _search_project_refs(projects_payload: object) -> list[dict[str, str | None]]:
50-
"""Extract project routing refs for account-scoped ChatGPT search."""
51-
if not isinstance(projects_payload, dict):
52-
return []
53-
54-
payload = cast(dict[str, Any], projects_payload)
55-
projects = payload.get("projects")
56-
if not isinstance(projects, list):
57-
return []
58-
59-
refs: list[dict[str, str | None]] = []
60-
seen: set[tuple[str | None, str | None]] = set()
61-
constrained_project = payload.get("constrained_project")
62-
for item in projects:
63-
if not isinstance(item, dict) or not _matches_constrained_project(
64-
item, constrained_project
65-
):
66-
continue
67-
68-
project = item.get("qualified_name") or item.get("name")
69-
project_name = project if isinstance(project, str) and project.strip() else None
70-
project_id = _valid_project_id(item.get("external_id"))
71-
if project_name is None and project_id is None:
72-
continue
73-
74-
key = (project_name, project_id)
75-
if key in seen:
76-
continue
77-
seen.add(key)
78-
refs.append({"project": project_name, "project_id": project_id})
79-
return refs
80-
81-
82-
def _raw_results_from_search_payload(
83-
results: SearchResponse | list[SearchResult | dict[str, Any]] | dict[str, Any],
84-
) -> list[SearchResult | dict[str, Any]]:
85-
"""Return the result list from any search_notes JSON-compatible payload."""
86-
if isinstance(results, SearchResponse):
87-
return list(results.results)
88-
if isinstance(results, dict):
89-
nested_results = results.get("results")
90-
return (
91-
cast(list[SearchResult | dict[str, Any]], nested_results)
92-
if isinstance(nested_results, list)
93-
else []
94-
)
95-
return list(results)
96-
97-
98-
def _qualify_permalink_for_project(permalink: object, project: str | None) -> object:
99-
"""Return a workspace-qualified permalink when the project ref supplies one."""
100-
if not isinstance(permalink, str) or not permalink.strip():
101-
return permalink
102-
if not isinstance(project, str) or "/" not in project.strip("/"):
103-
return permalink
104-
105-
normalized_permalink = permalink.strip("/")
106-
qualified_project = project.strip("/")
107-
if normalized_permalink == qualified_project or normalized_permalink.startswith(
108-
f"{qualified_project}/"
109-
):
110-
return normalized_permalink
111-
112-
workspace_slug, project_permalink = qualified_project.split("/", 1)
113-
if normalized_permalink == project_permalink or normalized_permalink.startswith(
114-
f"{project_permalink}/"
115-
):
116-
return f"{workspace_slug}/{normalized_permalink}"
117-
return f"{qualified_project}/{normalized_permalink}"
118-
119-
120-
def _qualify_results_for_project(
121-
results: list[SearchResult | dict[str, Any]],
122-
project_ref: dict[str, str | None],
123-
) -> list[dict[str, Any]]:
124-
"""Attach the searched workspace/project prefix to each ChatGPT result id."""
125-
qualified: list[dict[str, Any]] = []
126-
for result in results:
127-
if isinstance(result, SearchResult):
128-
result_data = result.model_dump()
129-
else:
130-
result_data = dict(result)
131-
result_data["permalink"] = _qualify_permalink_for_project(
132-
result_data.get("permalink"),
133-
project_ref.get("project"),
134-
)
135-
qualified.append(result_data)
136-
return qualified
137-
138-
139-
def _result_score(result: SearchResult | dict[str, Any]) -> float:
140-
"""Return a comparable search score for merged project results."""
141-
if isinstance(result, SearchResult):
142-
return result.score
143-
score = result.get("score")
144-
return float(score) if isinstance(score, int | float) else 0.0
145-
146-
14720
def _identifier_for_read_note(identifier: str) -> str:
14821
"""Convert ChatGPT result ids into routable Basic Memory identifiers."""
14922
stripped = identifier.strip()
@@ -252,69 +125,25 @@ async def search(
252125
logger.info(f"ChatGPT search request: query='{query}'")
253126

254127
try:
255-
project_refs = _search_project_refs(
256-
await list_memory_projects(output_format="json", context=context)
128+
# Keep this adapter tiny: the real search behavior lives in search_notes.
129+
results = await search_notes(
130+
query=query,
131+
page=1,
132+
page_size=10,
133+
output_format="json",
134+
context=context,
257135
)
258136

259-
raw_results: list[SearchResult | dict[str, Any]] = []
260-
if project_refs:
261-
# Trigger: ChatGPT only has a strict search(query) -> fetch(id) contract.
262-
# Why: default-project search strands notes in other workspaces/projects.
263-
# Outcome: query every discovered project, then merge the top matches.
264-
for project_ref in project_refs:
265-
results = await search_notes(
266-
query=query,
267-
project=project_ref["project"],
268-
project_id=project_ref["project_id"],
269-
page=1,
270-
page_size=10,
271-
output_format="json",
272-
context=context,
273-
)
274-
275-
if isinstance(results, str):
276-
logger.warning(f"Search failed with error: {results[:100]}...")
277-
search_results = {
278-
"results": [],
279-
"error": "Search failed",
280-
"error_details": results[:500],
281-
}
282-
return [
283-
{
284-
"type": "text",
285-
"text": json.dumps(search_results, ensure_ascii=False),
286-
}
287-
]
288-
289-
raw_results.extend(
290-
_qualify_results_for_project(
291-
_raw_results_from_search_payload(results),
292-
project_ref,
293-
)
294-
)
295-
raw_results = sorted(raw_results, key=_result_score, reverse=True)[:10]
296-
else:
297-
# Trigger: project discovery returned no structured rows.
298-
# Why: preserve the legacy single-project behavior when discovery is unavailable.
299-
# Outcome: let search_notes resolve the default project as before.
300-
results = await search_notes(
301-
query=query,
302-
page=1,
303-
page_size=10,
304-
output_format="json",
305-
context=context,
306-
)
307-
308-
if isinstance(results, str):
309-
logger.warning(f"Search failed with error: {results[:100]}...")
310-
search_results = {
311-
"results": [],
312-
"error": "Search failed",
313-
"error_details": results[:500], # Truncate long error messages
314-
}
315-
return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}]
137+
if isinstance(results, str):
138+
logger.warning(f"Search failed with error: {results[:100]}...")
139+
search_results = {
140+
"results": [],
141+
"error": "Search failed",
142+
"error_details": results[:500], # Truncate long error messages
143+
}
144+
return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}]
316145

317-
raw_results = _raw_results_from_search_payload(results)
146+
raw_results = results.get("results", []) if isinstance(results, dict) else []
318147

319148
formatted_results = _format_search_results_for_chatgpt(raw_results)
320149
search_results = {

0 commit comments

Comments
 (0)