Skip to content

feat: consolidate band-mcp into one SDK-owned MCP engine (INT-1096) - #552

Open
AlexanderZ-Band wants to merge 66 commits into
mainfrom
feat/keep-the-mcp-server-in-the-sdk-repo-not-a-separate-INT-1096
Open

feat: consolidate band-mcp into one SDK-owned MCP engine (INT-1096)#552
AlexanderZ-Band wants to merge 66 commits into
mainfrom
feat/keep-the-mcp-server-in-the-sdk-repo-not-a-separate-INT-1096

Conversation

@AlexanderZ-Band

@AlexanderZ-Band AlexanderZ-Band commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Migrates band-mcp into this repo as a sibling workspace package (packages/band-mcp), and builds one MCP-framework-neutral tool-registration engine consumed by two front doors, per the plan attached to INT-1096 ("Migration plan: one MCP engine (v5)").

All 13 execution steps are now complete, all 20 review threads addressed and resolved. One user-owned action item remains before a band-mcp release can actually ship (see below); the repo-archiving item is done whenever you get to it post-merge.

Progress (one commit per step)

  • 1. Spike: proved the FastMCP embedding mount recipe end-to-end (real client round trip, start/stop/start cycle, DNS-rebinding protection).
  • 2. Workspace scaffold: packages/band-mcp as a uv workspace member. Found and resolved a real crewai/mcp dependency conflict (band-mcp's own lock).
  • 3. Mechanical copy of band-mcp's source into the workspace, verified against the real SDK.
  • 4-5. Test migration (124 unit tests + integration suite) + get-it-green. Found and fixed 2 pre-existing bugs via a live run (band_lookup_peers room-scoping, band_create_chatroom's string return).
  • 6. Dependency refresh (band-client-rest 0.0.27, mcp floor). Found and resolved a second crewai/mcp conflict — this one on the published band-sdk extras.
  • 7. Testing toolkit: FakeHumanTools, wire-schema snapshot test locking in band-mcp 1.3.2's published contract before the engine touches anything.
  • 8. Built src/band/integrations/mcp/engine.py — the one engine, verified via real MCP protocol round trips (SDK in-memory transport).
  • 9. src/band/integrations/mcp/local_server.py (LocalMCPServer) — the embedded front door, rebuilt on the engine. src/band/runtime/mcp_server.py is now a pure re-export shim for backward compat.
  • 10. Rippled the chat_id rename through prompts, claude_sdk, and custom tools — the Python-side variable is still room_id everywhere; only model-facing text (schemas, prompts) says chat_id now, uniformly across both doors.
  • 11. Shrunk band-mcp's CLI package to a thin front door: standalone_spec() (per-tool room classification, SendEventWideInput widening, chat_id pinning) + StandaloneResolver (human singleton, LRU-cached/lock-striped per-room AgentTools). Legacy thnv_* key prefixes dropped per your decision — no compatibility shim.
  • 12. Publishing & CI: --all-packages everywhere in ci.yml, fixed the packaging job's now-broken LocalMCPServer smoke (real start/stop cycle instead), added band-mcp wheel/import/CLI verification steps, new band-mcp-publish.yml (mirrors band-publish.yml's trusted-publishing layout, waits for the declared band-sdk floor to land on PyPI before its install-check), tag-prefix guard on band-publish.yml so a band-mcp release doesn't also fire (and fail) the band-sdk publish job, packages/band-mcp registered as its own release-please component with exclude-paths on the root component, mcp added to pr-title.yml's scopes.
  • 13. Docs: new "MCP Engine" section in AGENTS.md/CLAUDE.md, fixed two now-stale room_id-vs-chat_id mentions.
  • Follow-up cleanup: single-sourced three vocabularies that were still duplicated as bare string literals — CHAT_ID_FIELD_NAME (the model-facing room-id argument name, now referenced by the engine's schema builders and every adapter prompt that mentions it), Surface (replaces ToolDefinition.surface's bare Literal["agent","human"]), and Scope/ToolGroup/Transport (replace band-mcp's own Literal types plus their hand-maintained parallel VALID_SCOPES/VALID_TOOLS lists). Also removed the # type: ignore[arg-type]/cast() escape hatches resolve_config's Mapping[str, object] boundary needed, by giving it a concrete CliArgs TypedDict instead.
  • Follow-up hardening: replaced every multi-line python3 -c "..." snippet in ci.yml/band-mcp-publish.yml with a quoted heredoc (<<'PYEOF') — the old form only worked because YAML strips the block's indentation, and silently allowed shell $/backtick/backslash expansion inside what's meant to be inert Python source.
  • Real bug found and fixed via the Letta lane smoke: build_engine() never told FastMCP the caller's real bind host, so FastMCP always assumed loopback and locked DNS-rebinding protection to 127.0.0.1/localhost only — rejecting a host.docker.internal caller with a 421 even when LocalMCPServer was explicitly bound to 0.0.0.0 for exactly that documented Docker-callback case. Fixed by threading the real host through; added regression tests in test_engine.py/test_local_server.py.
  • Your call, applied: dropped legacy single-key BAND_API_KEY support entirely (breaking change) — _legacy_key_capabilities, Config.legacy_key, the pure-legacy escape hatch in server.py, and the dead Settings.band_api_key field are all gone. Set BAND_USER_KEY/BAND_AGENT_KEY explicitly now.
  • Pre-existing bug found and fixed while touching the above: tests/integration/mcp/ (the live-API integration suite) has been silently uncollectable since step 11 — its conftest.py still imported band_mcp.tools.registrar/band_mcp.shared.build_app_context, both deleted that step. Rewrote the fixtures onto the current engine API and deleted test_forwarding.py (superseded by test_standalone_spec.py/test_engine.py). Verified live against the dev platform: 4 passed, 4 skipped, 2 xfailed (pre-existing, unrelated), 0 failures.
  • Test-suite strengthening pass: swapped mock-overuse (MagicMock/AsyncMock standing in for AgentTools) for the real FakeAgentTools fixture across tests/mcp/test_standalone_spec.py and tests/mcp/test_shared.py, so the participant-refresh/discard-on-failure/mention-error paths exercise real dispatch logic instead of a hand-typed assumption about it. Added a multi-step room-lifecycle test, a custom-tool-alongside-builtin-tools test, and a concurrent-dispatch-across-rooms test to test_engine.py; added a live retry test (test_agent_send_message_retry_after_lookup_peers) to tests/integration/mcp/test_full_workflow.py. Deleted test_engine_mount_spike.py (superseded feasibility spike, docstring said as much).
  • Replaced the wire-schema JSON snapshot (tests/fixtures/wire_schemas/*.json) with tests/mcp/test_wire_contract.py — declarative, hand-written per-tool contracts checked against a real tools/list round trip, instead of an opaque diff against a checked-in blob a reviewer can't meaningfully read.
  • Automated the one manual step the plan called out: added a bump-mcp-sdk-floor job to release.yml (mirrors the existing bump-add-band job's shape) that opens a PR bumping packages/band-mcp/pyproject.toml's band-sdk>= floor to match every future band-sdk release, so this never needs a human to remember it again.

Decisions made along the way

  • Legacy thnv_* key prefixes: the plan's one open decision (keep vs. drop). User decided: drop — no compatibility retained. Applied in step 11.
  • Two real dependency conflicts between crewai's transitive mcp pin and this migration's mcp floors, resolved by capping at mcp>=1.28.1,<2 instead of >=1.29.0,<2 everywhere — confirmed with the user both times.
  • SendEventWideInput (the CLI door's widened event-type input) is an independent model, not a subclass of the SDK's SendEventInput — pyrefly correctly flagged that widening a mutable Pydantic field's type via subclassing is unsound.
  • The embedded door's OpenCode packaging smoke now does a real async with LocalMCPServer(...) start/stop cycle (the old _build_server() it called no longer exists on the new class) — this exercises the actual FastMCP-mount + uvicorn serve/shutdown recipe against a freshly resolved mcp, which is the failure mode that step originally existed to catch.

User-owned action items

  1. PyPI trusted-publisher binding for band-mcpdone. band-mcp-publish.yml is registered as a trusted publisher on the band-mcp PyPI project (environment release), same as band-publish.yml is for band-sdk. The project's old trusted-publisher entry (from the standalone band-mcp repo) is intentionally left in place until the new one is confirmed working, then should be removed.
  2. Archiving the old band-mcp repo — still open. Once this PR merges and a band-mcp-v* release ships from here, the standalone band-mcp repo should be archived (or at minimum READMEd to point here) so it doesn't keep accepting PRs against dead code.
  3. Bump packages/band-mcp/pyproject.toml's band-sdk floor before tagging the first band-mcp-v* release — now automated end to end, but not yet correctable. It's currently >=1.6.0 — a real, already-published version, but one that predates this PR and lacks src/band/integrations/mcp/engine.py (confirmed live: band-sdk>=1.6.0 + band-client-rest==0.0.27 is unsatisfiable today, since published band-sdk==1.6.0 pins band-client-rest==0.0.10). This is a deliberate placeholder — the real floor is the band-sdk version this PR's merge actually ships as, which isn't known until release-please cuts that release. Full automated sequence: band-sdk releases → the new bump-mcp-sdk-floor job (release.yml) opens a fix:-typed PR correcting the floor (typed fix:, not chore:, specifically so merging it also triggers release-please's own version bump for the packages/band-mcp component — a chore: would've merged silently and left pyproject.toml's version stuck, which band-mcp-publish.yml's tag-must-match-version check would then reject) → merge that PR → release-please opens band-mcp's own version-bump release PR → merge that → tag band-mcp-v*. band-mcp-publish.yml's build job also fails loudly if any of this is skipped (its install-check step hits the exact resolver conflict above before anything reaches PyPI), so there's no risk of a silently-broken publish either way.

Test plan

  • Full unit suite green at every step (4801 passed, 116 skipped pre-existing, at HEAD — up from 4578 at the start).
  • ruff check, ruff format --check, pyrefly check clean at every step.
  • Real MCP protocol round trips (SDK in-memory transport) for the engine and the CLI's standalone_spec, no mocking of engine internals.
  • Subprocess-level contract tests (tests/mcp/test_cli_contract.py) against the real band-mcp CLI — stdio stdout purity, --version/--help, published tool names, the health_check title-leak regression.
  • Live integration run against a dev Band environment for the migrated band-mcp integration suite.
  • Live smoke: standalone band-mcp over real stdio against a real dev-environment Band room — initializetools/list (all 8 tools incl. health_check) → health_check OK → band_create_chatroomband_get_participantsband_lookup_peers, all real HTTP round trips, all succeeded.
  • Live smoke: claude_sdk core lane — 27 passed, 1 skipped, 1 rerun (transient timing flake, not a regression), 0 failures, against real Anthropic API + real Band rooms. Covers the exact code this migration touched (chat_id/room_id handling in claude_sdk/tools.py/prompts.py, extend_with_chat_id in the rebuilt engine).
  • Live smoke: Letta lane — ran, found and fixed the DNS-rebinding bind-host bug noted above.
  • All GitHub checks green on this PR (lint, packaging, CodeQL, full test matrix incl. crewai/parlant on ubuntu+windows × 3.11/3.12).
  • e2e backends lane (codex/opencode/copilot_acp) — reported complete in an earlier pass of this work; worth one final confirmation run before merge rather than relying on that memory.

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

@linear-code

linear-code Bot commented Aug 18, 2026

Copy link
Copy Markdown

INT-1096

@AlexanderZ-Band
AlexanderZ-Band marked this pull request as ready for review August 18, 2026 08:18
@AlexanderZ-Band
AlexanderZ-Band force-pushed the feat/keep-the-mcp-server-in-the-sdk-repo-not-a-separate-INT-1096 branch from 4841a13 to 4bff619 Compare August 18, 2026 12:16
AlexanderZ-Band and others added 24 commits August 18, 2026 16:04
Gates the INT-1096 migration plan's design: proves end-to-end that a bare
FastMCP instance's sse_app() and streamable_http_app() routes can be mounted
onto a custom-managed Starlette app (the shape LocalMCPServer already uses),
with the host lifespan entering session_manager.run() itself since a mounted
sub-app's own lifespan is never invoked by the ASGI server. Also proves the
start->stop->start rebuild requirement (session managers are single-use) and
that FastMCP's auto-enabled DNS-rebinding protection on loopback binds
accepts real MCP clients while still rejecting a spoofed Host header.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Adds [tool.uv.workspace] with packages/* members, and a placeholder
packages/band-mcp package (pyproject.toml, minimal __init__.py/server.py,
README.md stub). The real source lands in step 3's mechanical copy.

band-mcp's mcp[cli] floor is pinned to >=1.28.1,<2, not the latest 1.x:
it's an unconditional workspace member (not an extra), so it can't be
forked away from the dev-crewai extra the way [tool.uv] conflicts already
forks crewai from parlant/pydantic-ai -- and crewai 1.15.x unconditionally
pins mcp~=1.28.1. A tighter floor here made the shared uv.lock unsolvable
whenever crewai is in the graph. A standalone `pip install band-mcp` is
unaffected, since nothing else constrains it there.

Verified: uv lock resolves; uv sync --all-packages with --extra dev,
--extra dev-crewai, and --extra dev-parlant all succeed (dev-crewai's
runtime crewai import crash under Python 3.14 is pre-existing on main,
unrelated to this change); full unit suite green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
…1096)

Copies src/band_mcp/* (config.py, server.py, shared.py, tools/registrar.py),
README.md, and mcp_config_example.json from the standalone band-mcp repo
unchanged, replacing step 2's placeholders. Only dependency metadata (step 2)
and the new package's pins differ from the old repo -- this is code+docs.

One type-annotation-only fix beyond the copy: config.py's resolve_config()
called Mapping.get() twice per credential slot (once in an isinstance check,
once in the guarded value), relying on a mypy-specific `# type: ignore[arg-type]`
that this repo's pyrefly doesn't recognize (different error code). Rewrote
each as a single `.get()` into a local variable so the isinstance narrowing
is real and the ignore comment is no longer needed -- no behavior change
(verified: resolve_config()/validate() produce identical results before and
after).

Verified end-to-end against the real band-sdk in the workspace: register_tools()
against a live band_mcp.shared.mcp FastMCP instance advertises the same 7
agent tools as published band-mcp 1.3.2. Full unit suite green; ruff/pyrefly
clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
… (INT-1096)

Steps 4-5 of the migration plan (test migration + get-it-green), landed
together: the fixes needed to run under this repo's environment are baked
into the same files as the copy, so a clean "raw copy, zero changes" commit
wasn't possible without redoing the work.

Moved, unchanged:
- tests/unit/{test_shared,test_registrar,test_server,test_config}.py,
  tests/test_transport_security.py, tests/conftest.py -> tests/mcp/
  (124 tests, all pass unchanged against the packages/band-mcp source
  copied in the previous commit).
- tests/integration/test_forwarding.py -> tests/integration/mcp/
  (in-process, fully mocked -- no live API).

Moved with a real naming collision resolved: band-mcp's top-level
tests/conftest_integration.py would have collided with this repo's own
unrelated tests/conftest_integration.py (SDK-wide live fixtures, different
shape). Inlined its content directly into tests/integration/mcp/conftest.py
instead, dropping the old repo's separate re-export shim; fixed
test_full_workflow.py's now-dangling import.

Mechanical fixes to actually run here (Python 3.14 / this repo's
pytest-asyncio config, not behavior changes):
- agent_room fixture used the deprecated `asyncio.get_event_loop()` pattern,
  which raises under Python 3.14 outside a running loop -- converted to a
  plain async fixture.
- That conversion needs `@pytest.mark.asyncio(loop_scope="session")` on the
  three tests consuming `agent_room`, matching this repo's own
  asyncio_default_fixture_loop_scope="session" convention (already used
  elsewhere under tests/integration/) -- otherwise the AppContext's
  asyncio.Lock, first touched inside the fixture's loop, raises "bound to a
  different event loop" when the test body reuses it.
- _extract_id() only handled dict-shaped tool results; band_create_chatroom
  returns a bare room-id string (AgentTools.create_chatroom() -> str), so
  agent_room was silently skip()ing on every real run. Fixed to handle both.

Two real pre-existing bugs found by actually running this live suite
(reproduce identically against the unmodified old registrar.py + unmodified
old test -- not introduced by this migration, and this suite is excluded
from CI, gated behind `requires_api`/BAND_API_KEY):
- test_agent_lookup_peers_returns_list assumed band_lookup_peers is
  room-less; it isn't (AgentTools is constructor-scoped per room, and
  lookup_peers() filters to peers not already in that room) -- fixed by
  passing chat_id via the now-working agent_room fixture.
- test_agent_create_room_send_and_read_back and
  test_agent_send_message_accepts_room_id_alias call band_send_message on a
  freshly created room with no other participant to mention, but
  band_send_message requires a non-empty `mentions` list. Not a mechanical
  fix -- marked xfail with a reason citing the real cause; left for the
  engine work to resolve deliberately rather than guessing a design choice.

Verified: tests/mcp/ 124 passed; tests/integration/mcp/ 15 passed, 4 skipped,
2 xfailed, 0 failed (run live against platform.dev.band.ai, a dev
environment, using the .env.test legacy BAND_API_KEY the SDK's own
pre-existing integration tests already auto-load); full unit suite 4702
passed (was 4578); ruff/ruff format/pyrefly clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Step 6: dependency refresh, within the mcp<2 cap.

- band-client-rest 0.0.26 -> 0.0.27. Diffed the two wheels: the only change
  is the User-Agent/X-Fern-SDK-Version header literal -- no workaround
  needed, no adaptation required.
- desktop/opencode/letta/acp extras' mcp floor: >=1.25.0,<2 -> >=1.28.1,<2
  (also applied to packages/band-mcp, landed in an earlier commit).

Not >=1.29.0 as the plan's own text suggested: bumping band-sdk's own
published extras to >=1.29.0 makes `pip install band-sdk[crewai,acp]` (or
any crewai + desktop/opencode/letta/acp combo) genuinely unsolvable for
real downstream installs, not just this repo's dev lock -- crewai 1.15.x
(latest as of 2026-08-18) unconditionally pins mcp~=1.28.1. >=1.28.1 is the
newest floor that keeps every extra combination installable; it's still
within the load-bearing <2 cap (mcp 2.0.0 dropped the lowlevel Server
decorator registration these extras build on).

Used `uv lock --upgrade-package mcp --upgrade-package band-client-rest`
rather than a blanket `uv lock --upgrade`: the latter also bumped dev-only
tooling (ruff 0.15->0.16, pyrefly 0.61->1.2) and surfaced ~1348 pre-existing
lint findings across the whole repo, unrelated to this PR's scope.

band-testing-python==0.1.4 already latest -- no change needed.

Verified: uv lock resolves (two mcp forks: 1.28.1 for the crewai fork,
1.29.0 elsewhere); dev/dev-crewai/dev-parlant syncs all succeed; full unit
suite green (4702 passed); ruff/ruff format/pyrefly clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Step 7: the fixtures-first toolkit the plan wants engine.py written against
(step 8), built as far as possible before engine.py/local_server.py exist.

- FakeHumanTools (tests/mcp/conftest.py): behavioral fake for the human
  surface, in FakeAgentTools' style -- observable state, not a MagicMock.
  Covers every HumanTools method. Test-local for now; promote to
  band.testing only if a second consumer appears. Proven against the real
  registrar + a real FastMCP dispatch (tests/mcp/test_fake_human_tools.py),
  not just type-checked.
- advertised_schemas(session): projects a real list_tools() round trip into
  a snapshot-comparable dict.
- Wire-schema snapshot test (tests/mcp/test_wire_schema_snapshot.py):
  real in-memory-transport protocol round trip against band-mcp's current
  registrar, diffed against checked-in JSON (tests/fixtures/wire_schemas/
  {full,pinned}.json, generated now). Locks in the published 1.3.2 contract
  before the engine consolidation (steps 8-9) touches anything -- any
  accidental wire change during that work fails loudly here.

Deliberately deferred, not dropped: the engine_session / local_server_session
fixtures the plan also lists under step 7 need EngineSpec/build_engine
(step 8) and local_server.py (step 9) to exist -- writing them now would
just be dead code against nothing. They land with the step that makes them
real. Same for the CLI subprocess contract tests (--help/--version/stdio
purity/etc.): only meaningful once the CLI is testing the actual
consolidated engine, so they land with steps 11-12.

Verified: 132 tests pass in tests/mcp/ (was 124); full unit suite 4710
passed (was 4702); ruff/ruff format/pyrefly clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Step 8: src/band/integrations/mcp/engine.py -- collapses band-mcp's
FastMCP-based registrar and LocalMCPServer's hand-rolled lowlevel-Server
registration into one engine, consumed by two front-door factories
(packages/band-mcp's standalone_spec, step 11; local_server.py's
embedded_spec, step 9). Neither front door exists yet -- this is the shared
core they'll both call.

Design realized concretely from the plan's decisions:

- EngineSpec/MCPToolRegistration/CustomToolSpec/ToolsResolver are themselves
  framework-neutral (no mcp-package type in their own fields), even though
  engine.py itself is an allowlisted mcp-import module -- so INT-1150's v2
  migration only touches this module's FastMCP-translation internals.
- ToolsResolver has exactly one method (invoke()); EmbeddedResolver is the
  SDK-owned implementation (calls adapter-owned tools via a room-lookup
  callback, no cache/lock -- row 11). StandaloneResolver (CLI-side locking/
  caching/participant-refresh) lands with the CLI factory in step 11.
- Room-field injection unifies onto one mechanism for both doors:
  extend_with_chat_id()/pin_existing_chat_id() build a real extended
  Pydantic model (chat_id + AliasChoices("chat_id","room_id"), hidden via
  SkipJsonSchema when pinned) -- replacing LocalMCPServer's old hand-rolled
  schema-dict surgery. FastMCP has no API to hand a tool an explicit schema
  override; it only derives one from a real function's signature, which is
  why this goes through a model, not a dict.
- Dropped the ctx/AppContext/lifespan threading entirely: resolvers are
  self-contained objects a factory captures directly in each registration's
  execute closure, so build_engine()'s dynamic handlers need no Context
  parameter at all -- a real simplification (declarative-first, per the
  plan's decisions log), not just a port.
- SendEventWideInput (row 6) is a deliberate independent model, not a
  SendEventInput subclass: pyrefly correctly flagged that widening a
  mutable Pydantic field's type via subclassing is unsound (Liskov). Its
  Literal is WideEventMessageType, added to band.core.types next to the
  existing EventMessageType -- same single-source-of-truth pattern already
  used there, just widened.
- _serialize() (row 15) is the CLI's str-passthrough shape, now universal
  for both doors -- flagged in this commit as the one intentional,
  LLM-visible behavior change for embedded consumers (was a {"result": x}
  dict-wrap), to be verified by the e2e backends lane per the plan.
- validate_unique_tool_names() is the one engine-level duplicate check
  (row 8), covering every surface and custom tools together.
- AGENT_ROOM_BOUND_TOOL_NAMES + classify_room_binding() land in
  runtime/tools.py next to ROOM_POSTING_TOOL_NAMES/is_room_posting_tool
  (tool-schema concerns, framework-neutral) -- the CLI factory's
  classifier; the embedded factory doesn't call it, since its uniform wrap
  applies to every agent tool regardless (row 2). Also fixed the stale
  tools.py comment referencing band-mcp as an external repo path.
- Added the MCP-import-boundary test now (INT-1150's acceptance criterion,
  made executable early): scans src/band and packages/band-mcp/src for
  module-level mcp-package imports outside an explicit allowlist.

Verified end-to-end via real MCP protocol round trips (SDK in-memory
transport, mcp.shared.memory.create_connected_server_and_client_session) --
not just unit-tested in isolation: embedded uniform-wrap dispatch, pin
overriding a client-sent chat_id, send_message error enrichment with
available handles, room_id alias routing, human room-bound pinned/unpinned
dispatch, custom tools (both CustomToolSpec and the bare tuple contract).

Full unit suite 4727 passed (was 4710); ruff/ruff format/pyrefly clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Step 9: src/band/integrations/mcp/local_server.py replaces
runtime/mcp_server.py -- keeps LocalMCPServer's hard-won lifecycle shell
(port scanning, EmbeddedUvicornServer's signal-capture-disabling, bounded
graceful shutdown) verbatim, replaces its internals (raw lowlevel Server
with hand-rolled list_tools/call_tool) with mounting engine.py's FastMCP app
via the step-1 spike's proven recipe.

Two lifecycle bugs fixed, not ported:
- stop() used to skip socket close and state reset when the serve task
  crashed with anything but CancelledError (the bare `await
  self._serve_task` re-raised past the cleanup code below it). Fixed with
  cleanup in `finally`.
- start()/stop() had no concurrency guard. Fixed with one asyncio.Lock
  serializing every lifecycle transition (start() keeps its existing
  idempotent no-op-if-already-running check, now race-free).
- Gained __aenter__/__aexit__; start()/stop() remain as the escape hatch for
  non-lexical lifetimes (acp/client_adapter.py holds its server across
  method scopes) -- the context manager's own halves, not a second path.

build_band_mcp_tool_registrations()/build_resolved_band_mcp_tool_registrations()
now build on engine.py's shared build_tool_registration() + EmbeddedResolver
instead of hand-rolling -- same public signatures (band-sdk is published
API), different internals. Both now advertise "chat_id" (not "room_id") on
every registration, per the uniform wrap the embedded door has always used
(divergence-matrix row 2) -- the actual field-name rename is intentional and
happens here, not step 10 (which ripples the *prompt text* teaching models
the old name, a separate concern from the schema itself).

Rewired every caller per the plan's blast-radius table: backends.py,
letta/config.py, letta/mcp.py, acp/client_adapter.py (the one direct
LocalMCPServer import, not just the stable create_band_mcp_backend API).
runtime/mcp_server.py becomes a pure re-export shim (band-sdk is published,
so the module move alone would break an external `band.runtime.mcp_server`
import) -- dropped from the import-boundary allowlist since it no longer
imports the mcp package itself.

Migrated tests/runtime/test_mcp_server.py -> tests/integrations/mcp/test_local_server.py
(import paths, logger-name string, MCPToolRegistration.to_mcp_tool() ->
input_model.model_json_schema() since that method no longer exists -- the
engine derives schemas from a real function signature now, not a hand-built
Tool). Fixed two tests whose hand-built MCPToolRegistration returned a raw
dict: the dynamic handler build_engine() creates always declares -> str
(row 15, universal now), so FastMCP's structured-output validation now
rejects a non-string return that the old lowlevel-Server path never checked.
Same fix applied to test_e2e_codex_acp.py's equivalent (gated, not run
here). Added tests for both fixed lifecycle bugs directly, plus a real
start/stop/start cycle against a live server.

Verified: 1166 passed across tests/mcp, tests/integrations/mcp,
tests/runtime, tests/integrations/test_mcp_backends.py,
tests/adapters/test_letta_mcp.py, tests/integrations/acp (8 skipped --
codex-acp E2E, opt-in). Full unit suite 4730 passed (was 4727);
ruff/ruff format/pyrefly clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
…-1096)

Step 10: the embedded door's advertised schema field renamed room_id ->
chat_id in step 9 (build_resolved_band_mcp_tool_registrations's uniform
wrap) -- this updates what every embedded consumer *tells the model* to
match, so the model doesn't keep reaching for a field name the schema no
longer has. All 7 sites the plan enumerated, plus their test assertions:

1. opencode/adapter.py: per-turn "Current room_id" system block ->
   "Current chat_id".
2. integrations/letta/prompts.py: "Every tool call REQUIRES a `room_id`
   argument" -> `chat_id`.
3. adapters/letta.py: self-host/shared-mode rejoin prompt -> `chat_id`.
4. acp/client_adapter.py: room context "Current room_id" -> "Current
   chat_id".
5. integrations/claude_sdk/prompts.py: the full tool-call JSON example
   block (every "room_id" key and the [room_id: ...] message-format
   example) -> chat_id, throughout.
6. adapters/claude_sdk.py: the per-message room_context marker itself,
   `f"[room_id: {room_id}]"` -> `f"[chat_id: {room_id}]"` (the Python
   variable name stays room_id -- only the model-facing marker text and
   its schema field name change).
7. runtime/custom_tools.py: custom_tool_to_mcp_schema's include_room_id
   param and injected key -> include_chat_id / "chat_id". Confirmed this
   function has zero callers anywhere in the repo -- renaming outright
   rather than adding a compat alias for a function nothing calls.

One additional site the plan's table pointed at but didn't fully resolve:
integrations/claude_sdk/tools.py's _build_sdk_schema hand-spliced a
"room_id" key into a schema dict (the "sdk" in-process backend kind, C in
the plan's usage-impact table). Switched it to call the engine's
extend_with_chat_id() directly -- same canonical field-injection helper
every other embedded consumer now uses, instead of a fourth hand-rolled
copy of "splice a room field into a schema". Its own dispatch (room_id/
chat_id key extraction in the two tool handlers) updated to match.

Checked and left alone as genuinely out of scope (verified, not assumed):
copilot_sdk.py's own `[room_id: ...]` marker (doesn't use LocalMCPServer or
backends.py at all -- an unrelated mechanism); crewai's tools.py and
runtime/oneshot.py's "room_id" mentions (CrewAI's own separate tool-wrapping
layer, and the bridge's HTTP forwarding envelope, respectively -- neither
touches band-mcp/engine.py).

Deferred to step 13 as the plan specifies: the
examples/acp/copilot_docker/compose/README.md paragraph explaining the old
chat_id-vs-room_id split (now false) gets rewritten with the rest of the
docs pass.

Full unit suite 4730 passed (text/assertion changes only, no behavior
change in test count); ruff/ruff format/pyrefly clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
…096)

Step 11: packages/band-mcp/tools/registrar.py deleted entirely (fully
absorbed into engine.py, per the plan). server.py/shared.py/config.py
rewritten to build an EngineSpec (standalone_spec) and hand it to the shared
engine, instead of hand-registering tools via FastMCP's own decorator API.

Legacy thnv_* key prefixes dropped (user decision, INT-1096): config.py's
_legacy_key_capabilities() now only recognizes band_u_/band_a_/band_ --
a surviving thnv_* key serves neither scope, same as any other unrecognized
key. Locked in with a new test; every other test's thnv_* fixture values
swapped for band_* equivalents (same intent, still-recognized prefix).

Design change beyond a mechanical port: dropped the AppContext/FastMCP-
Context/lifespan threading entirely. The old registrar needed it because
free functions (get_agent_tools, get_human_tools, ...) had no other way to
reach per-room state from inside a dynamically-built handler. The new
StandaloneResolver (shared.py) is the concrete ToolsResolver for this door:
a self-contained object built once, synchronously, before the engine exists
(REST client construction does no I/O, so there's nothing to defer to a
lifespan). standalone_spec(config, resolver) then builds the CLI's
EngineSpec: per-tool room classification (classify_room_binding, unlike the
embedded door's uniform wrap), band_send_event widened to SendEventWideInput,
extend_with_chat_id/pin_existing_chat_id per tool. health_check is a
module-level function taking the resolver explicitly (testable in
isolation), wrapped by a thin zero-arg closure at registration time.

Every divergence-matrix row this door owns, carried forward in
StandaloneResolver: per-room AgentTools LRU cache (128) + 64 lock stripes
(row 11), room-less None-key/""-sentinel (row 24), send_message pre-flight
participant refresh + discard-on-failure (row 9), the CLI's str-shaped
result serialization (row 15, via the engine's now-universal _serialize()).

Caught by a real subprocess smoke test, not by any unit test: health_check
is registered by run() itself, outside standalone_spec, so the wire-schema
snapshot never covered it -- a wrapper function named _health_check_tool
leaked into the advertised schema's auto-derived "title" field (FastMCP
derives it from the function's own __name__, independent of the tool()
name= override). Fixed by naming the wrapper health_check directly, and
added tests/mcp/test_cli_contract.py (real `python -m band_mcp.server`
subprocess: --version, --help, missing-credential exit 2, stdio stdout
purity, and this exact regression) to close the gap going forward. A fuller
subprocess contract battery (per-config schema/validation-text parity)
remains step 12's job, per the plan.

Test suite changes:
- Deleted tests/mcp/test_registrar.py (tested the now-deleted module).
- Rewrote tests/mcp/test_shared.py against StandaloneResolver (same
  invariants: LRU eviction, lock stripes, room-less sentinel, refresh/
  discard, human dispatch). Dropped SDK-import-failure tests outright (row
  21: AgentTools/HumanTools import unconditionally now -- band-sdk is this
  same package, no failure mode left to test).
- Added tests/mcp/test_standalone_spec.py: the CLI-door integration
  coverage (scope/tools filtering, duplicate-name ConfigError, per-tool
  room classification, pinning) that test_registrar.py used to carry,
  ported to the new factory -- a real gap the raw pass-count would have
  hidden otherwise.
- Fixed tests/mcp/test_fake_human_tools.py and test_wire_schema_snapshot.py
  (both built FastMCP via the now-deleted register_tools) to build via
  standalone_spec + build_engine instead -- the wire-schema-snapshot fix is
  the real proof this step preserves the published contract: it caught one
  genuine bug (band_send_event's widened model carried a rewritten
  docstring mentioning tool_call/tool_result, not the original SendEventInput
  docstring the old registrar's model.__doc__ = original.__doc__ preserved)
  before this commit, fixed by reusing SendEventInput.__doc__ verbatim.
- Fixed tests/mcp/test_transport_security.py's two integration tests (built
  the engine fresh via standalone_spec + build_engine instead of importing
  a now-nonexistent module-level FastMCP singleton).

Verified: real `band-mcp --version`/`--help` subprocess output unchanged;
real stdio initialize+tools/list round trip (7 agent tools + health_check,
stdout pure JSON-RPC, chat_id schema correct); wire-schema snapshot passes
(byte-for-byte published contract preserved for the "full" and "pinned"
CLI profiles). Full unit suite 4723 passed; ruff/ruff format/pyrefly clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Adds --all-packages to every uv invocation in ci.yml so the workspace
member is installed and checked (lint, test, test-crewai, test-parlant),
replaces the packaging job's now-broken LocalMCPServer._build_server()
smoke with a real start/stop cycle against the new engine, and adds
dedicated band-mcp wheel-build + import + CLI verification steps.

Adds band-mcp-publish.yml mirroring band-publish.yml's trusted-publishing
layout, with a bounded wait for band-mcp's declared band-sdk floor to be
indexed on PyPI before the install-check (two-phase release: band-sdk
ships the engine first, then band-mcp's floor is bumped to match).
Gives band-publish.yml's build job a matching tag-prefix guard so a
band-mcp release doesn't also run (and fail) the band-sdk publish job.

Registers packages/band-mcp as its own release-please component
(package-relative changelog/extra-files, exclude-paths on the root
component so band-mcp-only commits don't bump band-sdk), and adds the
tools/auth/transport PR-title scopes band-mcp's old repo used.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Adds an AGENTS.md/CLAUDE.md section covering the one-engine/two-front-door
design (packages/band-mcp CLI + the embedded LocalMCPServer), the
EmbeddedResolver/StandaloneResolver split, the MCP-import allowlist, and
the wire-schema snapshot tests that pin the published CLI's contract.

Fixes two docs that still described chat_id and room_id as different
model-facing argument names across the two doors -- both now advertise
chat_id, per INT-1096's room-field unification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
… MCP engine

Closes several magic-string/parallel-list duplications the migration
introduced or inherited:

- `CHAT_ID_FIELD_NAME` (band.runtime.tools) is the one definition of the
  model-facing room-identifier argument name; engine.py's schema builders
  and every adapter prompt that tells the model to use it (opencode, letta,
  acp, claude_sdk) now reference the constant instead of re-typing "chat_id".
- `Surface` (StrEnum, band.runtime.tools) replaces the bare
  `Literal["agent", "human"]` ToolDefinition.surface carried, so
  classify_room_binding/iter_tool_definitions dispatch on named members via
  match/case instead of re-typed string literals.
- `Scope`/`ToolGroup`/`Transport` (StrEnum, band_mcp.config) replace
  band-mcp's own Literal types plus their hand-maintained parallel
  VALID_SCOPES/VALID_TOOLS lists -- one definition each, derived lists.
- `CliArgs` (TypedDict) replaces `Mapping[str, object]` for
  resolve_config's cli parameter, removing the isinstance-renarrowing dance
  and every `# type: ignore[arg-type]` that boundary needed -- the two
  `cast()` calls in Config's field defaults are gone too, now that
  DEFAULT_SCOPE/DEFAULT_TOOLS are already concretely typed.

Behavior is unchanged; the wire contract (band-mcp's advertised schemas,
CLI flags, exit codes) is untouched -- confirmed by the full unit suite,
the wire-schema snapshot tests, and the CLI subprocess contract tests all
staying green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
tools/auth/transport were band-mcp's own scopes when it was a standalone
repo, where those words were unambiguous. In this monorepo they collide
with existing broader concerns: transport reads as websocket's territory,
tools spans every adapter's runtime tool surface, and auth is generic
across the whole SDK. One mcp scope is precise and collision-free.

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

python3 -c "<newline>code<newline>" only works because YAML's `run: |`
block strips the block's common indentation before bash sees it -- correct
today, but silently depends on every line staying at the same indent level,
and the double-quoted string form performs $var/backtick/backslash
expansion, so a future snippet using any of those characters would break or
misbehave silently. A quoted heredoc (`<<'PYEOF' ... PYEOF`) disables shell
expansion entirely regardless of content, and is the idiomatic way to embed
a multi-line script in bash. Behavior is unchanged -- verified by extracting
every affected step's real (YAML-dedented) script and bash -n-checking it,
and by running the two package-version-extraction snippets directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Found live via the Letta lane smoke: a containerized Letta calling back
into a LocalMCPServer bound to 0.0.0.0 (the class's own documented
Docker-callback support) got a 421 Misdirected Request on every call.

build_engine() never told FastMCP the caller's real bind host, so FastMCP's
own constructor always saw its hardcoded default host="127.0.0.1" and took
its "auto-enable loopback-only DNS-rebinding protection" branch regardless
of what the caller actually bound to -- locking allowed_hosts to
127.0.0.1/localhost/::1 even when LocalMCPServer had explicitly bound
0.0.0.0 for a remote/containerized client. A Host: host.docker.internal
request then failed that allowlist unconditionally.

build_engine now takes a host param and LocalMCPServer.start() passes its
own self._host, so FastMCP's auto-detection sees the truth: loopback binds
keep the existing strict default unchanged, non-loopback binds get
protection off (matching the already-accepted risk model documented on
LocalMCPServer's own class docstring -- only bind non-loopback on an
isolated/trusted host). The CLI door is unaffected either way, since it
always passes its own explicit transport_security.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
BREAKING CHANGE: band-mcp no longer accepts BAND_API_KEY. Set
BAND_USER_KEY (human scope) and/or BAND_AGENT_KEY (agent scope) explicitly
-- there is no unscoped credential or prefix-inference fallback any more.

Removes _legacy_key_capabilities(), Config.legacy_key, the
"legacy-key-ignored" warning kind, server.py's pure-legacy escape hatch
(_is_pure_legacy_invocation and its scope write-back), and the dead
Settings.band_api_key field (nothing ever read it). resolve_credential_for_
scope() is now a plain per-scope lookup with no fallback branch. Updates
the README, mcp_config_example.json, and the CLI's --help env-var list to
match, and drops the now-legacy-specific tests from test_config.py/
test_server.py/test_cli_contract.py.

Incidentally found and fixed while touching this code: tests/integration/
mcp/conftest.py (and test_forwarding.py) still imported band_mcp.tools.
registrar and band_mcp.shared.build_app_context, both deleted in step 11 of
the INT-1096 migration -- the whole tests/integration/mcp/ live-API suite
has been silently uncollectable since then. Rewrote conftest.py's
live_config/harness fixtures on the current standalone_spec()/
build_engine()/StandaloneResolver API (LiveHarness's public interface is
unchanged, so test_smoke.py/test_full_workflow.py/test_error_cases.py
needed no changes), and deleted test_forwarding.py outright -- it tested
the now-deleted registrar module directly and its coverage is already
superseded by tests/mcp/test_standalone_spec.py + test_engine.py against
the real engine. Verified live: 4 passed, 4 skipped (no human-scope
credential), 2 xfailed (pre-existing, unrelated), 0 failures/errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
…t 2)

The rest of the previous commit's intended changes -- a bad pathspec in my
own `git add -A` invocation aborted that add before it staged anything but
the test_forwarding.py deletion, so the previous commit's message described
changes this repo didn't actually contain yet. This commit is those changes.

BREAKING CHANGE: band-mcp no longer accepts BAND_API_KEY. Set
BAND_USER_KEY (human scope) and/or BAND_AGENT_KEY (agent scope) explicitly
-- there is no unscoped credential or prefix-inference fallback any more.

Removes _legacy_key_capabilities(), Config.legacy_key, the
"legacy-key-ignored" warning kind, server.py's pure-legacy escape hatch
(_is_pure_legacy_invocation and its scope write-back), and the dead
Settings.band_api_key field (nothing ever read it). resolve_credential_for_
scope() is now a plain per-scope lookup with no fallback branch. Updates
the README, mcp_config_example.json, and the CLI's --help env-var list to
match, and drops the now-legacy-specific tests from test_config.py/
test_server.py/test_cli_contract.py.

Also rewrites tests/integration/mcp/conftest.py's live_config/harness
fixtures onto the current standalone_spec()/build_engine()/
StandaloneResolver API -- they still imported band_mcp.tools.registrar and
band_mcp.shared.build_app_context, both deleted in step 11 of the INT-1096
migration, so the whole tests/integration/mcp/ live-API suite has been
silently uncollectable since then (LiveHarness's public interface is
unchanged, so test_smoke.py/test_full_workflow.py/test_error_cases.py
needed no further changes beyond what's already in the previous commit).
Verified live against the real dev Band platform: 4 passed, 4 skipped (no
human-scope credential), 2 xfailed (pre-existing, unrelated), 0
failures/errors.

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

Comments should state why the code is the way it is, not reference a
tracker ID or narrate what changed between versions -- strip every
INT-1096/INT-1150/INT-338/step-N mention across the MCP engine, band-mcp,
and their tests.

Also tightens test_start_forwards_real_host_to_build_engine's spy to
build_engine's real keyword-only signature instead of *args/**kwargs:
object, which both fixes a genuine pyrefly no-matching-overload error on
the forwarding call (verified: 10 -> 3 pyrefly errors on this file, the
remaining 3 pre-existing and unrelated) and resolves ty's matching
complaint. The create_model(**{...}) and __signature__ assignment ty
flagged elsewhere are confirmed-necessary escape hatches (removing their
ignore comments reproduces the same errors under pyrefly, the repo's
actual gate) -- pydantic's create_model overloads and CPython's
FunctionType stub don't model dynamic model/signature construction, so
these stay as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Introduced when these assertions replaced structuredContent dict checks:
content is a five-member union (TextContent | ImageContent | AudioContent
| ResourceLink | EmbeddedResource) and only TextContent has .text, so
pyrefly flags every .text access as missing-attribute -- invisible to the
gate today only because project_excludes drops tests/** from pyrefly's
project-level check. Added _text_of(), an isinstance-narrowing helper used
at all three call sites; also fails loudly (not silently) if a tool ever
returns non-text content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Comparing the full advertised schema (title/description prose included)
made every snapshot diff noisy -- a reviewer can't tell an intentional
wire-contract change from a reworded description at a glance. Project
each tool down to what a real call's acceptance actually depends on:
required-ness, JSON type, enum values (resolving $ref/anyOf so a ref'd
enum's allowed values are still covered), array item type, and
string-length bounds. full.json shrinks from ~43KB to ~18KB.

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

A diff against a 45-tool JSON blob is not reviewable -- nothing forces a
human to actually read it before regenerating and committing whatever
comes out. Replace it with small, hand-written ToolContract entries for
only the 8 tools that carry a real non-obvious wire invariant (enum
values, array item type, a required-set beyond boilerplate); the other
~37 tools are plain string/int/bool CRUD fields already covered by the
engine/converter/dispatch unit tests.

Enum values are reflected off their real StrEnum/Literal source
(band.core.memory_types, WideEventMessageType, the input models
themselves via a small _field_literal_values helper) rather than
hand-copied, so the test can't silently drift from the validator's own
source of truth. chat_id room-binding (required unpinned, hidden pinned)
is checked once generically against AGENT_ROOM_BOUND_TOOL_NAMES instead
of being repeated per tool.

Verified the new test actually fails loudly on a real break (renamed a
required field) before committing.

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

Review pass over the MCP engine refactor:

- engine.py: _build_handler_signature dropped every ge/le/max_length/
  pattern constraint from the advertised wire schema; fixed via Pydantic's
  own FieldInfo.rebuild_annotation(). The chat_id field description no
  longer names the room_id alias (model-facing text stays chat_id-only).
- Extracted resolve_tool_method/dispatch_tool in engine.py, shared by
  EmbeddedResolver and StandaloneResolver: a method-not-found registry
  mistake now raises one actionable message instead of a raw
  AttributeError at whichever call site hit it first.
- shared.py: StandaloneResolver never passed agent_id into AgentTools, so
  a failed send_message on the CLI door hinted the agent's own handle as
  mentionable. Resolves and caches it via agent_api_identity.get_agent_me().
  Lock stripes bumped 64->128 to match cache size (removes cross-room
  false contention).
- config.py: unified --scope/--tools explicit-empty detection into one
  _is_explicit_empty() helper (--scope "" had no equivalent handling
  before).
- server.py: _health_check probes human/agent connectivity concurrently.
- Test-quality pass: replaced weak assertions (`is not None`, bare
  `isinstance(profile, dict)`) with pinned values verified against real
  response shapes; test_concurrent_start_calls_are_serialized now counts
  actual socket-reservation calls instead of just checking a bind
  succeeded; test_invoke_human_dispatches_to_singleton uses the real
  FakeHumanTools instead of a MagicMock; added match= to ConfigError
  assertions; removed remaining stray ticket-ID references.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
test_loopback_bind_auto_dns_rebinding_protection_accepts_real_clients
hangs the full 30s pytest-timeout on windows-latest CI, stuck inside
asyncio's Proactor _poll waiting on a completion that never arrives.

This is a real, isolated bug in the spike's own throwaway _RunningApp
helper, not in production code: LocalMCPServer.start() uses the identical
raw-socket-to-uvicorn handoff (_reserve_socket + uvicorn.Server(sockets=
[...])), and its own real-client tests (test_local_server.py's
test_serves_sse_tools_on_localhost / test_serves_streamable_http_tools_
on_localhost) already exercise that exact path successfully on Windows CI
today (verified against main via PR #545/#547's green windows runs) --
so the production path isn't broken, only this spike's copy of it.

The spike's own docstring already says it's disposable once local_server.py
lands ("this test either moves onto it or is deleted -- it is a feasibility
gate, not permanent product code"). That already happened; this test's
unique coverage (a real client isn't 421'd on loopback) is now fully
redundant with test_local_server.py's tests against the real class, so
delete it rather than debug a Windows Proactor quirk in disposable spike
code. The sibling rejects_spoofed_host test stays -- it has no live-server
equivalent elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
@AlexanderZ-Band
AlexanderZ-Band force-pushed the feat/keep-the-mcp-server-in-the-sdk-repo-not-a-separate-INT-1096 branch from 121c233 to f6bdf3b Compare August 18, 2026 13:05
AlexanderZ-Band and others added 2 commits August 18, 2026 17:30
…nfig resolvers

/simplify pass (4 parallel review agents: reuse/simplification/efficiency/
altitude) over the full MCP-engine migration diff. Efficiency and altitude
came back clean. Reuse found one finding already explicitly deferred by
the diff's own comment (a "[System]: " prefix duplicated across 9 other
adapters -- fixing it means touching every one of them, out of scope
here). Applied the 5 simplification findings:

- engine.py: extend_with_chat_id's pinned branch and pin_existing_chat_id
  built the identical create_model field spec; extracted
  _pinned_chat_id_field().
- local_server.py: build_band_mcp_tool_registrations duplicated
  build_resolved_band_mcp_tool_registrations wholesale; now delegates to
  it with a single-room get_tools closure.
- band_mcp/config.py: resolve_config's --scope and --tools blocks repeated
  the same resolve-then-partition shape; extracted
  _resolve_and_partition().
- claude_sdk.py: _on_assistant_message and _on_user_message duplicated
  the ToolUseBlock/ToolResultBlock match cases; extracted
  _dispatch_tool_block() (single-pass semantics preserved -- each block is
  still visited exactly once, in original order).
- client_types.py: BandACPClient.__init__ was a pure passthrough adding
  no behavior over its parent; removed.

Also dropped a few more stray "the ticket"/"divergence-matrix row N"
references picked up while touching these functions, per the no-ticket-
refs convention already applied elsewhere this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Dependency Conflicts table and the band-client-rest pin example had
drifted from pyproject.toml (crewai/pydantic/opentelemetry/fastmcp
versions). Also collapsed three near-identical gh api examples and
three restatements of the same ValidationError/required-config rule
down to one each.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SecysJC5LKmM3RGY9CcV2v
AlexanderZ-Band and others added 7 commits August 18, 2026 21:33
~16 tests each hand-rolled the identical mechanism: a local MockMessage
class, a nonlocal-capturing callback, then a call to client._handle_events.
Extracted dispatch() -- feeds one event through _handle_events via a
single registered callback and returns what it received (None if never
called). Left tests with a genuinely different shape (custom raising
callbacks, multi-handler routing, no-handler-registered cases, a
different method under test) untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
task_statuses/task_error_codes extracted: 9 tests hand-rolled the identical
"pull one metadata_namespace field out of every task event" comprehension
this repo's own CLAUDE.md names as the anti-pattern to avoid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
- register_pending_approval() extracted: 17 tests hand-built the identical
  _PendingApproval registration (varying only token/tool_name/summary/
  created_at), each duplicating asyncio.get_running_loop().create_future()
  and the _pending_approvals dict-poke.
- _tool_result_payload() extracted alongside the file's existing
  _error_events/_narrated_message_types, for the one call site with the
  same "pull the sole matching event's content" shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Demonstrates the published band-mcp CLI's stdio transport driven by three
different consumers, each proving a distinct capability: a raw mcp client
composing a room at runtime (agent scope), a vanilla Claude Agent SDK script
proving durable cross-process memory (--tools memory), and a LangGraph agent
using band-mcp's human scope as a personal assistant. All three live-verified
against the dev platform.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vHeRvLouZegJmdmgyue1o
Extracted started_tools fixture: ~30 tests hand-rolled the identical
"construct adapter, on_started, reach into crewai_mocks.Agent.call_args[1]
['tools'], look up by name" sequence, most also duplicating asyncio.run()
for sync test methods. started_tools(**adapter_kwargs) does the
construct+start and returns the registered tools keyed by name, so tests
read as arrange (kwargs) -> act (call) -> assert (lookup/membership).

Left the Agent-constructor-kwarg checks (role/goal/verbose/max_rpm/
allow_delegation) untouched -- a different concern, not the tools lookup
this fixture targets, and each is already a short, undusplicated
arrange/act/assert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Extracted message_types() for the two TestExecutionReporting sites that
hand-rolled a count/loop over mock_tools.send_event.call_args_list instead
of reusing the projection this file's sibling files already extract this
shape into.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
… credential gating

band_send_message requires a non-empty mentions list, but a freshly created
agent room has no other participant and self-mention is disallowed by
design. ensure_mentionable_participant() adds the room-owning human (or a
given identifier) as a real participant before sending, so both previously
xfail tests now pass for real.

Also replaces every skip that was masking a real failure or a missing
credential with a hard failure: live_config now requires both
BAND_AGENT_KEY and BAND_USER_KEY (aliased to the already-present
BAND_API_KEY_USER), agent_room asserts instead of skipping when room
creation fails, and the now-dead "scope not served" skips are removed
since scope is always [AGENT, HUMAN].

Adds a genuine two-agent scenario (test_two_agents_collaborate_in_shared_room)
backed by a second real agent identity (BAND_AGENT_KEY_2, aliased to the
existing BAND_API_KEY_2/TEST_AGENT_ID_2), sharing the harness-building code
via _build_harness() to keep the two identities' fixtures DRY.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Comment thread src/band/core/types.py Outdated
Comment thread packages/band-mcp/src/band_mcp/server.py Outdated
Comment thread src/band/adapters/claude_sdk.py Outdated
Comment thread tests/integration/mcp/conftest.py Outdated
Comment thread tests/integration/mcp/conftest.py Outdated
Comment thread tests/integration/mcp/conftest.py Outdated
Comment thread tests/integration/mcp/conftest.py Outdated
Comment thread tests/integration/mcp/test_full_workflow.py Outdated
local_server.py mixed three concerns: an sse_starlette shutdown-bug
workaround, tool-registration building (RoomToolResolver,
build_band_mcp_tool_registrations, build_resolved_band_mcp_tool_registrations),
and the actual LocalMCPServer lifecycle. The registration builders never
touched sockets/uvicorn/starlette and depended entirely on primitives
engine.py already owns, so they move there -- engine.py already builds
every other Band MCP tool registration. Updated all in-repo import sites
(backends.py, the mcp_server.py compat shim, three test files); both
files were already on the MCP import-boundary allowlist, so no allowlist
change needed.

Also collapses the sse_starlette shutdown-bug explanation, previously
told three times (module docstring, a standalone comment, and
EmbeddedUvicornServer's docstring), down to one authoritative spot, and
dedupes _reserve_socket's twice-repeated socket setup into two small
helpers -- which also narrows the range-scan's try/except to just
bind(), no longer silently swallowing a listen()/setblocking() failure
as "port taken."

No behavior change: LocalMCPServer's public surface (including the
pre-existing .url property) is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Comment thread src/band/runtime/tools.py Outdated
AlexanderZ-Band and others added 3 commits August 19, 2026 07:42
…ework swap

- core/types.py: WideEventMessageType had exactly one consumer (engine.py's
  SendEventWideInput) and retyped EventMessageType's members instead of
  composing them. Moved it next to that consumer and derived it from
  EventMessageType, closing both the misplaced-indirection and
  duplicated-taxonomy complaints in one fix.

- claude_sdk.py/copilot_sdk.py: both hand-roll a per-message room-context
  label instead of using the existing CHAT_ID_FIELD_NAME constant (already
  used correctly by opencode.py and acp/client_adapter.py). copilot_sdk.py's
  version said "room_id", violating the documented invariant that
  model-facing text always says "chat_id" -- a real bug, not just a style
  gap. Both now derive from CHAT_ID_FIELD_NAME; fixed the test that had
  encoded the wrong string.

- tests/integration/mcp/conftest.py: renamed live_config_2/harness_2 to
  second_agent_config/second_agent_harness (a numeric suffix named nothing
  about the fixture's role). Replaced ensure_mentionable_participant's
  dual-mode "identifier=None means discover the owner instead" design with
  a single-purpose add_room_owner -- adding a *known* second agent is a
  one-line band_add_participant call, no helper needed.

- test_full_workflow.py: merged test_agent_create_room_send_and_read_back
  and test_two_agents_collaborate_in_shared_room (near-duplicate scenarios,
  one per participant kind) into one test_agent_room_with_human_and_second_agent
  covering both a human and a second real agent identity in the same room,
  including a mention with more than one target.

- packages/band-mcp: replaced argparse with Typer. Typer is already a real
  transitive dependency (mcp[cli] requires it), so this adds no new install
  footprint -- just promoted from implicit to an explicit direct dependency.
  Native Enum support replaces the manual `type=Transport, choices=list(Transport)`
  wiring; _cli_mapping's Namespace-to-dict flatten is gone entirely since
  Typer hands the command function typed parameters directly. resolve_config/
  validate in config.py are untouched -- they're deliberately pure and
  framework-agnostic, and stay the single place CLI>env precedence and
  credential validation happen. Verified live: --version, --help, the
  missing-credential exit(2) path, and a real stdio tools/list round trip
  all match the pre-swap contract (tests/mcp/test_cli_contract.py, 5/5).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
- runtime/tools.py: TOOL_DEFINITIONS had every tool's name typed twice --
  once as the dict key, once as the ToolDefinition's own name= field --
  with nothing structural stopping the two from drifting apart. Now built
  from a tuple of ToolDefinition entries (name typed once) with the dict
  derived by keying on .name.

- band_mcp/server.py: main()'s "Order of operations: 1-6" docstring was
  narrating six responsibilities glued into one function. Extracted three
  of them into named steps (_resolve_validated_config, _exit_on_config_error,
  _run_transport) -- also killing a duplicated exit(2)+log ConfigError
  pattern that appeared twice. main() now reads as its own step list via
  the calls themselves; no docstring narration needed.

- engine.py: the WideEventMessageType move in the last commit left its new
  rationale comment immediately followed by the old comment's now-redundant
  first paragraph restating the same thing. Removed the duplicate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
create_room() and main() each hand-rolled the same
stdio_client(...)/ClientSession(...)/initialize() nesting to get a ready
session. Factored into open_session(), an async context manager -- both
call sites now just state what they need (a session, optionally pinned to
a room) instead of repeating how to get one.

The other two band_mcp examples (02, 03) were already this shape --
each helper used once or twice with nothing to extract. Left the
per-file logging/base_url boilerplate duplicated across all three
examples as-is: these are standalone, copy-paste-able PEP 723 scripts by
design (this repo's own example convention), so a shared helper module
would break that -- a reader copying just one file shouldn't need a
sibling module to run it.

Verified live against the real platform: room creation, tool listing,
peer discovery, participant add, and message send all still work
end-to-end through the refactored open_session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Comment thread src/band/runtime/tools.py Outdated
… output

Leading underscore is a convention for module-private functions -- not
classes, which Python has no equivalent privacy convention for and where
band-sdk's own style is plain PascalCase. Renamed every underscore-prefixed
class across the codebase (17 total): TimeoutNotSet, ResyncRequest,
BacklogProcessResult, PendingToolCall, SessionCommand, SlackTeeingTools,
SeenEvents, CodexClientProtocol, PendingApproval (both codex.py and
claude_sdk.py have their own, unrelated to each other), TurnResult,
RoomCacheEntry, RoomLockEntry, AmbiguousReply, RoomContext, RoomState.
langchain.py's own _ParsedToolResult is renamed to LangChainParsedToolResult,
not the bare name -- band.converters.parsing already has a real, different
ParsedToolResult, and this file only imports parse_tool_result/parse_tool_call
from there, not that type, so the two could otherwise be confused for the
same shape. No filenames needed the same fix -- the __init__.py/__main__.py
hits are Python's own required convention, not a style choice.

Also fixes a real CI failure this surfaced while re-verifying: band_mcp's
Typer app left rich_markup_mode at its default, which lets Typer's
Rich-based --help renderer emit ANSI color codes -- inserted *inside*
option names ("--user-key" splits into several separately-colored spans)
whenever Rich's terminal-capability detection decides the output stream
supports color. That detection is environment-dependent: plain locally
(macOS), colored on Ubuntu CI for the identical piped-subprocess call,
which broke test_cli_contract.py's plain substring match on Ubuntu only
(Windows/macOS both passed). Setting rich_markup_mode=None forces plain,
deterministic help output -- verified with FORCE_COLOR=1 set explicitly,
not just by re-running the previously-flaky case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Comment thread src/band/integrations/mcp/local_server.py Dismissed
Comment thread src/band/integrations/mcp/local_server.py Dismissed
Comment thread packages/band-mcp/src/band_mcp/shared.py Outdated
Comment thread packages/band-mcp/src/band_mcp/shared.py Outdated
Comment thread src/band/integrations/mcp/engine.py Outdated
AlexanderZ-Band and others added 3 commits August 19, 2026 08:11
…ion in shared.py

runtime/tools.py: moved SEND_MESSAGE_TOOL_NAME earlier so
ROOM_POSTING_TOOL_NAMES and AGENT_ROOM_BOUND_TOOL_NAMES reference it
instead of repeating the literal. Consolidated every other standalone
"is this the send-message tool" check/constant to the same constant:
crewai/tools.py's own redundant _SEND_MESSAGE_TOOL local constant is gone
entirely, claude_sdk/tools.py's mention-hint-enrichment check, letta/
prompts.py's SEND_MESSAGE_TOOL_NAMES tuple, and agno.py's reply-detection
check. Left literals alone where they're one member of an already-uniform
enumerated collection (crewai's per-tool category dict, the format-success-
payload if/elif chain, slack's write-tool set) or docstring prose --
converting just one member there would be a mixed-style regression, not
an improvement.

band_mcp/shared.py: `assert self._agent_rest is not None` replaced with an
explicit if/raise (asserts vanish under `python -O`, so a real invariant
in production code needs a real check) -- matches the sibling guard three
lines below it. AGENT_TOOLS_LOCK_STRIPES was a second name for exactly
AGENT_TOOLS_CACHE_MAX_SIZE's value with no independent existence; removed
it and reference the real constant directly at its one use site, moving
the explanatory comment there.

Also dismissed two CodeQL alerts (py/bind-socket-all-network-interfaces,
local_server.py:311,327) as won't-fix: LocalMCPServer's own docstring
already documents 0.0.0.0 as an intentional, opt-in, explicitly-warned-about
Docker-bridge configuration, not a default or an accident.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Both describe the same field (name, max length); splitting them across
runtime/tools.py and engine.py meant a reader had to know to look in two
files for one field's canonical properties. Moved CHAT_ID_MAX_LENGTH next
to CHAT_ID_FIELD_NAME; engine.py imports it like it already imports the
field name. Verified it isn't duplicated anywhere else in this repo or in
the band-client-rest dependency -- it's a standalone choice for this
schema-extension code, not mirroring an existing backend constraint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
… live coverage

An audit of tests/mcp/, tests/integrations/mcp/, and tests/integration/mcp/
found several tests reaching for MagicMock/AsyncMock where a real
FakeAgentTools already existed and would exercise more production code, and
no agent-surface test chained multiple tool calls into one realistic
session the way the human-surface tests already do.

- Deleted test_engine_mount_spike.py: its own docstring says it's a
  feasibility gate superseded once local_server.py was built, which
  happened earlier this session -- confirmed the module exists and is real
  product code before removing the spike.

- test_standalone_spec.py / test_shared.py: replaced bare MagicMock/AsyncMock
  agent-tools doubles with real FakeAgentTools (subclassed where a test
  needs order-tracking, a forced refresh failure, or a bare BandToolError
  with no hint pre-applied). The load-bearing fix is in test_shared.py's
  error-enrichment test: the old mock raised a bare ValueError, so
  engine.py's `except (ValueError, BandToolError)` branch for the real
  exception type was never actually exercised.

- test_local_server.py: replaced a MagicMock(data=[]) participants response
  with the real ListAgentChatParticipantsResponse Fern model, and narrowed
  a `pytest.raises(Exception)` to the real BandToolError production code
  raises. Verified live: the MagicMock stand-in silently tolerates an
  attribute-name typo in get_participants() that the real Pydantic model
  correctly rejects with AttributeError.

- test_engine.py: three new fake-backed unit tests exercising this
  codebase's own dispatch/caching/registration logic --
  test_agent_multi_step_room_lifecycle (add_participant -> send_message
  mentioning that participant -> get_participants, each step depending on
  the prior's real mutated state), test_custom_tool_alongside_builtin_tools_in_one_session
  (a custom tool's handler mutates the same FakeAgentTools a built-in tool
  then reads), and test_concurrent_dispatch_through_one_engine (drives
  StandaloneResolver's real identity resolution, per-room caching, and lock
  striping through a real engine's `_tool_manager.call_tool`, asserting no
  cross-room leakage and that the LRU cache stays bounded). No fake-backed
  test for cross-identity isolation was added -- that invariant depends on
  real backend behavior and stays live-only, per this session's existing
  test_full_workflow.py::test_agent_room_with_human_and_second_agent.

- test_full_workflow.py: added test_agent_send_message_retry_after_lookup_peers,
  a new live test against platform.dev.band.ai. Mentioning a real peer
  found via band_lookup_peers who isn't yet a room participant is observed
  to fail live with `Unknown participant '<id>'`; band_add_participant then
  makes the identical retry succeed.

Every new/fixed test was verified to fail for the right reason (by
reverting the fix or breaking the real production code path it guards)
before being confirmed green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Comment thread tests/adapters/copilot_sdk/test_reply.py Outdated
Comment thread tests/websocket/test_client.py Outdated
Comment thread tests/mcp/test_wire_contract.py
Adds a bump-mcp-sdk-floor job to release.yml, mirroring bump-add-band's
shape, so packages/band-mcp/pyproject.toml's band-sdk>= floor always
tracks the most recently published SDK version instead of relying on a
manual bump once the right version number is known.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
Comment thread tests/mcp/test_transport_security.py Outdated
Comment thread tests/mcp/test_transport_security.py Outdated
…e, imports

- test_reply.py: assert the chat_id room-context marker via
  CHAT_ID_FIELD_NAME instead of a hardcoded "[chat_id: ...]" literal that
  duplicates the constant copilot_sdk.py itself builds the prompt from.
- test_client.py: replace dispatch()'s nonlocal-closure callback with a
  plain AsyncMock, reading the received payload off its await_args.
- test_transport_security.py: hoisted every function-local band_mcp/
  engine import to the top of the file; dropped TestDnsRebindingProtectionBehavior,
  which characterized the mcp SDK's own TransportSecurityMiddleware
  in isolation rather than anything band-mcp builds -- the "our config
  reaches the SDK correctly" claim it made is already covered by
  TestMcpTransportSecurityIntegration, which drives band-mcp's real
  factories.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GfY2tLM1peE7YwhYDsMs1M
@AlexanderZ-Band
AlexanderZ-Band requested a review from a team August 19, 2026 08:17
release-please only bumps a component's own version off a bump-worthy
commit type (feat/fix/breaking, not chore). A chore:-typed floor bump
would merge into main without ever triggering a release-please PR for
packages/band-mcp, leaving its pyproject.toml version stuck at the last
release -- which band-mcp-publish.yml's tag-must-match-version check
then rejects on the next band-mcp-v* tag. Retyped the bump-mcp-sdk-floor
job's commit/PR title to fix: (branch prefix to match) so merging it
actually opens band-mcp's next release-please PR.

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

2 participants