Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
45b98fd
fix!: bind the room to the Band MCP connection so models never supply…
AlexanderZ-Band Oct 2, 2026
46668eb
refactor: share one transport vocabulary and room-endpoint test helpers
AlexanderZ-Band Oct 2, 2026
1d75ca3
docs: drop stale chat_id and in-process wording from claude_sdk docst…
AlexanderZ-Band Oct 2, 2026
96b34c6
refactor: remove the claude_sdk tool type aliases left unused
AlexanderZ-Band Oct 2, 2026
f1bc6d8
refactor!: drop the unread Band MCP backend kind and name its transpo…
AlexanderZ-Band Oct 2, 2026
120612f
fix(mcp): serve Band MCP tool results as plain JSON text, not a struc…
AlexanderZ-Band Oct 2, 2026
a55acf2
refactor(mcp): tighten the Band MCP backend's public surface
AlexanderZ-Band Oct 2, 2026
934f32a
test(claude_sdk): give the turn-timeout tests a deadline a real MCP r…
AlexanderZ-Band Oct 2, 2026
a5f735e
test(e2e): report why an approval turn never closed
AlexanderZ-Band Oct 2, 2026
33cdc7f
fix: restore MCP tool failure logs and guard chat_id-free prompts
AlexanderZ-Band Oct 2, 2026
c74c318
fix: tighten room-bound kind-mismatch tests and clarify pin docs
AlexanderZ-Band Oct 2, 2026
7b29118
refactor(mcp): make BandMCPTransport a StrEnum so each transport has …
AlexanderZ-Band Oct 2, 2026
7d3a686
Merge origin/main into fix/fix-bind-the-room-server-side-so-models-ne…
AlexanderZ-Band Oct 3, 2026
14ab516
fix: read room-bound MCP rooms from FastMCP Context
AlexanderZ-Band Oct 4, 2026
45107fc
fix: Replace a crashed Band MCP server in every adapter that hosts on…
AlexanderZ-Band Oct 4, 2026
aaa8cbb
merge: resolve main into room-bound MCP branch
AlexanderZ-Band Oct 4, 2026
baa703d
fix: tear down Claude SDK adapters under looptime
AlexanderZ-Band Oct 4, 2026
2eaf455
fix: access looptime teardown helpers via getattr
AlexanderZ-Band Oct 4, 2026
455204b
test: preserve the MCP server clock during Claude fixture cleanup
AlexanderZ-Band Oct 5, 2026
2a2b77d
Merge branch 'main' into fix/fix-bind-the-room-server-side-so-models-…
AlexanderZ-Band Oct 5, 2026
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
12 changes: 12 additions & 0 deletions docs/acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ adapter = ACPClientAdapter(config)
assert adapter.config.command == ("codex-acp",)
```

## Band tools

- **Injected tools are bound to the room.** With `inject_band_tools` on (the
default), the adapter hosts one loopback `LocalMCPServer` and gives each room's
session that room's endpoint (`/rooms/<room>/mcp`, or `/sse`), so the tools take no
`chat_id` and the prompt never states one. A reloaded session gets the same endpoint.
If that server dies, the next message replaces it on a new port, and a room whose
session still dials the old one gets a fresh session with the transcript replayed.
- **An external Band MCP server takes the room as an argument.** With
`inject_band_tools=False` (a remote `band-mcp`), the session's first prompt states
`Current chat_id` for its tools to use.

## Turn delivery

- **Narration is live and ordered.** `ACPCollectingClient` streams finalized chunks to
Expand Down
6 changes: 6 additions & 0 deletions docs/adapters/claude_sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ CLI launch options and chat approvals are nested groups:
- **Two credentials.** `Agent.create(api_key=...)` is the Band key only. Claude
Code authenticates itself (`claude auth login` or `ANTHROPIC_API_KEY`); the
adapter never hands it a key.
- **Band tools are bound to the room.** The adapter hosts one loopback
`LocalMCPServer` and gives each room's session that room's endpoint
(`/rooms/<room>/mcp`), so the tools take no `chat_id` and the prompt never
states one. If that server dies, the next message replaces it on a new port
and each room's session resumes against it on that room's next message; a
turn already in flight loses its Band tools until it ends.
- **Assistant text is never posted.** The adapter only debug-logs it. A reply
reaches the room through the `band_send_message` tool, and a turn that ends
with no successful reply or action tool call is reported to the room as an
Expand Down
4 changes: 3 additions & 1 deletion docs/adapters/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ Four invariants are easy to break and expensive to rediscover:
registrations globally by name. Each agent registers under a name derived from
its Band identity, and every prompt scopes tool visibility to that
registration (deny the shared namespace, then re-allow its own — OpenCode
applies the last matching rule).
applies the last matching rule). If the adapter's Band MCP server dies, the
next turn replaces it on a new port and re-registers it under the same name,
which OpenCode treats as a replacement and reconnects.
- **The model is told the current `chat_id` every turn.** The band MCP tools'
schemas require it, so without the per-turn Room Context block the platform
tools are uncallable.
Expand Down
5 changes: 3 additions & 2 deletions examples/acp/copilot_docker/compose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ and calls Band tools via band-mcp.
approved through ACP). Drop the flag to gate built-in shell/file tools; note
enterprise policy can disable allow-all flags at startup.
- **Room routing.** band-mcp's chat/message tools take a `chat_id` argument per
call (scoped within that one identity) — the same argument name the SDK's
in-process `inject_band_tools` path advertises.
call (scoped within that one identity), so the adapter states the room's
`chat_id` in each session's first prompt. The SDK's `inject_band_tools` path
binds each session to its room's endpoint instead, so its tools take none.
- **Platform base URL.** band-mcp (`BAND_BASE_URL`) defaults to `https://app.band.ai`;
the compose file points it at `BAND_REST_URL` (default `https://app.band.ai`).

