fix: Isolate self-hosted Letta MCP tool storage per adapter instance - #610
Open
AlexanderZ-Band wants to merge 4 commits into
Open
fix: Isolate self-hosted Letta MCP tool storage per adapter instance#610AlexanderZ-Band wants to merge 4 commits into
AlexanderZ-Band wants to merge 4 commits into
Conversation
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
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
AlexanderZ-Band
commented
Sep 5, 2026
- 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Self-hosted Letta dedupes MCP-discovered
Toolrows by(name, organization_id). With nouser_idheader set, everyLettaAdapterinstance 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'sband_send_messagetool row'smetadata_.mcp.server_idto 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 againstletta-ai/letta@0.16.8(see INT-985's Investigation section).LettaAdapterinstance and send itsuser_idin 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=Trueagainst Cloud is rejected at config construction — Cloud doesn't expose the admin API this needs).src/band/integrations/letta/orgscope.py: a small raw-httpxclient for Letta's self-hosted-only admin API (/v1/admin/orgs/,/v1/admin/users/— not exposed by theletta_clientSDK), following theopencode/client.pyprecedent.tools.list()) for a freshly-provisioned org inon_started, since a fresh org otherwise has zeroToolrows and the firstagents.create(include_base_tools=True)would raise.LETTA_NO_DEFAULT_ACTOR=trueon the test Letta server so an unresolvableuser_idfails loud instead of silently falling back to the default org (closing a residual gap that would otherwise mask a single-instance scoping bug).Adapter.LETTAexclusion 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, whosecreated_atisnull— Letta's own pagination code silently drops the boundary filter for a null-created_atcursor, 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)uv run pytest tests/ --ignore=tests/integration/ --ignore=tests/e2e/— 5402 passed, 0 faileduv run ruff check . && uv run ruff format --check . && uv run pyrefly check— cleanuv run pytest tests/integration/test_letta_live.py -v -s --no-cov— 4/4 pass, including the newtest_two_instances_stay_isolated_in_shared_org(org/user isolation, MCP tool metadata non-collision, cross-org 404 checks, fresh-org agent creation)lettalane 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, includingtest_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