Skip to content

fix: Isolate self-hosted Letta MCP tool storage per adapter instance - #610

Open
AlexanderZ-Band wants to merge 4 commits into
mainfrom
fix/letta-org-scoped-mcp-isolation-INT-985
Open

fix: Isolate self-hosted Letta MCP tool storage per adapter instance#610
AlexanderZ-Band wants to merge 4 commits into
mainfrom
fix/letta-org-scoped-mcp-isolation-INT-985

Conversation

@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator

Summary

Self-hosted Letta dedupes MCP-discovered Tool rows by (name, organization_id). With no user_id header set, every LettaAdapter instance on a shared self-hosted server resolves to the same default org/actor — so a second instance's MCP registration silently re-points the first instance's band_send_message tool row's metadata_.mcp.server_id to itself. When the first instance's agent later calls that tool, Letta routes the call into the second instance's in-process MCP server, posting the reply under the wrong Band agent's identity. Root-caused and verified live against letta-ai/letta@0.16.8 (see INT-985's Investigation section).

  • Provision a dedicated Letta organization + user per self-hosted LettaAdapter instance and send its user_id in every request (AsyncLetta(default_headers={"user_id": ...})), isolating MCP server + tool storage between instances. Default-on for self-hosted, no-op on Cloud (org_scoped=True against Cloud is rejected at config construction — Cloud doesn't expose the admin API this needs).
  • New src/band/integrations/letta/orgscope.py: a small raw-httpx client for Letta's self-hosted-only admin API (/v1/admin/orgs/, /v1/admin/users/ — not exposed by the letta_client SDK), following the opencode/client.py precedent.
  • Force-seed base tools (tools.list()) for a freshly-provisioned org in on_started, since a fresh org otherwise has zero Tool rows and the first agents.create(include_base_tools=True) would raise.
  • CI: set LETTA_NO_DEFAULT_ACTOR=true on the test Letta server so an unresolvable user_id fails loud instead of silently falling back to the default org (closing a residual gap that would otherwise mask a single-instance scoping bug).
  • Removed the Adapter.LETTA exclusion from the rehydration E2E test — this fix is exactly what that exclusion was waiting on.

Bug found and fixed while live-testing this against a real Letta server: the org/user pagination helper's after-cursor walk stalled forever once it landed on Letta's own seeded default user, whose created_at is null — Letta's own pagination code silently drops the boundary filter for a null-created_at cursor, so the "next page" comes back as the same unfiltered list forever. Fixed with stall detection (treat a non-advancing cursor as exhaustion) plus a permanent regression test.

Test plan

  • uv run pytest tests/adapters/test_letta_orgscope.py tests/adapters/test_letta_mcp.py tests/adapters/test_letta_adapter.py tests/adapters/test_deprecation_shims.py -v — all pass (including 6 new unit/wiring tests + 1 pagination-stall regression test)
  • Full unit suite: uv run pytest tests/ --ignore=tests/integration/ --ignore=tests/e2e/ — 5402 passed, 0 failed
  • uv run ruff check . && uv run ruff format --check . && uv run pyrefly check — clean
  • Live integration tests against a real self-hosted Letta (Docker): uv run pytest tests/integration/test_letta_live.py -v -s --no-cov — 4/4 pass, including the new test_two_instances_stay_isolated_in_shared_org (org/user isolation, MCP tool metadata non-collision, cross-org 404 checks, fresh-org agent creation)
  • E2E baseline letta lane against the live Band platform + LLMs: BAND_E2E_LANE=letta E2E_TESTS_ENABLED=true uv run pytest tests/e2e/baseline/ -v -s --no-cov — 60 passed, 0 failed, including test_partial_reboot_preserves_context_and_peer[letta] (the test this fix was specifically meant to unblock)

🤖 Generated with Claude Code

https://claude.ai/code/session_018j6NRbrMzkT4jTP6dBChsn

Self-hosted Letta dedupes MCP-discovered Tool rows by (name, organization_id).
With no user_id header, every LettaAdapter instance on a shared server resolves
to the same default org, so a second instance's MCP registration silently
re-points the first instance's band_send_message tool row to its own server --
replies then route into the wrong Band agent's identity.

Provision a dedicated Letta organization + user per self-hosted adapter
instance (org_scoped, default-on for self-hosted, no-op/rejected on Cloud) and
send its user_id in every request, closing the collision. Also fixes a live
bug found while testing this against a real server: pagination via the
after-cursor stalls forever once it lands on Letta's own seeded default user,
whose created_at is null and silently breaks the server's boundary filter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018j6NRbrMzkT4jTP6dBChsn
@linear-code

linear-code Bot commented Sep 4, 2026

Copy link
Copy Markdown

INT-985

@AlexanderZ-Band
AlexanderZ-Band requested a review from a team September 4, 2026 17:56
PR #609 correctly classified Letta Cloud via urlsplit(...).hostname
(case-insensitive, trailing-slash-agnostic) but had a whitespace gap.
This branch's pagination/live-test fixes superseded #609 as the base
for INT-985, but its cloud-detection rewrite regressed to a raw
base_url string compare that no longer normalizes host casing,
silently misclassifying a Cloud URL as self-hosted and bypassing the
org_scoped+Cloud construction guard.

Restore is_letta_cloud_url() with a .strip() added to close the
whitespace gap, restore the case/trailing-slash regression tests
dropped when the helper was removed, and add a whitespace case to
both. Also fixes a stale docstring in test_rehydration_partial.py
that still described the Letta exclusion as unresolved after the
code below it already re-enabled Letta in that scenario.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ezy1XrPH9G8wScBdjBLvA
…tion.py

tests/runtime/test_execution.py had a recurring anti-pattern: start
background async work, sleep a fixed duration, then assert on mock
call counts or internal state that depends on that work having
finished. Under CI load the fixed sleep isn't always long enough --
this surfaced as real flakes on PR #610's CI
(test_pending_next_message_present_in_context_still_executes, twice)
and PR #609's CI (test_sync_removes_duplicate_from_ws_queue, a
different commit, same class).

tests/runtime/test_resync.py already had the correct fix pattern
locally: a wait_for_condition(predicate, timeout, interval) poller.
Hoist it into tests/runtime/conftest.py as the shared source of
truth, and convert every settle-then-assert sleep in
test_execution.py (and two in TestExecutionContextEvents/
TestExecutionContextParticipantEvents) to wait on the exact predicate
the following assertion checks -- ctx._sync_complete, ctx.is_processing,
ctx.queue.qsize(), or a mock call/await count.

Left untouched: sleeps that are the actual behavior under test (a
deliberately slow fake handler body, a real elapsed-time threshold
assertion), an asyncio.sleep(0) yield-once idiom, and two sleeps whose
following assertions don't actually depend on the sleep's duration.

Verified with 10 repeated full-file runs, 30 repeated runs of the two
previously-observed-flaky tests, and one run under artificial CPU
contention -- zero failures, and the suite runs faster (~1.4s vs
~4-5s) since polling resolves as soon as the condition is true instead
of always waiting out the fixed sleep.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ezy1XrPH9G8wScBdjBLvA
Comment thread src/band/integrations/letta/orgscope.py Outdated
Comment thread tests/adapters/test_deprecation_shims.py
Comment thread tests/adapters/test_letta_mcp.py Outdated
Comment thread tests/adapters/test_letta_mcp.py Outdated
Comment thread tests/adapters/test_letta_mcp.py Outdated
Comment thread tests/adapters/test_letta_orgscope.py
Comment thread tests/adapters/test_letta_orgscope.py
Comment thread tests/integration/test_letta_live.py
Comment thread tests/runtime/test_execution.py
Comment thread src/band/adapters/letta.py
- Bound _paginated_find's pagination loop with a page-count circuit
  breaker: Letta's admin API has already shown one pagination quirk
  (the null-created_at stall), so a second one hanging forever should
  fail loud instead.
- Split LettaAdapter.on_started's client construction (import, kwargs,
  org-scope resolution, base-tool seeding) into a dedicated
  _build_client helper, leaving on_started to orchestrate prompt/client/
  MCP wiring.
- Extract the org+user httpx_mock provisioning boilerplate, duplicated
  between test_letta_mcp.py and test_letta_orgscope.py, into a shared
  mock_org_user_provisioned helper in lettakit.py.
- Delete test_on_started_two_instances_resolve_distinct_user_ids: it
  mocked away resolve_org_scoped_headers entirely and proved no
  cross-instance state exists to protect against — the real
  collision-avoidance property is already covered by orgscope's
  name-based dedup tests and the live test_two_instances_stay_isolated_in_shared_org.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ezy1XrPH9G8wScBdjBLvA
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.

1 participant