Expand Down
9 changes: 5 additions & 4 deletions examples/band_mcp/02_claude_agent_sdk_external.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
No `band-sdk`, no `ClaudeSDKAdapter`, no `Agent.create`: this is what a
Claude Agent SDK user reaches for on their own, wiring Band in exactly like
Claude Desktop or Cursor would via `mcp_config_example.json`. Contrast with
`ClaudeSDKAdapter`, which hands Claude an in-process `LocalMCPServer`
(`mcp_servers={"band": <server object>}`); here Claude spawns `band-mcp` as
its own subprocess (`{"type": "stdio", "command": "band-mcp", ...}`) and the
two processes never share Python state.
`ClaudeSDKAdapter`, which points each room's Claude session at that room's
endpoint on a loopback `LocalMCPServer` it hosts
(`{"type": "http", "url": ".../rooms/<room>/mcp"}`); here Claude spawns
`band-mcp` as its own subprocess (`{"type": "stdio", "command": "band-mcp",
...}`) and the two processes never share Python state.

The point of this example specifically: `--tools memory` gives *any*
external agent script durable, cross-session memory with no shared Python
Expand Down
2 changes: 1 addition & 1 deletion examples/band_mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ task through different clients:
| Example | Consumer | Scope / tools | What it proves |
|---|---|---|---|
| `01_raw_client.py` | plain `mcp` python SDK | `--scope agent` | **Dynamic room composition** — create a room, discover a peer with `band_lookup_peers`, pull them in with `band_add_participant`, message them. No LLM, no framework — the wire contract itself. |
| `02_claude_agent_sdk_external.py` | vanilla `claude_agent_sdk` | `--scope agent --tools memory` | **Durable memory across processes** — one Claude session stores a fact via `band_store_memory`; a second, fully independent session (fresh `band-mcp` subprocess, no shared Python state) recalls it via `band_list_memories`. Contrast with `ClaudeSDKAdapter`, which hands Claude an in-process `LocalMCPServer` instead of spawning `band-mcp` as an external process. |
| `02_claude_agent_sdk_external.py` | vanilla `claude_agent_sdk` | `--scope agent --tools memory` | **Durable memory across processes** — one Claude session stores a fact via `band_store_memory`; a second, fully independent session (fresh `band-mcp` subprocess, no shared Python state) recalls it via `band_list_memories`. Contrast with `ClaudeSDKAdapter`, which points each room's session at that room's endpoint on a loopback `LocalMCPServer` it hosts instead of spawning `band-mcp` as an external process. |
| `03_langgraph_external.py` | LangGraph + `langchain-mcp-adapters` | `--scope human` | **Human-scope personal assistant** — a person's own `BAND_USER_KEY`, no agent identity involved at all: list *my* chats, read the most recent one, summarize it. Shows band-mcp's dual-scope design and works as a generic tool source for a framework with no Band-specific relationship. |

