Skip to content

fix(mcp): resolve projects by external_id, remove workspace from MCP tools#777

Merged
phernandez merged 5 commits intomainfrom
project-id-resolution
May 1, 2026
Merged

fix(mcp): resolve projects by external_id, remove workspace from MCP tools#777
phernandez merged 5 commits intomainfrom
project-id-resolution

Conversation

@phernandez
Copy link
Copy Markdown
Member

@phernandez phernandez commented Apr 30, 2026

Summary

Lets MCP tools address projects unambiguously across cloud workspaces, without requiring callers to know about workspaces. Removes the workspace parameter from MCP tools entirely and adds an optional project_id (UUID) parameter to every project-scoped tool.

Closes basicmachines-co/basic-memory-cloud#673

What changed

1. Project resolution by external_id (UUID)

  • resolve_workspace_project_identifier() accepts a UUID and resolves it via a fast-path lookup against the workspace/project index — bypassing name/permalink resolution and workspace ambiguity handling.
  • UUID inputs are canonicalized via str(UUID(value)) before lookup, so uppercase hex, brace-wrapped ({...}), urn:uuid:..., and unhyphenated forms all map to the same key. (Codex P2)

2. workspace parameter removed from MCP tools
Workspaces are now purely an implementation detail. When a project name exists in multiple workspaces and no UUID disambiguator is given, resolution falls back to the default workspace (instead of erroring with "exists in multiple workspaces").

3. Optional project_id parameter on all 14 project-scoped MCP tools
read_note, write_note, search_notes, edit_note, delete_note, view_note, read_content, move_note, list_directory, canvas, recent_activity, build_context, schema_validate/infer/diff, search_notes_ui, read_note_ui.

  • When provided, project_id takes precedence over project and routes to a specific project unambiguously.
  • LLMs discover the UUID from list_memory_projects(), which now includes external_id in its output (and the docstring points there).
  • Backward compatible: callers passing only project (the existing pattern) keep working unchanged.

4. get_project_client() routes UUIDs correctly in both modes
The local config keys projects by name only, so get_project_mode(uuid) would have falsely defaulted to CLOUD and broken pure-local routing for any UUID identifier. Fixed by:

  • In Step 3 (cloud routing): only enter the cloud path for unknown identifiers when cloud is actually available (factory mode, explicit cloud, or credentials present).
  • In Step 4 (local routing): when project_id is set, skip per-project mode lookup in get_client() — local mode routes everything through the same ASGI client; the API resolves the UUID via /v2/projects/resolve.

5. Internal tool→tool calls preserve workspace selection
read_note._search_candidates (the title/text fallback path) now passes project_id=active_project.external_id to search_notes. Without this, the inner re-resolution could select a different tenant via the default-workspace fallback when context was unavailable (CLI / context=None). (Codex P1, solved more thoroughly than the original suggestion — preserving routing without breaking local mode.)

Tools that delegate (view_noteread_note, ui_sdksearch_notes/read_note) propagate project_id through.

Tools calling resolve_project_and_path() (read_note, read_content, search_notes) now pass active_project.name (the resolved canonical name) instead of the raw project argument, so the inner get_active_project() cache hits even when project_id was used or project was wrong.

Why this approach

The earlier draft had read_note._search_candidates swap project=name for project=external_id. That broke pure-local mode because the local config indexes by name only — passing a UUID hit the CLOUD-by-default branch in get_project_mode() and errored without cloud credentials.

Adding a separate project_id parameter:

  • Keeps the existing name-based contract for local single-workspace users (no behavior change).
  • Gives LLMs in cloud multi-workspace setups a way to specify the exact project after discovering its UUID via list_memory_projects().
  • Closes the workspace-ambiguity loop end-to-end: discovery → identifier → routing → workspace-correct API call.

Test plan

  • All MCP unit tests pass (569 passed, 8 skipped)
  • New integration tests for project_id end-to-end through MCP transport pass
  • Lint clean, type-check clean
  • Tool signature contract test updated for new parameter
  • Telemetry tests updated for the new requested_project_id span attribute

New tests

  • tests/mcp/test_project_context.py:
    • test_resolve_workspace_project_identifier_resolves_by_external_id — direct UUID lookup picks the right workspace
    • test_resolve_workspace_project_identifier_normalizes_uuid_forms — uppercase/brace/urn/unhyphenated forms canonicalize
    • test_get_project_client_prefers_project_id_over_project_name — UUID precedence
    • test_get_project_client_with_project_id_routes_locally_without_cloud — regression for the local-mode UUID routing fix
  • test-int/mcp/test_read_note_integration.py:
    • test_read_note_by_project_id — end-to-end read using only project_id
    • test_read_note_project_id_takes_precedence_over_name — wrong name + correct UUID still resolves correctly

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 30, 2026

Claude finished @phernandez's task in 3m 11s —— View job


PR Review: Resolve projects by external_id, remove workspace from MCP tools

Tasks:

  • Gather context and understand the PR
  • Review project_context.py changes (core resolver logic)
  • Review MCP tool signature changes (15 tools)
  • Review project_management.py changes (list_memory_projects, external_id)
  • Review schema changes
  • Review test changes
  • Evaluate against team checklist

Summary

This is a well-scoped cleanup PR that removes workspace as a public concern from all 15 MCP tool signatures and routes projects by external_id (UUID) instead. The core logic is sound and the reduction in surface area is a genuine improvement. I found one coverage gap (the primary new feature) and a few style/robustness notes.


Issues

🔴 Missing test for the UUID fast-path — coverage gap

The most significant new feature — resolving projects by external_id UUID — has no dedicated test. The entries_by_external_id dict is populated by _build_workspace_project_index, and the fast-path is exercised by resolve_workspace_project_identifier. But no test calls that function with a valid UUID string.

The test fixtures at lines 591/595 use non-UUID strings ("personal-project-id", "acme-project-id"), so they cannot exercise the UUID(project) code path. The try: UUID(project) block in project_context.py:560-565 is effectively dead from a test perspective.

Given the 100% coverage requirement, this needs a test like:

@pytest.mark.asyncio
async def test_resolve_workspace_project_identifier_by_external_id(monkeypatch):
    """Projects can be resolved unambiguously by external_id UUID."""
    ...
    entries = (
        WorkspaceProjectEntry(
            workspace=personal,
            project=_project("My Notes", id=1, external_id="11111111-1111-1111-1111-111111111111"),
        ),
    )
    ...
    resolved = await resolve_workspace_project_identifier("11111111-1111-1111-1111-111111111111")
    assert resolved.project.external_id == "11111111-1111-1111-1111-111111111111"

Fix this →

🟡 Silent collision in entries_by_external_id when duplicate external_id values exist

_build_workspace_project_index (line 361) silently overwrites on external_id collision:

by_external_id[entry.project.external_id] = entry  # last-writer wins

The permalink index uses grouped.setdefault to accumulate duplicates, but the external_id index has no equivalent protection. UUIDs should be globally unique, but a logger.warning on collision would make data-integrity bugs visible rather than silent.

🟡 Simplified comment doesn't follow CLAUDE.md decision-point format

The removed workspace-precedence comment block was replaced with a briefer form:

if project_entry and project_entry.workspace_id:
    # Per-project config stores the cloud tenant id directly
    workspace_id = project_entry.workspace_id

CLAUDE.md requires Trigger / Why / Outcome for conditionals that materially change behavior. The previous version had this. The simplified version loses the "Why: routing can send that id directly; requiring workspace discovery here would turn a control-plane listing outage into a project routing failure" rationale, which is non-obvious and worth keeping.

🟡 Telemetry tests leave bare empty lines after workspace_id removal

In test_tool_telemetry.py, five dicts have a trailing blank line where "workspace_id": None, was removed (e.g., line 66 in the diff). Syntactically fine, but cosmetically inconsistent with the rest of the file.


Checklist

Item Status
Follows coding conventions (CLAUDE.md) ✅ Mostly — see comment-style note above
Python 3.12+ type annotations and async patterns
SQLAlchemy 2.0 best practices ✅ (no DB layer changes)
FastAPI and Typer conventions followed
100-character line length
No commented-out code blocks
Unit tests for new functions/methods ❌ UUID fast-path is untested
Integration tests for new MCP tools ✅ Contract tests updated correctly
Test coverage for edge cases ✅ Default-workspace fallback is tested
100% test coverage maintained ❌ UUID fast-path not covered
Documentation/docstrings updated get_project_client and resolve_workspace_project_identifier docstrings updated
CLAUDE.md updated if conventions change N/A
MCP tools follow atomic, composable design
Database changes include Alembic migrations N/A
Preserves local-first architecture principles
Knowledge graph operations maintain consistency
No hardcoded secrets or credentials
Input validation for MCP tools ✅ UUID parsing via uuid.UUID() is clean
Proper error handling and logging
No sensitive data in logs
Windows-compatible file path comparisons N/A
No emojis/unicode in console/log output

Positive Notes

  • Excellent surface-area reduction: Removing workspace from 15 tool signatures is a clean, breaking-free simplification — LLMs no longer need to know about the workspace concept.
  • UUID fast-path approach: Using try: UUID(project) is clean and doesn't require format-prefix heuristics.
  • Default-workspace fallback: Preferring the default workspace over raising ValueError on ambiguous names is a better LLM UX.
  • external_id in list_memory_projects output: Smart — LLMs can now discover and use stable project identifiers in subsequent calls.
  • _fetch_cloud_projects removal: Consolidating to the workspace-index approach removes a separate code path, reducing complexity.

The main ask before merging is a test covering the UUID resolution fast-path.

Workspaces are an implementation detail that should not be exposed to MCP
tool callers. This change:

- 🔑 Adds external_id (UUID) lookup to project resolution so callers can
  reference projects unambiguously without knowing which workspace owns them
- 🗑️ Removes the `workspace` parameter from all 15 MCP tool signatures
- 🔄 Falls back to the default workspace when a project name exists in
  multiple workspaces (instead of erroring)
- 📋 Includes external_id in list_memory_projects output so LLMs can
  discover and use project UUIDs

Closes basicmachines-co/basic-memory-cloud#673

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
@phernandez phernandez force-pushed the project-id-resolution branch from 17b4142 to 4915e13 Compare April 30, 2026 22:31
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 30, 2026

Claude finished @phernandez's task in 2m 21s —— View job


PR Review: Resolve projects by external_id, remove workspace from MCP tools

Tasks:

  • Gather context and understand the PR
  • Review project_context.py changes (core resolver logic)
  • Review MCP tool signature changes (15 tools)
  • Review project_management.py changes (list_memory_projects, external_id)
  • Review schema changes
  • Review test changes
  • Evaluate against team checklist

Summary

This is a well-scoped PR with a clear goal: let LLMs reference projects by UUID (external_id) without needing to know workspace ownership, and clean up MCP tool signatures by removing workspace as a user-facing parameter. The core resolver logic is sound. I found one test coverage gap for the new UUID fast path, one minor type-consistency issue, and a few other observations.


Issues

🔴 Missing test: UUID fast path is never exercised

resolve_workspace_project_identifier() now includes:

# project_context.py:561-567
try:
    UUID(project)
    entry = index.entries_by_external_id.get(project)
    if entry:
        return entry
except ValueError:
    pass

However, no test passes a real UUID string to this function. Every test fixture uses non-UUID external_id values like "personal-project-id", "acme-project-id", etc. (see test_project_context.py:591-596). The UUID() constructor will raise ValueError for those strings, so the fast path is silently skipped in every existing test.

To hit 100% coverage (as required by CLAUDE.md) and validate the feature works, a test like this is needed:

@pytest.mark.asyncio
async def test_resolve_workspace_project_identifier_by_external_id(monkeypatch):
    import basic_memory.mcp.project_context as project_context
    from basic_memory.mcp.project_context import (
        WorkspaceProjectEntry,
        _build_workspace_project_index,
        resolve_workspace_project_identifier,
    )
    VALID_UUID = "11111111-1111-1111-1111-111111111111"
    personal = _workspace(tenant_id="personal-tenant", ..., is_default=True)
    entries = (
        WorkspaceProjectEntry(
            workspace=personal,
            project=_project("Notes", id=1, external_id=VALID_UUID),
        ),
    )
    index = _build_workspace_project_index((personal,), entries)
    async def fake_index(context=None): return index
    monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)

    resolved = await resolve_workspace_project_identifier(VALID_UUID)
    assert resolved.project.external_id == VALID_UUID

Fix this →

🟡 Mutable dict field in frozen=True dataclass

WorkspaceProjectIndex is frozen=True, but entries_by_external_id is a dict (mutable):

# project_context.py:61
entries_by_external_id: dict[str, WorkspaceProjectEntry] = field(default_factory=dict)

All other collection fields are tuple (immutable). The frozen=True prevents rebinding the attribute but not mutation of the dict itself. For consistency and to match the "immutable index" intent, consider MappingProxyType or just leaving a comment noting this inconsistency is intentional.

🟡 Missing test: collision with no default workspace

resolve_workspace_project_identifier lines 634–649 now prefer the default workspace when a project name matches multiple workspaces. If no workspace is is_default, it falls through to the existing ValueError. There's no test covering that fallback path (multiple matches, none is default → error). The happy path (default workspace wins) is tested in test_resolve_workspace_project_identifier_handles_qualified_and_collisions, but the unhappy path isn't.

🟡 create_memory_project JSON response omits external_id

list_memory_projects now surfaces external_id so LLMs can use UUIDs for unambiguous routing. But create_memory_project's JSON output (lines 482–491) doesn't include it:

return {
    "name": new_project.name if new_project else project_name,
    "path": new_project.path if new_project else project_path,
    "is_default": bool(...),
    "created": True,
    "already_exists": False,
    # no external_id here
}

An LLM that just created a project could immediately use its external_id for subsequent calls without a second list_memory_projects() round-trip if it were included.


Checklist

Code Quality & Standards

  • Follows Basic Memory's coding conventions (literate-style comments, decision-point annotations, section headers)
  • Python 3.12+ type annotations and async patterns throughout
  • SQLAlchemy 2.0 best practices (no new DB code in this PR)
  • FastAPI and Typer conventions followed
  • 100-character line length maintained
  • No commented-out code blocks

Testing & Documentation

  • Unit tests for new resolver paths (qualified, cached workspace, failed workspaces, default workspace fallback)
  • Integration tests updated (test_tool_contracts.py, test_tool_project_management.py)
  • Edge cases covered (partial workspace failure, cache invalidation, factory mode)
  • UUID fast path has no test — critical coverage gap (see above)
  • Missing test for multi-match with no default workspace (minor)
  • CLAUDE.md conventions followed (no changes needed)

Basic Memory Architecture

  • MCP tools follow atomic, composable design
  • No schema/DB changes (no migration needed — external_id was already on ProjectItem)
  • Local-first architecture preserved
  • Knowledge graph operations unaffected
  • workspace removal is clean — all 15 tools confirmed updated in contract test

Security & Performance

  • No hardcoded secrets
  • Input validation preserved (UUID parse wrapped in try/except)
  • Error handling correct — UUID parse failure gracefully falls through to name resolution
  • entries_by_external_id lookup is O(1) — no performance concern
  • No sensitive data in logs

Compatibility

  • No new unicode/emoji in console output
  • No OS-specific path operations introduced

Positive Highlights

  • Clean workspace abstraction: Removing workspace from all 15 MCP tool signatures is the right long-term direction. Workspaces are an internal routing detail LLMs shouldn't need to know.
  • UUID fast path is well-structured: The try: UUID(...) / except ValueError: pass pattern is idiomatic and avoids regex.
  • Default-workspace fallback: Much better UX than erroring on ambiguous names — the error case is still preserved when no workspace is marked default.
  • list_memory_projects showing external_id: The format - name (source) [uuid] in text output makes the feature discoverable without breaking existing output parsing.
  • _build_workspace_project_index correctly rebuilds entries_by_external_id on cache deserialization — the serialization path omits the dict but re-derives it from entries, which is correct.

@phernandez phernandez changed the title Resolve projects by external_id, remove workspace from MCP tools fix(mcp): resolve projects by external_id, remove workspace from MCP tools Apr 30, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4915e1373e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/basic_memory/mcp/project_context.py Outdated
Comment on lines +562 to +564
UUID(project)
entry = index.entries_by_external_id.get(project)
if entry:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize UUID before external_id lookup

The new external-id fast path validates project with UUID(project) but then looks up entries_by_external_id using the original string, so valid UUID spellings like uppercase hex, brace-wrapped values, or urn:uuid: forms are accepted as UUIDs but fail lookup and fall through to name/permalink resolution. In those cases a real project external ID can incorrectly return “not found” (or resolve ambiguously by name) even though the UUID is valid. Canonicalizing once (e.g., str(UUID(project))) before dictionary lookup would make UUID handling robust.

Useful? React with 👍 / 👎.

The CLI tool commands still passed workspace= to MCP tool functions
after the workspace parameter was removed from all MCP tool signatures.

Also removes the workspace passthrough test and applies ruff formatting.

Signed-off-by: phernandez <paul@basicmemory.com>

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 30, 2026

Claude finished @phernandez's task in 2m 8s —— View job


PR Review: Resolve projects by external_id, remove workspace from MCP tools

Tasks:

  • Gather context and understand the PR
  • Read updated files (project_context.py, test_project_context.py, project_management.py)
  • Check if previous review issues were addressed
  • Review against team checklist
  • Post final review

Summary

This PR removes workspace from all 15 MCP tool signatures and adds UUID fast-path resolution via external_id. The second commit (4e8a62b) cleans up the remaining places in CLI commands that still passed workspace= after the first commit removed it from the function signatures.

The workspace removal is clean and correct. Two issues remain unaddressed from the prior reviews.


Issues

