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
4 changes: 2 additions & 2 deletions .github/workflows/sqlite-lifecycle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ on:
- 'src/basic_memory/**'
- 'pyproject.toml'
- 'uv.lock'
- 'test-int/mcp/test_chatgpt_tools_integration.py'
- 'test-int/mcp/test_search_integration.py'
- 'test-int/mcp/test_concurrent_write_integration.py'

permissions:
Expand Down Expand Up @@ -55,7 +55,7 @@ jobs:
shell: bash
run: >-
uv run pytest
test-int/mcp/test_chatgpt_tools_integration.py::test_chatgpt_search_pagination_default
test-int/mcp/test_search_integration.py::test_search_pagination
test-int/mcp/test_concurrent_write_integration.py::test_concurrent_write_high_volume
-v -s --no-cov --timeout=120 --timeout-method=thread
-o faulthandler_timeout=60 --junitxml=sqlite-mcp.xml
Expand Down
39 changes: 0 additions & 39 deletions src/basic_memory/mcp/clients/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from typing import Any

from httpx import AsyncClient, HTTPStatusError
from pydantic import TypeAdapter

import logfire

Expand All @@ -32,12 +31,6 @@
MAX_ACCEPTED_CONTENT_BATCH,
AcceptedNoteContentBatchResponse,
)
from basic_memory.schemas.v2.note_placement import (
AmbiguousNotePlacementResponse,
NoteCreatePlacementResponse,
NotePlacementResponse,
OutsideProjectPlacementResponse,
)
from basic_memory.schemas.base import Entity
from basic_memory.schemas.v2.note_write import (
WriteNoteRequest,
Expand All @@ -46,11 +39,6 @@
)


_NOTE_PLACEMENT_ADAPTER: TypeAdapter[
NoteCreatePlacementResponse | AmbiguousNotePlacementResponse | OutsideProjectPlacementResponse
] = TypeAdapter(NotePlacementResponse)


class KnowledgeClient:
"""Typed client for knowledge graph entity operations.

Expand Down Expand Up @@ -486,33 +474,6 @@ async def get_orphans(self) -> list[GraphNode]:
)
return OrphanEntitiesResponse.model_validate(response.json()).entities

# --- Placement ---

async def place_note(
self, identifier: str
) -> (
NoteCreatePlacementResponse
| AmbiguousNotePlacementResponse
| OutsideProjectPlacementResponse
):
"""Ask where a note that resolves under none of its readings would be created."""
from basic_memory.mcp.tools.utils import call_query

with logfire.span(
"mcp.client.knowledge.place_note",
client_name="knowledge",
operation="place_note",
):
response = await call_query(
self.http_client,
f"{self._base_path}/placement",
json={"identifier": identifier},
client_name="knowledge",
operation="place_note",
path_template="/v2/projects/{project_id}/knowledge/placement",
)
return _NOTE_PLACEMENT_ADAPTER.validate_python(response.json())

# --- Resolution ---

async def _resolve_entity_data(
Expand Down
37 changes: 1 addition & 36 deletions src/basic_memory/mcp/project_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,7 @@
workspace_permalink_context,
)
from basic_memory.mcp.project_context_identifiers import (
UnresolvedProjectRouteError,
WorkspaceMemoryUrlResolution,
add_project_metadata as _add_project_metadata,
routes_to_default_project as _routes_to_default_project,
canonical_memory_path_for_active_route as _canonical_memory_path_for_active_route,
canonical_memory_path_for_workspace as _canonical_memory_path_for_workspace,
canonicalize_project_name as _canonicalize_project_name,
Expand Down Expand Up @@ -94,8 +91,6 @@

# Keep the original module's helper surface intact for callers and tests while
# the implementations live in focused, dependency-light modules.
add_project_metadata = _add_project_metadata
routes_to_default_project = _routes_to_default_project
_format_qualified_choices = _format_qualified_choices_impl
_match_workspace_identifier = _match_workspace_identifier_impl
_split_qualified_project_identifier = _split_qualified_project_identifier_impl
Expand Down Expand Up @@ -795,31 +790,11 @@ async def resolve_project_and_path(
project: Optional[str] = None,
context: Optional[Context] = None,
headers: HeaderTypes | None = None,
*,
strict_project_routing: bool = False,
allow_missing_project_fallback: bool = False,
cache_resolved_project: bool = True,
) -> tuple[ProjectItem, str, bool]:
"""Resolve project and normalized path for memory:// identifiers.