Each script is self-contained (PEP 723 inline metadata) and runs standalone
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ opencode = [
"mcp>=1.28.1,<2",
]
letta = [
"letta-client>=0.1.0",
"letta-client>=1.0.0",
# Floor pinned to crewai's own transitive pin (mcp~=1.28.1) -- see `desktop` above.
"mcp>=1.28.1,<2",
]
Expand Down Expand Up @@ -237,7 +237,7 @@ dev = [
# Include Strands Agents for testing
"strands-agents[openai]>=1.40,<2",
# Include letta-client for testing
"letta-client>=0.1.0",
"letta-client>=1.0.0",
# Include bridge deps for testing
"aiohttp>=3.9,<4",
"python-dotenv>=1.2.2",
Expand Down
150 changes: 76 additions & 74 deletions src/band/adapters/claude_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
HookInput,
HookJSONOutput,
HookMatcher,
McpHttpServerConfig,
McpServerConfig,
PermissionMode,
PermissionResultAllow,
PermissionResultDeny,
Expand Down Expand Up @@ -101,10 +103,14 @@
DedupingAgentTools,
)
from band.integrations.claude_sdk.prompts import generate_claude_sdk_agent_prompt
from band.integrations.claude_sdk.session_manager import ClaudeSessionManager
from band.integrations.mcp.backends import (
BandMCPBackend,
create_band_mcp_backend,
from band.integrations.claude_sdk.session_manager import (
ClaudeSessionManager,
ClaudeSessionManagerStoppedError,
)
from band.integrations.mcp import (
BandMCPBackendSettings,
BandMCPTransport,
SharedBandMCPBackend,
)
from band.runtime.custom_tools import (
CustomToolDef,
Expand All @@ -118,8 +124,8 @@
from band.runtime.formatters import format_tokens, strip_leading_mentions
from band.runtime.tools import (
ALL_TOOL_NAMES,
BAND_MCP_SERVER_NAME,
BASE_TOOL_NAMES,
CHAT_ID_FIELD_NAME,
MAX_INLINE_IMAGE_BYTES,
MCP_TOOL_PREFIX,
MEMORY_TOOL_NAMES,
Expand Down Expand Up @@ -579,10 +585,10 @@ def __init__(
)
self.config = config or ClaudeSDKAdapterConfig()

# Session manager and MCP server (created after start)
# Created in on_started.
self._session_manager: ClaudeSessionManager | None = None
self._mcp_server = None
self._mcp_backend: BandMCPBackend | None = None

self._mcp = SharedBandMCPBackend(self._mcp_settings)

# Per-room tools: the adapter's own sends use them directly, while the
# MCP server's tool calls go through _mcp_room_tools (see _bind_mcp_tools).
Expand Down Expand Up @@ -648,9 +654,8 @@ async def on_started(self, agent_name: str, agent_description: str) -> None:
"""Create MCP server and session manager after agent metadata is fetched."""
await super().on_started(agent_name, agent_description)

# Create MCP server with self (provides tool access via _mcp_room_tools)
self._mcp_backend = await self._create_mcp_backend()
self._mcp_server = self._mcp_backend.server
await self._mcp.reopen()
mcp_backend = await self._mcp.ensure()

# Generate system prompt with agent info
system_prompt = generate_claude_sdk_agent_prompt(
Expand All @@ -669,8 +674,7 @@ async def on_started(self, agent_name: str, agent_description: str) -> None:
model=resolved_model,
fallback_model=self.config.fallback_model,
system_prompt=system_prompt,
mcp_servers={"band": self._mcp_server},
allowed_tools=[*self._mcp_backend.allowed_tools, TOOL_SEARCH],
allowed_tools=[*mcp_backend.allowed_tools, TOOL_SEARCH],
# Same values as the SDK's PermissionMode (pinned by tests/adapters/claude_sdk/test_config.py).
permission_mode=cast("PermissionMode", self.config.permission_mode),
effort=self.config.effort,
Expand Down Expand Up @@ -718,6 +722,7 @@ async def on_started(self, agent_name: str, agent_description: str) -> None:
self._session_manager = ClaudeSessionManager(
sdk_options,
can_use_tool_factory=can_use_tool_factory,
mcp_servers_factory=self._room_mcp_servers,
)

logger.info(
Expand All @@ -730,25 +735,26 @@ async def on_started(self, agent_name: str, agent_description: str) -> None:
self._approval_label,
)

async def _create_mcp_backend(self) -> BandMCPBackend:
"""Create shared MCP backend that uses stored room tools."""
tool_definitions = list(
iter_tool_definitions(capabilities=self.features.capabilities)
)
backend = await create_band_mcp_backend(
kind="sdk",
tool_definitions=tool_definitions,
def _mcp_settings(self) -> BandMCPBackendSettings:
return BandMCPBackendSettings(
tool_definitions=list(
iter_tool_definitions(capabilities=self.features.capabilities)
),
get_tools=self._mcp_room_tools.get,
additional_tools=self._custom_tools,
room_bound=True,
)

logger.info(
"Band MCP SDK server created with %s tools (%s custom)",
len(backend.allowed_tools),
len(self._custom_tools),
)

return backend
def _room_mcp_servers(self, room_id: str) -> dict[str, McpServerConfig]:
Comment thread
AlexanderZ-Band marked this conversation as resolved.
"""A room session's MCP servers: the Band endpoint bound to that room."""
if (backend := self._mcp.current) is None:
raise RuntimeError("Band MCP backend is not started")
return {
BAND_MCP_SERVER_NAME: McpHttpServerConfig(
type="http",
url=backend.endpoint(BandMCPTransport.HTTP, room_id),
)
}

# --- Adapted from BandClaudeSDKAgent._handle_message ---
async def on_message(
Expand All @@ -767,7 +773,6 @@ async def on_message(

- Store tools for MCP server access
- Get or create ClaudeSDKClient for this room
- Include chat_id in the message so Claude can pass it to tools
- Stream response and log events (tools execute via MCP)
"""
logger.debug("Handling message %s in room %s", msg.id, room_id)
Expand Down Expand Up @@ -820,49 +825,28 @@ async def on_message(
)
return

await self._mcp.ensure()

# The manager only resumes when it has to create the client: on
# bootstrap, or after a retired client (see _retire_client).
stored_session_id = (
history.session_id if is_session_bootstrap else None
) or self._session_ids.get(room_id)

# Get or create Claude SDK client for this room (optionally resuming)
try:
client = await self._session_manager.get_or_create_session(
room_id, resume_session_id=stored_session_id
client = await self._open_session(
manager=self._session_manager,
room_id=room_id,
resume_session_id=stored_session_id,
)
except Exception as resume_exc:
if stored_session_id:
logger.warning(
"Room %s: Session resume failed (session_id=%s): %s. "
"Creating new session",
room_id,
stored_session_id,
resume_exc,
)
try:
client = await self._session_manager.get_or_create_session(
room_id, resume_session_id=None
)
except Exception:
logger.exception(
"Room %s: Fresh session creation also failed", room_id
)
await tools.send_failure(
AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE)
)
raise
else:
logger.exception("Room %s: Session creation failed", room_id)
await tools.send_failure(
AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE)
)
raise

# Add chat_id context (Claude needs this for tool calls) -- the label
# must read "chat_id" (the model-facing name everywhere else), not
# the Python-side room_id it's built from.
room_context = f"[{CHAT_ID_FIELD_NAME}: {room_id}]"
except ClaudeSessionManagerStoppedError:
raise
except Exception:
logger.exception("Room %s: Session creation failed", room_id)
await tools.send_failure(
AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE)
)
raise

# Initialize history for this room on first message
if is_session_bootstrap:
Expand Down Expand Up @@ -898,17 +882,15 @@ async def on_message(

# Inject participants message if changed
if participants_msg:
messages_to_send.append(f"{room_context}[System]: {participants_msg}")
messages_to_send.append(f"[System]: {participants_msg}")
logger.info("Room %s: Participants updated", room_id)

# Inject contacts message if present
if contacts_msg:
messages_to_send.append(f"{room_context}[System]: {contacts_msg}")
messages_to_send.append(f"[System]: {contacts_msg}")
logger.info("Room %s: Contacts broadcast received", room_id)

# Add current message with room_id context
user_message = f"{room_context}{msg.format_for_llm()}"
messages_to_send.append(user_message)
messages_to_send.append(msg.format_for_llm())

# Send combined message to Claude
full_message = "\n\n".join(messages_to_send)
Expand Down Expand Up @@ -950,6 +932,29 @@ async def on_message(
if self._turn_release.get(room_id) is release_future:
del self._turn_release[room_id]

@staticmethod
async def _open_session(
manager: ClaudeSessionManager, room_id: str, resume_session_id: str | None
) -> ClaudeSDKClient:
"""The room's client, starting a fresh session when the resume fails."""
try:
return await manager.get_or_create_session(
room_id, resume_session_id=resume_session_id
)
except ClaudeSessionManagerStoppedError:
raise
except Exception as resume_exc:
if not resume_session_id:
raise
logger.warning(
"Room %s: Session resume failed (session_id=%s): %s. "
"Creating new session",
room_id,
resume_session_id,
resume_exc,
)
return await manager.get_or_create_session(room_id, resume_session_id=None)

async def _run_turn(
self,
client: ClaudeSDKClient,
Expand Down Expand Up @@ -1610,10 +1615,7 @@ async def cleanup_all(self) -> None:
await self._cancel_turn(room_id)
if self._session_manager:
await self._session_manager.stop()
if self._mcp_backend:
await self._mcp_backend.stop()
self._mcp_backend = None
self._mcp_server = None
await self._mcp.close(final=True)
self._room_tools.clear()
self._mcp_room_tools.clear()
self._session_context.clear()
Expand All @@ -1632,7 +1634,7 @@ async def cleanup_all(self) -> None:
def _semantic_tool_name(sdk_tool_name: str) -> str:
"""The bare tool name for platform/user-facing records.

claude_sdk exposes band + custom tools through an in-process MCP server, so
claude_sdk exposes band + custom tools through its Band MCP server, so
the Claude Agent SDK namespaces them as ``mcp__band__<tool>``. The platform
``tool_call`` event and the approval UX are cross-adapter, semantic records
where every other adapter uses the bare name, so strip our own server's
Expand Down
Loading
Loading