🔴 UUID fast-path still has no test — 100% coverage requirement unmet

project_context.py:560-567 introduces a UUID resolution path:

try:
    UUID(project)
    entry = index.entries_by_external_id.get(project)
    if entry:
        return entry
except ValueError:
    pass

Every test that exercises resolve_workspace_project_identifier passes non-UUID strings like "personal-project-id" and "acme-project-id" as external_id values (see test_project_context.py:591,595). The UUID() constructor raises ValueError for those strings, so the fast-path if entry: return entry branch at line 564-565 is never reached.

The _project helper default was updated to "11111111-1111-1111-1111-111111111111" (line 60), but none of the tests that call resolve_workspace_project_identifier use it as the identifier argument — they still pass name strings like "meeting-notes".

A test like this is needed:

@pytest.mark.asyncio
async def test_resolve_workspace_project_identifier_by_external_id(monkeypatch):
    import basic_memory.mcp.project_context as project_context
    from basic_memory.mcp.project_context import (
        WorkspaceProjectEntry,
        _build_workspace_project_index,
        resolve_workspace_project_identifier,
    )
    VALID_UUID = "11111111-1111-1111-1111-111111111111"
    personal = _workspace(tenant_id="personal-tenant", workspace_type="personal",
                          name="Personal", role="owner", is_default=True)
    entries = (
        WorkspaceProjectEntry(
            workspace=personal,
            project=_project("My Notes", id=1, external_id=VALID_UUID),
        ),
    )
    index = _build_workspace_project_index((personal,), entries)

    async def fake_index(context=None):
        return index

    monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
    resolved = await resolve_workspace_project_identifier(VALID_UUID)
    assert resolved.project.external_id == VALID_UUID

Fix this →

🟡 UUID not canonicalized before dict lookup — Codex P2 not addressed

project_context.py:563: the input project string is used directly as the dict key after UUID(project) validates it parses, but entries_by_external_id is keyed by entry.project.external_id (set at line 361). If the caller passes a UUID in a non-canonical form (no hyphens, uppercase, brace-wrapped) that differs from the stored form, UUID() accepts it but the dict lookup returns None and the fast-path is silently skipped.

Fix is one line:

# before (line 563)
entry = index.entries_by_external_id.get(project)

# after — normalize to canonical lowercase-hyphenated form
entry = index.entries_by_external_id.get(str(UUID(project)))

Note: this requires moving UUID(project) out of the try block into a variable first:

try:
    canonical = str(UUID(project))
    entry = index.entries_by_external_id.get(canonical)
    if entry:
        return entry
except ValueError:
    pass

Fix this →

🟡 create_memory_project JSON response omits external_id

project_management.py:480-490: the new-project JSON response doesn't include external_id, so an LLM that just created a project can't use UUID routing without a second list_memory_projects() round-trip. The same is true for the already_exists path (lines 459-465). Minor, but inconsistent with the list_memory_projects change that motivated including external_id.


Checklist

Item Status
Follows coding conventions (CLAUDE.md)
Python 3.12+ type annotations and async patterns
SQLAlchemy 2.0 best practices ✅ (no DB changes)
FastAPI and Typer conventions followed
100-character line length
No commented-out code blocks
Unit tests for new functions/methods ❌ UUID fast-path branch is untested
Integration tests for new MCP tools ✅ Contract tests updated
Test coverage for edge cases ✅ Default-workspace fallback tested
100% test coverage maintained ❌ UUID fast-path if entry: return entry not hit by any test
Documentation/docstrings updated
CLAUDE.md updated if conventions change N/A
MCP tools follow atomic, composable design
Database changes include Alembic migrations N/A
Preserves local-first architecture principles
Knowledge graph operations maintain consistency
No hardcoded secrets or credentials
Input validation for MCP tools ✅ UUID parsing in try/except is clean
Proper error handling and logging
No sensitive data in logs
Windows-compatible file path comparisons N/A
No emojis/unicode in console/log output ✅ (unicode in MCP result strings was pre-existing)

What the second commit fixes

4e8a62b correctly removes the remaining workspace= argument passthrough in CLI tool.py commands that survived the first commit — those would have caused TypeError at runtime since the MCP tool signatures no longer accept workspace. Also applies ruff formatting and removes the now-invalid test_get_project_client_rejects_workspace_for_local_project test. This is a necessary cleanup.


Positive Notes

  • Removing workspace from all 15 MCP tool signatures is the right long-term direction — workspaces are an internal routing detail.
  • The try: UUID(project) / except ValueError: pass pattern is idiomatic for UUID detection.
  • Default-workspace fallback on ambiguous names is a significant UX improvement over the previous error.
  • external_id in list_memory_projects output makes UUID routing discoverable in one call.
  • _fetch_cloud_projects removal consolidates the code path correctly.

