Skip to content

Commit 58d1d43

Browse files
committed
fix(mcp): qualify ChatGPT search fetch permalinks
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent e871298 commit 58d1d43

7 files changed

Lines changed: 304 additions & 44 deletions

File tree

src/basic_memory/mcp/project_context.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -432,15 +432,13 @@ def _canonical_memory_path_for_workspace(
432432
) -> str:
433433
"""Return the stored canonical path for a workspace-qualified memory URL."""
434434
normalized_remainder = remainder.strip("/")
435-
if workspace_type == "organization":
436-
prefix = f"{generate_permalink(workspace_slug)}/{project_permalink}"
437-
elif workspace_type == "personal":
438-
prefix = project_permalink if include_project else ""
439-
else:
435+
if workspace_type not in {"organization", "personal"}:
440436
raise ValueError(f"Unsupported workspace_type for memory URL routing: {workspace_type}")
441437

442-
if not prefix:
443-
return normalized_remainder
438+
# Trigger: a caller supplied a workspace-qualified memory URL.
439+
# Why: the first two path segments are the global route, even for Personal.
440+
# Outcome: lookups preserve the complete workspace/project canonical permalink.
441+
prefix = f"{generate_permalink(workspace_slug)}/{project_permalink}"
444442
if not normalized_remainder:
445443
return prefix
446444
return f"{prefix}/{normalized_remainder}"

src/basic_memory/mcp/tools/chatgpt_tools.py

Lines changed: 212 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,168 @@
66
"""
77

88
import json
9-
from typing import Any, Dict, List, Optional
9+
from typing import Any, Dict, List, Optional, cast
10+
from uuid import UUID
1011

1112
from fastmcp import Context
1213
from loguru import logger
1314

1415
from basic_memory.mcp.server import mcp
16+
from basic_memory.mcp.tools.project_management import list_memory_projects
1517
from basic_memory.mcp.tools.read_note import read_note
1618
from basic_memory.mcp.tools.search import search_notes
1719
from basic_memory.schemas.search import SearchResponse, SearchResult
1820

1921

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+
147+
def _identifier_for_read_note(identifier: str) -> str:
148+
"""Convert ChatGPT result ids into routable Basic Memory identifiers."""
149+
stripped = identifier.strip()
150+
if stripped.startswith("memory://") or "/" not in stripped:
151+
return identifier
152+
return f"memory://{stripped}"
153+
154+
20155
def _format_search_results_for_chatgpt(
21-
results: SearchResponse | list[SearchResult] | list[dict[str, Any]] | dict[str, Any],
156+
results: SearchResponse | list[SearchResult | dict[str, Any]] | dict[str, Any],
22157
) -> List[Dict[str, Any]]:
23158
"""Format search results according to ChatGPT's expected schema.
24159
25160
Returns a list of result objects with id, title, and url fields.
26161
"""
27162
if isinstance(results, SearchResponse):
28-
raw_results: list[SearchResult] | list[dict[str, Any]] = results.results
163+
raw_results: list[SearchResult | dict[str, Any]] = list(results.results)
29164
elif isinstance(results, dict):
30165
nested_results = results.get("results")
31-
raw_results = nested_results if isinstance(nested_results, list) else []
166+
raw_results = (
167+
cast(list[SearchResult | dict[str, Any]], nested_results)
168+
if isinstance(nested_results, list)
169+
else []
170+
)
32171
else:
33172
raw_results = results
34173

@@ -113,34 +252,77 @@ async def search(
113252
logger.info(f"ChatGPT search request: query='{query}'")
114253

115254
try:
116-
# Let search_notes resolve the default project via get_project_client(),
117-
# which works in both local mode (ConfigManager) and cloud mode (database).
118-
results = await search_notes(
119-
query=query,
120-
page=1,
121-
page_size=10,
122-
output_format="json",
123-
context=context,
255+
project_refs = _search_project_refs(
256+
await list_memory_projects(output_format="json", context=context)
124257
)
125258

126-
# Handle string error responses from search_notes
127-
if isinstance(results, str):
128-
logger.warning(f"Search failed with error: {results[:100]}...")
129-
search_results = {
130-
"results": [],
131-
"error": "Search failed",
132-
"error_details": results[:500], # Truncate long error messages
133-
}
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]
134296
else:
135-
# Format successful results for ChatGPT
136-
raw_results = results.get("results", []) if isinstance(results, dict) else []
137-
formatted_results = _format_search_results_for_chatgpt(raw_results)
138-
search_results = {
139-
"results": formatted_results,
140-
"total_count": len(raw_results), # Use actual count from results
141-
"query": query,
142-
}
143-
logger.info(f"Search completed: {len(formatted_results)} results returned")
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)}]
316+
317+
raw_results = _raw_results_from_search_payload(results)
318+
319+
formatted_results = _format_search_results_for_chatgpt(raw_results)
320+
search_results = {
321+
"results": formatted_results,
322+
"total_count": len(raw_results), # Use actual count from results
323+
"query": query,
324+
}
325+
logger.info(f"Search completed: {len(formatted_results)} results returned")
144326

145327
# Return in MCP content array format as required by OpenAI
146328
return [{"type": "text", "text": json.dumps(search_results, ensure_ascii=False)}]
@@ -180,7 +362,7 @@ async def fetch(
180362
# which works in both local mode (ConfigManager) and cloud mode (database).
181363
content = str(
182364
await read_note(
183-
identifier=id,
365+
identifier=_identifier_for_read_note(id),
184366
context=context,
185367
)
186368
)

src/basic_memory/workspace_context.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@
1414

1515
@dataclass(frozen=True)
1616
class WorkspacePermalinkContext:
17-
"""Workspace metadata needed to build canonical organization permalinks."""
17+
"""Workspace metadata needed to build canonical workspace permalinks."""
1818

1919
workspace_slug: str
2020
workspace_type: str
2121

2222
@property
2323
def should_prefix_permalinks(self) -> bool:
24-
return self.workspace_type == "organization" and bool(self.workspace_slug)
24+
return bool(self.workspace_slug)
2525

2626

2727
_workspace_permalink_context: ContextVar[WorkspacePermalinkContext | None] = ContextVar(

tests/mcp/test_project_context.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -826,7 +826,7 @@ async def fake_index(context=None):
826826
resolved = await resolve_workspace_qualified_memory_url("memory://personal/main/notes/foo")
827827

828828
assert resolved is not None
829-
assert resolved.canonical_path == "main/notes/foo"
829+
assert resolved.canonical_path == "personal/main/notes/foo"
830830

831831

832832
@pytest.mark.asyncio
@@ -1373,7 +1373,7 @@ async def fake_call_post(*args, **kwargs):
13731373

13741374

13751375
@pytest.mark.asyncio
1376-
async def test_resolve_project_and_path_strips_personal_workspace_prefix(
1376+
async def test_resolve_project_and_path_preserves_personal_workspace_prefix(
13771377
config_manager,
13781378
monkeypatch,
13791379
):
@@ -1417,7 +1417,7 @@ async def fake_call_post(*args, **kwargs):
14171417
)
14181418

14191419
assert active_project == cached_project
1420-
assert resolved_path == "main/notes/foo"
1420+
assert resolved_path == "personal/main/notes/foo"
14211421
assert is_memory_url is True
14221422

14231423

tests/mcp/test_tool_read_note.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,14 +380,14 @@ async def test_team_workspace_write_stores_complete_permalink_when_project_prefi
380380

381381

382382
@pytest.mark.asyncio
383-
async def test_personal_workspace_write_keeps_project_scoped_permalink(
383+
async def test_personal_workspace_write_stores_complete_canonical_permalink(
384384
app,
385385
test_project,
386386
entity_repository,
387387
):
388388
from basic_memory.workspace_context import workspace_permalink_context
389389

390-
expected_permalink = f"{test_project.name}/personal/personal-workspace-note"
390+
expected_permalink = f"personal/{test_project.name}/personal/personal-workspace-note"
391391

392392
with workspace_permalink_context(workspace_slug="personal", workspace_type="personal"):
393393
write_result = await write_note(

0 commit comments

Comments
 (0)