Args:
strict_project_routing: Reject a memory URL whose leading project-like
segment cannot be resolved. Mutating tools use this to prevent a
failed route from falling back to the active project.
allow_missing_project_fallback: When strict routing is enabled, still
allow a genuinely missing project prefix to be treated as an active-
project path. This is safe only for mutations that require an existing
target and cannot create content.
cache_resolved_project: Persist a project resolved from the memory URL in
MCP context. Set this to false for validation-only routing that may
reject a resolved cross-project source.

Returns:
Tuple of (active_project, normalized_path, is_memory_url)

Raises:
UnresolvedProjectRouteError: If strict routing is enabled and the
memory URL's leading project segment does not resolve.
"""
is_memory_url = identifier.strip().startswith("memory://")
config = ConfigManager().config
Expand Down Expand Up @@ -957,18 +932,9 @@ async def resolve_project_and_path(
"does not have access to this project" in error_message
and cached_project is not None
and _project_matches_identifier(cached_project, project)
and not strict_project_routing
)
if not project_route_missing and not project_route_hidden_by_scope:
raise
if strict_project_routing and not (
project_route_missing and allow_missing_project_fallback
):
# Mutations that can create content must not reinterpret a
# missing project route as an active-project path (#1066).
# Existing-target mutations may opt into that legacy path
# fallback, while scope-hidden routes always fail above.
raise UnresolvedProjectRouteError(identifier, project_prefix) from exc
else:
resolved_project = await resolve_project_parameter(project_prefix, context=context)
if resolved_project and generate_permalink(resolved_project) != generate_permalink(
Expand All @@ -985,8 +951,7 @@ async def resolve_project_and_path(
path=resolved.path,
is_default=resolved.is_default,
)
if cache_resolved_project:
await _set_cached_active_project(context, active_project)
await _set_cached_active_project(context, active_project)

resolved_path = _canonical_memory_path_for_active_route(
active_project,
Expand Down
39 changes: 0 additions & 39 deletions src/basic_memory/mcp/project_context_identifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Iterable, Optional

Expand All @@ -19,18 +18,6 @@
from basic_memory.workspace_context import current_workspace_permalink_context


class UnresolvedProjectRouteError(ValueError):
"""A mutating memory URL named a project prefix that could not be resolved."""

def __init__(self, identifier: str, project_prefix: str):
self.identifier = identifier
self.project_prefix = project_prefix
super().__init__(
f"Memory URL project route '{project_prefix}' could not be resolved; "
"refusing to treat the URL as a path in the active project."
)


@dataclass(frozen=True)
class WorkspaceMemoryUrlResolution:
"""Resolved workspace/project route for a workspace-qualified memory URL."""
Expand Down Expand Up @@ -263,32 +250,6 @@ def split_project_prefix(path: str) -> tuple[Optional[str], str]:
return project_prefix, remainder


def routes_to_default_project(project: Optional[str], project_id: Optional[str]) -> bool:
"""Return whether a call with these arguments is served by the default project.

MCP requests carry no session state, so a call that names no project goes to the
default project, unless the server is constrained to one project
(BASIC_MEMORY_MCP_PROJECT), which then serves every call.
"""
if project is not None or project_id is not None:
return False
return not os.environ.get("BASIC_MEMORY_MCP_PROJECT")


def add_project_metadata(result: str, project_name: str, *, used_default: bool) -> str:
"""Name the project a write landed in.

When the default project served a call that named none, say so, so a caller that
meant another project does not mistake where the write went.
"""
if not used_default:
return f"{result}\n\n[Project: '{project_name}']"
return (
f"{result}\n\n[Project: '{project_name}' (no project was given, so this used "
"the default project; pass project to target another)]"
)


def detect_project_from_url_prefix(
identifier: str,
config: BasicMemoryConfig,
Expand Down
7 changes: 0 additions & 7 deletions src/basic_memory/mcp/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,7 @@
"""

# Shared implementations used by the CLI and the bm_* tools; these do not register MCP tools.
from basic_memory.mcp.tools.read_note import read_note as read_note

from basic_memory.mcp.tools.search import search_notes as search_notes
from basic_memory.mcp.tools.edit_note import edit_note as edit_note
from basic_memory.mcp.tools.workspaces import list_workspaces as list_workspaces

# ChatGPT adapter implementations; Cloud owns their registration.
from basic_memory.mcp.tools.chatgpt_tools import search as search, fetch as fetch

# Shared POSIX implementations used by the shell commands.
from basic_memory.mcp.tools.posix_tools import (
Expand Down
Loading
Loading