Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/basic_memory/mcp/index_readiness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Index-required guidance shared by MCP discovery tools."""

from httpx import AsyncClient

from basic_memory.schemas.project_info import ProjectItem
from basic_memory.schemas.project_readiness import ProjectIndexPhase
from basic_memory.utils import shell_command


async def project_index_required(client: AsyncClient, project: ProjectItem) -> str | None:
"""Distinguish an unsearched project from an honest empty result.

Call only after an empty read: the status endpoint observes project files,
so successful reads should not pay for an extra directory scan. Let status
failures propagate rather than interpreting unknown readiness as an empty index.
"""
# Match the tools' deferred client imports to keep CLI startup lightweight.
from basic_memory.mcp.clients.project import ProjectClient

status = await ProjectClient(client).get_status(project.external_id)
readiness = status.readiness
if readiness.phase is not ProjectIndexPhase.NEVER_INDEXED:
return None

# ProjectItem carries no routing mode. Label both remedies explicitly instead
# of guessing from local config, which cannot identify a hosted factory route.
local = readiness.describe(
project.name, index_command=shell_command("bm", "project", "index", project.name)
)
cloud = readiness.describe(project.name, index_command=None)
return (
"# Project Index Required\n\n"
f"Project '{project.name}' has never been indexed. You need to index it before "
"an empty result can establish that no notes match.\n\n"
f"- For a local project: {local}.\n"
f"- For a cloud project: {cloud}; wait for server-side indexing before retrying."
)
12 changes: 12 additions & 0 deletions src/basic_memory/mcp/tools/recent_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from pydantic import AliasChoices, Field

from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.index_readiness import project_index_required
from basic_memory.mcp.project_context import (
get_project_client,
resolve_project_parameter,
Expand Down Expand Up @@ -224,6 +225,11 @@ async def recent_activity(
# Query each project's activity
for project_info in project_list.projects:
project_activity = await _get_project_activity(client, project_info, params, depth)
# Discovery must not hide an unindexed project in an empty summary.
if not project_activity.item_count:
guidance = await project_index_required(client, project_info)
if guidance is not None:
return guidance
projects_activity[project_info.name] = project_activity

# Aggregate stats
Expand Down Expand Up @@ -330,6 +336,12 @@ async def recent_activity(
)
activity_data = GraphContext.model_validate(response.json())

# Do not offer first-note onboarding before the project was indexed.
if not activity_data.results:
guidance = await project_index_required(client, active_project)
if guidance is not None:
return guidance

if output_format == "json":
return _extract_recent_rows(activity_data)

Expand Down
11 changes: 5 additions & 6 deletions src/basic_memory/mcp/tools/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
is_factory_mode,
)
from basic_memory.mcp.container import get_container
from basic_memory.mcp.index_readiness import project_index_required
from basic_memory.mcp.project_context import (
detect_project_from_identifier_prefix,
get_project_client,
Expand Down Expand Up @@ -1477,13 +1478,11 @@ async def search_notes(
f"page={result.current_page} page_size={result.page_size}"
)

# Check if we got no results and provide helpful guidance
# An empty page is a trustworthy miss only after an index pass.
if not result.results:
logger.debug(
f"Search returned no results for query: {query} in project {active_project.name}"
)
# Don't treat this as an error, but the user might want guidance
# We return the empty result as normal - the user can decide if they need help
guidance = await project_index_required(client, active_project)
if guidance is not None:
return guidance

if compact:
result = _compact_search_response(result)
Expand Down
8 changes: 4 additions & 4 deletions test-int/cli/test_cli_tool_delete_note_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def _project_file(test_project, file_path: str) -> Path:


def test_delete_note_removes_file_database_record_and_search_result(
app, app_config, test_project, config_manager
app, app_config, test_project, config_manager, indexed_project
) -> None:
"""Single-note deletion removes the note from every user-visible surface."""
note = _write_note(
Expand Down Expand Up @@ -164,7 +164,7 @@ def test_delete_note_case_mismatch_does_not_delete_exact_note(


def test_delete_note_project_id_takes_precedence_over_wrong_project_name(
app, app_config, test_project, config_manager
app, app_config, test_project, config_manager, indexed_project
) -> None:
"""CLI `--project-id` routes destructive operations to the exact project."""
note = _write_note(
Expand All @@ -186,7 +186,7 @@ def test_delete_note_project_id_takes_precedence_over_wrong_project_name(


def test_delete_note_memory_url_detects_project_from_identifier(
app, app_config, test_project, config_manager
app, app_config, test_project, config_manager, indexed_project
) -> None:
"""A memory:// URL can select the project without a separate --project flag."""
note = _write_note(
Expand All @@ -206,7 +206,7 @@ def test_delete_note_memory_url_detects_project_from_identifier(


def test_delete_directory_removes_nested_files_database_records_and_search_results(
app, app_config, test_project, config_manager
app, app_config, test_project, config_manager, indexed_project
) -> None:
"""Directory deletion removes nested notes and reports a complete JSON summary."""
notes = [
Expand Down
10 changes: 7 additions & 3 deletions test-int/cli/test_cli_tool_json_failure_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
@pytest.mark.parametrize(
"identifier", ["nonexistent-note-that-does-not-exist", "22222222-2222-4222-8222-222222222222"]
)
def test_read_note_not_found(app, app_config, test_project, config_manager, identifier):
def test_read_note_not_found(
app, app_config, test_project, config_manager, identifier, indexed_project
):
"""A missing note remains machine-readable but must not report success."""
result = runner.invoke(
cli_app,
Expand All @@ -37,7 +39,7 @@ def test_read_note_not_found(app, app_config, test_project, config_manager, iden
@pytest.mark.parametrize("mode", ["piped", "json", "plain", "rich"])
@pytest.mark.parametrize("related", [False, True])
def test_read_after_delete_fails_in_every_mode(
app, app_config, test_project, config_manager, monkeypatch, mode, related
app, app_config, test_project, config_manager, monkeypatch, mode, related, indexed_project
):
"""The actual write/delete/read flow must fail even when search offers alternatives."""
monkeypatch.setattr("basic_memory.cli.commands.tool._use_rich", lambda: mode == "rich")
Expand Down Expand Up @@ -131,7 +133,9 @@ def test_write_note_then_read_note_roundtrip(app, app_config, test_project, conf
assert read_data["permalink"] == write_data["permalink"]


def test_recent_activity_empty_project(app, app_config, test_project, config_manager, monkeypatch):
def test_recent_activity_empty_project(
app, app_config, test_project, config_manager, monkeypatch, indexed_project
):
"""recent-activity on empty project returns valid empty JSON list."""
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", test_project.name)

Expand Down
9 changes: 9 additions & 0 deletions test-int/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,3 +543,12 @@ async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
"""Create test client that both MCP and tests will use."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
yield client


@pytest_asyncio.fixture
async def indexed_project(client: AsyncClient, test_project: Project) -> Project:
"""Complete an initial pass for tests asserting honest misses after reads/deletes."""
from basic_memory.mcp.clients.project import ProjectClient

await ProjectClient(client).index(test_project.external_id, run_in_background=False)
return test_project
2 changes: 1 addition & 1 deletion test-int/mcp/test_chatgpt_tools_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ async def test_chatgpt_search_basic(mcp_server, app, test_project):


@pytest.mark.asyncio
async def test_chatgpt_search_empty_results(mcp_server, app, test_project):
async def test_chatgpt_search_empty_results(mcp_server, app, test_project, indexed_project):
"""Test ChatGPT search with no matching results."""

async with openai_mcp_client(mcp_server) as client:
Expand Down
4 changes: 2 additions & 2 deletions test-int/mcp/test_delete_note_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ async def test_delete_note_by_title(mcp_server, app, test_project):


@pytest.mark.asyncio
async def test_delete_note_by_permalink(mcp_server, app, test_project):
async def test_delete_note_by_permalink(mcp_server, app, test_project, indexed_project):
"""Test deleting a note by its permalink."""

async with Client(mcp_server) as client:
Expand Down Expand Up @@ -362,7 +362,7 @@ async def test_delete_note_rejects_case_mismatch(mcp_server, app, test_project):


@pytest.mark.asyncio
async def test_delete_multiple_notes_sequentially(mcp_server, app, test_project):
async def test_delete_multiple_notes_sequentially(mcp_server, app, test_project, indexed_project):
"""Test deleting multiple notes in sequence."""

async with Client(mcp_server) as client:
Expand Down
42 changes: 42 additions & 0 deletions test-int/mcp/test_index_readiness_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""A real first index pass changes MCP guidance into trustworthy retrieval."""

from pathlib import Path

import pytest

from basic_memory.mcp.clients.project import ProjectClient
from basic_memory.mcp.tools import recent_activity, search_notes
from basic_memory.schemas.project_readiness import ProjectIndexPhase


@pytest.mark.parametrize("output_format", ["text", "json"])
async def test_first_index_makes_empty_reads_trustworthy(client, test_project, output_format):
note = Path(test_project.path) / "unindexed.md"
note.write_text("# Previously Invisible\n\nUniqueReadinessToken\n", encoding="utf-8")
projects = ProjectClient(client)
before = await projects.get_status(test_project.external_id)
assert before.readiness.phase is ProjectIndexPhase.NEVER_INDEXED

search = await search_notes(
project=test_project.name, query="UniqueReadinessToken", output_format=output_format
)
activity = await recent_activity(project=test_project.name, output_format=output_format)
assert isinstance(search, str) and search.startswith("# Project Index Required")
assert isinstance(activity, str) and activity.startswith("# Project Index Required")

await projects.index(test_project.external_id, run_in_background=False)
after = await projects.get_status(test_project.external_id)
assert after.readiness.phase is not ProjectIndexPhase.NEVER_INDEXED
found = await search_notes(
project=test_project.name, query="UniqueReadinessToken", output_format="json"
)
assert isinstance(found, dict) and found["results"]
miss = await search_notes(
project=test_project.name, query="MissingReadinessToken", output_format="json"
)
assert isinstance(miss, dict) and miss["results"] == []
# The note has no observations: filtering them is now an honest empty activity page.
assert (
await recent_activity(project=test_project.name, type="observation", output_format="json")
== []
)
2 changes: 1 addition & 1 deletion test-int/mcp/test_search_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ async def test_search_pagination(mcp_server, app, test_project):


@pytest.mark.asyncio
async def test_search_no_results(mcp_server, app, test_project):
async def test_search_no_results(mcp_server, app, test_project, indexed_project):
"""Test search with no matching results."""

async with Client(mcp_server) as client:
Expand Down
9 changes: 9 additions & 0 deletions tests/mcp/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,12 @@ async def second_project(config_manager, engine_factory, tmp_path_factory) -> Pr
config.projects["second-project"] = ProjectEntry(path=str(project_path))
config_manager.save_config(config)
return project


@pytest_asyncio.fixture
async def indexed_project(client: AsyncClient, test_project: Project) -> Project:
"""Complete an initial pass for tests asserting ordinary empty retrieval."""
from basic_memory.mcp.clients.project import ProjectClient

await ProjectClient(client).index(test_project.external_id, run_in_background=False)
return test_project
12 changes: 8 additions & 4 deletions tests/mcp/test_first_connect_onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ def test_server_sets_first_connect_instructions():


@pytest.mark.asyncio
async def test_recent_activity_empty_project_offers_first_note(client, test_project):
async def test_recent_activity_empty_project_offers_first_note(
client, test_project, indexed_project
):
"""An empty project (no test_graph) should surface the offer-not-act first-note guidance."""
result = await recent_activity(project=test_project.name, timeframe="7d") # pyright: ignore[reportGeneralTypeIssues]

Expand All @@ -64,7 +66,9 @@ async def test_recent_activity_populated_project_has_no_first_note_offer(


@pytest.mark.asyncio
async def test_recent_activity_filtered_empty_result_has_no_first_note_offer(client, test_project):
async def test_recent_activity_filtered_empty_result_has_no_first_note_offer(
client, test_project, indexed_project
):
"""A type-filter miss does not mean an established project has no notes."""
await write_note(
project=test_project.name,
Expand All @@ -87,7 +91,7 @@ async def test_recent_activity_filtered_empty_result_has_no_first_note_offer(cli

@pytest.mark.asyncio
async def test_recent_activity_out_of_range_page_has_no_first_note_offer(
client, test_project, test_graph
client, test_project, test_graph, indexed_project
):
"""An empty later page should direct the agent back through pagination."""
result = await recent_activity(
Expand All @@ -107,7 +111,7 @@ async def test_recent_activity_out_of_range_page_has_no_first_note_offer(


@pytest.mark.asyncio
async def test_search_no_results_points_to_recent_activity(client, test_project):
async def test_search_no_results_points_to_recent_activity(client, test_project, indexed_project):
"""Empty search must point at recent_activity rather than repeat the first-note offer."""
result = await search_notes(query="XYZ123NoSuchNote", project=test_project.name) # pyright: ignore[reportGeneralTypeIssues]

Expand Down
Loading
Loading