Main ask before merging: add the UUID fast-path test and canonicalize the UUID before the dict lookup (one-liner fix).

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4e8a62be5e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 237 to 239
response = await search_notes(
project=active_project.name,
workspace=workspace,
query=identifier_text,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve resolved workspace when read fallback calls search

When read_note falls back to _search_candidates, it now calls search_notes with only project=active_project.name. In CLI/direct invocations (context=None), this loses the tenant selection from the original get_project_client resolution; if the same project name exists in multiple workspaces, search_notes re-resolves the name and can select the default workspace (see resolve_workspace_project_identifier ambiguity handling), returning content from the wrong workspace. This is especially reproducible for title-based reads that hit the fallback path after direct entity resolution misses.

Useful? React with 👍 / 👎.

Adds optional `project_id: Optional[str] = None` (UUID) parameter to all 14
project-scoped MCP tools so callers can address projects unambiguously across
cloud workspaces without depending on name resolution.

What changed:
- Canonicalize UUID input via str(UUID(...)) in resolve_workspace_project_identifier
  so uppercase, brace-wrapped, urn:uuid, and unhyphenated forms all match the
  stored external_id (Codex P2)
- Add project_id parameter to: read_note, write_note, search_notes, edit_note,
  delete_note, view_note, read_content, move_note, list_directory, canvas,
  recent_activity, build_context, schema_validate/infer/diff, search_notes_ui,
  read_note_ui. project_id takes precedence over project when both are passed.
- Update get_project_client to route UUID identifiers correctly in pure local
  mode. Without this, get_project_mode(uuid) defaults to CLOUD and fails for
  users without cloud credentials. Fix gates the cloud-by-default branch on
  cloud actually being available, and skips per-project routing for UUIDs in
  Step 4 (local mode routes everything via the same ASGI client; the API
  resolves the UUID via /v2/projects/resolve)
- Wire read_note._search_candidates to pass project_id=active_project.external_id
  to the inner search_notes call so workspace selection from the outer
  get_project_client is preserved across re-resolution. Without this, names
  that collide across workspaces could re-resolve to a different tenant via
  the default-workspace fallback in CLI/context=None paths (Codex P1)
- Switch resolve_project_and_path callers (read_note, read_content, search)
  to pass active_project.name instead of the raw project arg so cache hits
  even when project_id was used or project name was wrong/ambiguous

Documentation:
- AI assistant guide (live MCP resource) explains project vs project_id and
  when to prefer the UUID
- Extended guide adds a dedicated `project` vs `project_id` section
- list_memory_projects docstring points LLMs at external_id for unambiguous
  addressing
- Per-tool project_id docstring is directive ("Prefer this over `project`
  when known") rather than conditional ("Use this when...")

Tests:
- 6 new unit tests in test_project_context.py covering UUID lookup, four
  normalization variants, project_id precedence, and pure-local UUID routing
- 2 new integration tests verify project_id end-to-end through MCP transport
- Tool signature contract test and telemetry tests updated for new parameter

Closes basicmachines-co/basic-memory-cloud#673

Signed-off-by: phernandez <paul@basicmachines.co>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 2026

Claude finished @phernandez's task in 2m 12s —— View job


PR Review: fix(mcp): resolve projects by external_id, remove workspace from MCP tools

Tasks:

  • Gather context and understand the PR
  • Read project_context.py — check UUID canonicalization fix
  • Read test_project_context.py — verify UUID fast-path test added
  • Read project_management.py — check external_id in create response
  • Read read_note.py — verify project_id propagation in _search_candidates
  • Review new MCP tool signature changes (project_id parameter)
  • Evaluate against team checklist
  • Post final review

Summary

The latest commit (25be29e) addresses all 🔴 critical issues raised in the previous review rounds. The PR is substantively correct and ready for merge. One 🟡 minor gap remains.


All prior 🔴 issues resolved ✅

UUID canonicalizationproject_context.py:563-565

canonical_external_id = str(UUID(project))
entry = index.entries_by_external_id.get(canonical_external_id)

Uppercase, brace-wrapped ({...}), urn:uuid:, and unhyphenated forms all normalize to the stored lowercase-hyphenated key before lookup. Correct.

UUID fast-path tests — all four added to test_project_context.py:

  • test_resolve_workspace_project_identifier_resolves_by_external_id (line 669) — UUID picks the exact workspace when the same project name exists in multiple workspaces
  • test_resolve_workspace_project_identifier_normalizes_uuid_forms (line 723) — four alternative UUID spellings all canonicalize correctly
  • test_get_project_client_with_project_id_routes_locally_without_cloud (line 768) — regression: UUID in pure-local mode must not trigger cloud routing
  • test_get_project_client_prefers_project_id_over_project_name (line 815) — project_id takes precedence over project

_search_candidates workspace preservationread_note.py:254-261

response = await search_notes(
    project=active_project.name,
    project_id=active_project.external_id,  # preserves outer workspace selection
    ...
)

The inner search_notes re-resolution now stays tenant-pinned. ✅

view_note propagates project_idview_note.py:59-64: passes project_id=project_id to read_note. ✅


Remaining issue

🟡 create_memory_project JSON response omits external_id (minor, not blocking)

project_management.py:463-470 (already-exists path) and 485-495 (new project path) return structured JSON without external_id. An LLM that just created a project can't immediately use UUID routing without a second list_memory_projects() round-trip.

# already_exists path (line 463)
return {
    "name": existing_match.name,
    "path": existing_match.path,
    "is_default": is_default,
    "created": False,
    "already_exists": True,
    # missing: "external_id": existing_match.external_id
}

# new project path (line 485)
return {
    "name": new_project.name if new_project else project_name,
    ...
    # missing: "external_id": new_project.external_id if new_project else None
}

This is a minor ergonomic gap — not a correctness issue. The feature works correctly via list_memory_projects().

Fix this →


Checklist

Item Status
Follows Basic Memory's coding conventions (CLAUDE.md) ✅ Literate style, decision-point comments present
Python 3.12+ type annotations and async patterns
SQLAlchemy 2.0 best practices ✅ (no DB changes)
FastAPI and Typer conventions followed
100-character line length
No commented-out code blocks
Unit tests for new functions/methods ✅ UUID fast-path fully covered
Integration tests for new MCP tools test_read_note_by_project_id + test_read_note_project_id_takes_precedence_over_name in test-int/
Test coverage for edge cases ✅ UUID normalization variants, local-mode regression, precedence
100% test coverage maintained ✅ UUID fast-path if entry: return entry branch is now exercised
Documentation/docstrings updated get_project_client, resolve_workspace_project_identifier, all 14 tool docstrings updated
CLAUDE.md updated if conventions change N/A
MCP tools follow atomic, composable design
Database changes include Alembic migrations N/A
Preserves local-first architecture principles
Knowledge graph operations maintain consistency
No hardcoded secrets or credentials
Input validation for MCP tools UUID() in try/except
Proper error handling and logging
No sensitive data in logs
Windows-compatible file path comparisons N/A
No emojis/unicode in console/log output

Positive highlights

  • All prior 🔴 blockers addressed in this commit — the PR is now correct end-to-end.
  • The str(UUID(project)) canonicalization one-liner is the minimal correct fix for the case-variant lookup gap.
  • test_resolve_workspace_project_identifier_normalizes_uuid_forms iterates over all four alternative UUID representations in a single parametrized loop — clean test design.
  • The project_id=active_project.external_id fix in _search_candidates correctly solves the workspace-pinning problem without requiring a separate project_id field propagation chain through every fallback call.
  • view_note → read_note → search_notes chain now propagates project_id all the way through.

The create_memory_project external_id gap is the only remaining item and it's cosmetic — happy to merge as-is or include it in a follow-up.

Copy link
Copy Markdown
Contributor

@bm-clawd bm-clawd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against the goals stated in the PR description. LGTM, ship once integration CI is green. This is the right shape for the workspace-ambiguity problem.

What I checked

1. resolve_workspace_project_identifier UUID fast-pathstr(UUID(project)) canonicalizes uppercase, brace-wrapped, urn:uuid:, and unhyphenated forms to the same key as the stored external_id. The entries_by_external_id dict is built once when the index is constructed, so UUID lookups are O(1) and bypass name/permalink resolution entirely. ✅

2. project_id parameter on all 14 project-scoped tools — verified every tool listed in the PR description actually has project_id: Optional[str] = None and threads it into get_project_client(...). Delegating tools (view_note → read_note, ui_sdk → search_notes/read_note) propagate it through. ✅

3. The get_project_client UUID-in-local-mode fix is the load-bearing change. config.get_project_mode(unknown_identifier) defaults to CLOUD because the local config keys by project name. A UUID will always be unknown locally, so without this PR's guard a project_id argument would have falsely routed every local user through the cloud client and errored without credentials. The fix is in two places:

  • Step 3 only enters the cloud routing path when cloud is actually available (factory_mode or explicit_cloud_routing or has_cloud_credentials(config)).
  • Step 4 builds client_kwargs = {} if project_id else {"project_name": resolved_project} so the local ASGI client doesn't try to consult per-project mode for a UUID it can't recognize.
    The regression test test_get_project_client_with_project_id_routes_locally_without_cloud pins this exact failure mode. ✅

4. Internal tool→tool routing preservationread_note._search_candidates passes project_id=active_project.external_id into search_notes. Without that, the inner get_project_client(...) call would re-resolve from a bare name and could land in a different tenant via the default-workspace fallback when context is unavailable (CLI, context=None). This was Codex P1 and the resolution is more thorough than the original suggestion — name-based routing still works for local single-workspace users because the inner call also receives project=active_project.name. ✅

5. Removal of the workspace MCP parameterlist_memory_projects no longer takes workspace, but the merged output now includes external_id for every project. The docstring tells the LLM where to find UUIDs and how to use them. The flow is closed end-to-end: discover via list_memory_projects() → pick UUID → pass as project_id → routed to the exact project regardless of name collisions. ✅

6. Backwards compat — pure local single-workspace users keep working with no changes (still pass project="name", no UUID needed, name-keyed local config still resolves). ✅

Minor things I'd flag (non-blocking)

  1. _search_candidates passes both project=active_project.name and project_id=active_project.external_id. Since project_id takes precedence per the contract, the name is technically redundant. The comment in the code explains that this preserves workspace selection across re-resolution, but it would be slightly cleaner to either drop the name (let UUID alone disambiguate) or add a one-line comment saying "name is defensive — kept for log clarity and any code paths that read project directly." Cosmetic.

  2. get_project_client Step 1 calls resolve_project_parameter(project_identifier=<uuid>, context=context). That passes the UUID through ProjectResolver and _canonicalize_project_name, which loops over config.projects doing permalink matches that will never hit. Cheap, but on the order of tens of microseconds × 14 tools × hot paths. Consider an early try: UUID(project_identifier) branch that skips the resolver entirely when the input is already a canonical external_id — same shape as the fast-path you added in resolve_workspace_project_identifier. Not blocking.

  3. detect_project_from_url_prefix is unaware of project_id. If a caller passes project=None, project_id=<uuid>, identifier="memory://specs/foo", the URL-prefix detector still runs and may set project="specs" from the prefix. project_id then takes precedence in get_project_client so routing is correct, but the resolved active project might end up disagreeing with what the user expected the URL-prefix to mean. Worth a comment in read_note noting that project_id overrides URL prefix detection by design, or short-circuit the prefix detection when project_id is set. Edge case but worth a follow-up.

  4. Validation symmetry: resolve_workspace_project_identifier raises a clear "Project '...' was not found in indexed cloud workspaces" error with the available list. But if a UUID is passed and doesn't match any external_id, the fast-path falls through to _split_qualified_project_identifier and then a permalink lookup that will also fail. The eventual error is "Project '' was not found in indexed cloud workspaces" with a list of names — not great UX for a UUID typo. A targeted "no project with that external_id" error would help, but it's an edge case.

  5. Telemetry: requested_project_id is added to spans. Worth confirming this is accepted by the existing telemetry filters / not flagged as PII (it's not — external_id is an opaque UUID, not the cloud user's tenant_id). Sanity check, not a change request.

Risk assessment

  • Local single-workspace users: zero behavior change. Name-based contract preserved.
  • Cloud single-workspace users: zero behavior change unless they opt into UUIDs. The default-workspace fallback for ambiguous names replaces the old "exists in multiple workspaces" error, which is less surprising for the common case.
  • Cloud multi-workspace users with name collisions: previously errored, now resolvable via project_id. Strictly better.
  • The internal CLI/context=None re-resolution path: previously could pick the wrong tenant on default-workspace fallback. Fixed.
  • Failure mode: passing an unknown UUID errors clearly. Passing a local-mode UUID without cloud routes locally and the API resolves it via /v2/projects/resolve. No silent wrong-tenant routes that I can construct.

Tying back to PR #666

Worth noting: PR #666 fixed the cloud-side data loss when move_note raced its own webhooks. PR #777 closes the upstream ambiguity that causes MCP callers to land in the wrong tenant in the first place. Together they make multi-workspace MCP routing correct end-to-end — caller picks an unambiguous identifier (#777), tenant performs the move without losing data on stale webhooks (#666). Good shipping order.

Approving once the test matrix lands green. Nice work on isolating the UUID-in-local-mode trap before it shipped — that one would have been a confusing customer report.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 25be29eef0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1081 to +1083
cloud_available = factory_mode or explicit_cloud_routing or has_cloud_credentials(config)
if project_id and not cloud_available:
project_mode = ProjectMode.LOCAL
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route UUID project IDs to local mode when project is local

When project_id is provided and cloud credentials exist, this branch leaves project_mode as CLOUD for unknown identifiers (get_project_mode(uuid) defaults to cloud), so local projects addressed by external UUID are misrouted through cloud resolution. That breaks the newly added project_id path for local projects and can also break read_note fallbacks now that _search_candidates forwards active_project.external_id; with a logged-in user, title/text fallback can hit cloud instead of the already-resolved local project and fail or return wrong-tenant data.

Useful? React with 👍 / 👎.

Previously the JSON and text responses from create_memory_project omitted
external_id (UUID), forcing callers that just created a project to make a
second list_memory_projects() round-trip before they could use UUID routing
via the new project_id parameter.

Both code paths now include it:
- already-exists branch: returns existing_match.external_id
- new-project branch: returns status_response.new_project.external_id
- text output: adds "External ID:" line to Project Details

Tests cover:
- already-exists JSON path returns valid external_id string
- create-new JSON path returns valid external_id string
- text output includes "External ID:" line

Signed-off-by: phernandez <paul@basicmachines.co>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 2026

Claude finished @phernandez's task in 1m 36s —— View job


PR Review: fix(mcp): resolve projects by external_id, remove workspace from MCP tools

Tasks:

  • Gather context and understand the PR
  • Review latest commit (6c7d7d5) — external_id in create_memory_project response
  • Verify all previous review issues are resolved
  • Run checklist and post final review

Summary

The latest commit (6c7d7d5) closes the last remaining issue flagged across all prior review rounds. All issues are now resolved — this PR is ready to merge.


Latest commit: 6c7d7d5external_id in create_memory_project

Both code paths in create_memory_project now return external_id:

  • already-exists JSON path (project_management.py:463): "external_id": existing_match.external_id
  • new-project JSON path (project_management.py:488): "external_id": new_project.external_id if new_project else None
  • text output (both paths): • External ID: <uuid> line added to Project Details

Tests added in test_tool_json_output_modes.py cover all three paths:

  • create_text output contains "External ID:"
  • create_json_again["external_id"] (already-exists) is a non-empty string ✅
  • create_json_new["external_id"] (new project) is a non-empty string ✅

All prior issues resolved

Issue Status
🔴 UUID fast-path (try: UUID(project)) has no test ✅ Resolved in commit 25be29e — 4 dedicated tests added
🔴 UUID not canonicalized before dict lookup ✅ Resolved — canonical_external_id = str(UUID(project)) normalizes uppercase/brace/urn forms
🔴 _search_candidates loses workspace selection on re-resolution ✅ Resolved — project_id=active_project.external_id passed to inner search_notes
🟡 create_memory_project JSON omits external_id ✅ Resolved in commit 6c7d7d5 — both code paths now include it

Checklist

Item Status
Follows Basic Memory's coding conventions (CLAUDE.md) ✅ Decision-point comments, section headers, literate style
Python 3.12+ type annotations and async patterns
SQLAlchemy 2.0 best practices ✅ (no DB layer changes)
FastAPI and Typer conventions followed
100-character line length
No commented-out code blocks
Unit tests for new functions/methods ✅ UUID fast-path fully covered (4 tests)
Integration tests for new MCP tools test_read_note_by_project_id + test_read_note_project_id_takes_precedence_over_name in test-int/
Test coverage for edge cases ✅ UUID normalization variants, local-mode regression, precedence, already-exists + new-project create paths
100% test coverage maintained
Documentation/docstrings updated ✅ All 14 tool docstrings, get_project_client, resolve_workspace_project_identifier, assistant guide
CLAUDE.md updated if conventions change N/A
MCP tools follow atomic, composable design
Database changes include Alembic migrations N/A
Preserves local-first architecture principles ✅ Pure-local users: zero behavior change
Knowledge graph operations maintain consistency
No hardcoded secrets or credentials
Input validation for MCP tools UUID() in try/except
Proper error handling and logging
No sensitive data in logs
Windows-compatible file path comparisons N/A
No emojis/unicode in console/log output

Positive highlights

  • Surface-area reduction is clean: removing workspace from all 15 MCP tool signatures is the right long-term direction.
  • UUID fast-path is minimal and idiomatic: try: str(UUID(project)) / except ValueError: pass avoids regex and handles all variant forms.
  • create_memory_project now gives LLMs a complete picture: UUID is immediately available after create — no second round-trip needed.
  • Backward compatible: callers still passing only project (name string) keep working unchanged.
  • Local-mode regression is pinned: test_get_project_client_with_project_id_routes_locally_without_cloud prevents the false-cloud-routing bug from regressing silently.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c7d7d5e2b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 102 to 104
content=content,
directory=folder,
project=project,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add project_id passthrough to CLI tool commands

Removing --workspace from bm tool without adding a project_id alternative leaves these commands unable to disambiguate same-named projects across cloud workspaces. In multi-workspace accounts, project name resolution now falls back to the default workspace, so write-note (and the other wrappers following this pattern) can read/write the wrong tenant with no way for the CLI caller to force the intended project. Expose --project-id and forward it to the MCP tool call.

Useful? React with 👍 / 👎.

Removing --workspace without exposing a --project-id alternative left
`bm tool` commands unable to disambiguate same-named projects across cloud
workspaces. In multi-workspace accounts, name resolution falls back to the
default workspace, so write-note (and the other wrappers) could read/write
the wrong tenant with no override available to the CLI caller.

Adds --project-id (UUID) to all 9 project-scoped CLI tool commands and
forwards it to the underlying MCP tool's project_id parameter:

- write-note, read-note, edit-note
- build-context, recent-activity, search-notes
- schema-validate, schema-infer, schema-diff

Test coverage:
- New test_write_note_project_id_passthrough verifies --project-id reaches
  the MCP tool's project_id kwarg

Closes the P1 finding from Codex review.

Signed-off-by: phernandez <paul@basicmachines.co>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 2026

Claude finished @phernandez's task in 3m 18s —— View job


PR Review: fix(mcp): resolve projects by external_id, remove workspace from MCP tools

Tasks:

  • Gather context and understand the PR
  • Review latest commit (03881f6) — --project-id passthrough to CLI tool commands
  • Review project_context.py — UUID resolution and routing logic
  • Verify previous review issues remain resolved
  • Evaluate against team checklist
  • Post final review

Summary

All 🔴 blockers from prior review rounds are resolved. The latest commit (03881f6) closes the remaining Codex P1 gap by adding --project-id to all 8 CLI tool subcommands. This PR is ready to merge.


Latest commit (03881f6) — CLI --project-id passthrough

All 8 CLI tool subcommands (write-note, read-note, edit-note, build-context, recent-activity, search-notes, schema-validate, schema-infer, schema-diff) now expose --project-id and forward it to their corresponding MCP tool call. The option help text is clear: "Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces."

test_write_note_project_id_passthrough in test_cli_tool_json_output.py verifies the passthrough works. The other 7 commands follow an identical mechanical pattern, so the single representative test is reasonable. The old test_read_note_workspace_passthrough test was correctly removed since --workspace no longer exists.


All prior 🔴 issues confirmed resolved ✅

Issue Status
UUID fast-path has no test ✅ Four dedicated tests added (test_resolve_workspace_project_identifier_resolves_by_external_id, test_resolve_workspace_project_identifier_normalizes_uuid_forms, test_get_project_client_with_project_id_routes_locally_without_cloud, test_get_project_client_prefers_project_id_over_project_name)
UUID not canonicalized before dict lookup canonical_external_id = str(UUID(project)) normalizes uppercase/brace/urn/unhyphenated forms
_search_candidates loses workspace selection on re-resolution project_id=active_project.external_id passed through to inner search_notes
create_memory_project JSON omits external_id ✅ Both already-exists and new-project paths now return external_id; text output also shows • External ID:
CLI commands had no UUID disambiguator after --workspace was removed --project-id added to all 8 bm tool subcommands in this commit

Remaining minor observations (non-blocking)

🟡 Edge case: local project + cloud credentials + project_id UUID

When a user has cloud credentials AND a local-only project, passing project_id=<local_uuid> routes through the cloud branch (because cloud_available = True and the UUID is unknown to the local name-keyed config). The cloud workspace index won't contain a local-only project's UUID, so the call fails. The error message ("Project not found in indexed cloud workspaces") could be confusing for this scenario.

This is an obscure edge case — the typical project_id use case is cross-workspace cloud disambiguation. Local users with no cloud credentials are correctly handled by test_get_project_client_with_project_id_routes_locally_without_cloud. For local projects, project=<name> remains the correct call pattern and continues to work unchanged.

Consider for follow-up →

🟡 entries_by_external_id uses silent last-writer-wins on UUID collision

_build_workspace_project_index line 361: by_external_id[entry.project.external_id] = entry overwrites silently on collision. UUIDs should be globally unique, but a logger.warning on duplicate would surface data-integrity bugs visibly rather than silently (consistent with how ambiguous permalink collisions are handled via setdefault). Minor — not a correctness issue.

🟡 Trailing blank lines in test_tool_telemetry.py

Five test dicts have a trailing blank line where "workspace_id": None, was removed (e.g., lines 66, 111, 159, 212, 264 in the diff). Syntactically fine, cosmetically inconsistent. This was noted in the first review round and remains unaddressed.


Checklist

Item Status
Follows Basic Memory's coding conventions (CLAUDE.md) ✅ Literate style, decision-point comments (Trigger/Why/Outcome), section headers
Python 3.12+ type annotations and async patterns
SQLAlchemy 2.0 best practices ✅ (no DB changes)
FastAPI and Typer conventions followed
100-character line length
No commented-out code blocks
Unit tests for new functions/methods ✅ UUID fast-path fully covered (4 tests); CLI passthrough tested
Integration tests for new MCP tools test_read_note_by_project_id + test_read_note_project_id_takes_precedence_over_name in test-int/
Test coverage for edge cases ✅ UUID normalization variants, local-mode regression, precedence, create paths
100% test coverage maintained
Documentation/docstrings updated ✅ All 14 tool docstrings, get_project_client, resolve_workspace_project_identifier, assistant guide, list_memory_projects docstring points LLMs to external_id
CLAUDE.md updated if conventions change N/A
MCP tools follow atomic, composable design
Database changes include Alembic migrations N/A (no schema changes)
Preserves local-first architecture principles ✅ Pure-local users: zero behavior change
Knowledge graph operations maintain consistency
No hardcoded secrets or credentials
Input validation for MCP tools UUID() in try/except
Proper error handling and logging
No sensitive data in logs ✅ (requested_project_id is an opaque UUID, not PII)
Windows-compatible file path comparisons N/A
No emojis/unicode in console/log output

Positive highlights

  • Complete workspace abstraction: removing workspace from all 15 MCP tool signatures AND all 8 CLI tool subcommands is the right long-term direction — workspaces are internal routing detail.
  • End-to-end UUID routing: list_memory_projects() shows UUIDs → caller picks one → passes as project_id → fast-path lookup resolves unambiguously → workspace-correct API call. The loop is fully closed.
  • Local-mode regression pinned: test_get_project_client_with_project_id_routes_locally_without_cloud prevents the false-cloud-routing bug from regressing silently.
  • Backward compatible: callers still using only project=<name> keep working unchanged.
  • _fetch_cloud_projects removal: consolidates the listing code path, reducing complexity.

@phernandez phernandez merged commit 0b33547 into main May 1, 2026
25 of 26 checks passed
@phernandez phernandez deleted the project-id-resolution branch May 1, 2026 10:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants