Skip to content

refactor(mcp): server.py 1617→377 LOC via ToolDispatcher + per-domain handlers#75

Closed
emp3thy wants to merge 20 commits into
mainfrom
refactor/mcp-server-dispatcher
Closed

refactor(mcp): server.py 1617→377 LOC via ToolDispatcher + per-domain handlers#75
emp3thy wants to merge 20 commits into
mainfrom
refactor/mcp-server-dispatcher

Conversation

@emp3thy

@emp3thy emp3thy commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Summary

  • Decomposed better_memory/mcp/server.py (1617 LOC god-module) into a ToolDispatcher + 8 per-domain handler modules + a frozen ServiceContainer built once at startup.
  • _call_tool is now a 2-line delegation (return await dispatcher.call(name, args)). _tool_definitions is a thin shim built from all_handlers(). Every long-lived service (SemanticMemoryService, SessionBootstrapService, etc.) is constructed once instead of per-call.
  • Zero MCP wire-format changes. Every test that passed on main still passes. ValueError("Unknown tool: ...") shape preserved for capability gating.

What changed

  • New: better_memory/mcp/container.py (frozen ServiceContainer, 12 fields)
  • New: better_memory/mcp/dispatcher.py (Handler dataclass + ToolDispatcher)
  • New: better_memory/mcp/_session.py (resolve_session_id)
  • New: better_memory/mcp/_best_effort.py (_run_best_effort — extracted to break server↔handlers import cycle)
  • New: better_memory/mcp/handlers/ (8 domain modules + _audit.py, totalling ~1600 LOC)
  • Promoted: ReflectionSynthesisService.read_queue_counts (was _read_queue_counts)
  • server.py: 1617 → 377 LOC (-77%). Legacy _tool_definitions + _call_tool if-chain deleted. Thin re-export shims kept for 8 existing test files that import helpers by name.

Architecture

MCP stdio frame
   ↓
Server SDK → _call_tool(name, args)
   ↓
ToolDispatcher.call(name, args)        ← O(1) name lookup
   ↓
Handler.call(services, args)
   ↓
domain method (e.g. SemanticHandlers.observe)

Test plan

  • Full pytest: 1180 passed, 23 skipped, 0 failed
  • tests/mcp/: 158 passed, 6 skipped
  • New regression tests: test_service_container.py (constructed-exactly-once), test_tool_dispatcher.py (capability gate + unknown-tool ValueError shape), 8 per-domain handler test files
  • uv run ruff check better_memory/mcp/ — clean
  • uv run pyright better_memory/mcp/ — 0 errors, 0 warnings
  • Live server boot: create_server() returns ctx.dispatcher with 22 tools

Follow-ups (separate PRs)

  • Strip legacy re-export shims (_tool_definitions, _serialize_*, _run_best_effort) by rewriting the 8 test files to import from canonical locations.
  • Update README + website/mcp-tools.md to point at better_memory/mcp/handlers/ for live tool schemas.
  • Convert ServerContext to @dataclass(frozen=True) for consistency with ServiceContainer + Handler.

🤖 Generated with Claude Code

emp3thy and others added 20 commits June 13, 2026 14:01
Spec: extract _call_tool if-chain into ToolDispatcher + ServiceContainer
+ per-domain handler modules. 17 tasks, single big-bang PR, ~94% avg
confidence with all sub-90% tasks lifted via parallel investigation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bundles every long-lived service the dispatcher needs so they're
built once at startup, not per-call (today: SemanticMemoryService
4x, SessionBootstrapService 2x).
- Strip unnecessary quote-string forward refs (ruff UP037) — the
  `from __future__ import annotations` import already defers
  evaluation, so quoting is redundant.
- Restore `semantic: SemanticMemoryService` type (was `Any`) by adding
  the import to the TYPE_CHECKING block alongside the other services.
  No runtime circular-import risk: container.py is only imported by
  the MCP dispatcher and services/semantic.py only depends on
  services/memory_rating.
- Strengthen frozen-dataclass test: assert
  `dataclasses.FrozenInstanceError` is raised on attribute
  reassignment, in addition to the existing structural checks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the 470-LOC if-chain with O(1) name lookup. Capability gate
preserves the existing 'Unknown tool: ...' ValueError shape so clients
don't notice.
No behaviour change. Lets handler modules import it without a
circular dep on the server module.
Audit JSONL byte-shape unchanged. Test updates import path only.
…_counts

Two MCP dispatcher branches were the only callers; making this public
removes a leak the handler-module split would otherwise expose.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ugh create_server

Legacy _call_tool if-chain still drives the actual dispatch; container
is now the canonical source for every long-lived service.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…down

The _wrap helper inside test_build_services_constructs_each_service_exactly_once
was permanently mutating SemanticMemoryService.__init__ and
SessionBootstrapService.__init__ via direct cls.__init__ = _init assignment.
Any test running after this one continued executing the wrapped __init__,
a slow leak that monkeypatch was already in scope to prevent. Switch to
monkeypatch.setattr so pytest reverts the class patch on teardown.

Also tighten the assertion comment to scope the regression statement to
_build_services (the only thing this test exercises).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…etrieve_observations + record_use)

Schemas + bodies lifted verbatim from _call_tool. spool->retention->backend
ordering preserved. ObservationHandlers exposes one method per tool +
handlers() registration list for ToolDispatcher.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ve + update + delete)

SemanticMemoryService is now built ONCE on the container, not 4x per call.
Task 9 of the MCP server dispatcher refactor. Lifts the four episode
lifecycle tools out of the legacy _call_tool if-chain into a domain
handler module. Bodies are verbatim, including the close_episode
try/except fork that converts EpisodeService.close_active's ValueError
into the already_closed=True no-op payload, and the 10-field
list_episodes serializer the UI depends on. _serialize_queue is
duplicated as a small module-local helper to keep the handler module
self-contained.

After this task, all_handlers() returns 12 (4 obs + 4 sem + 4 ep).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds ReflectionHandlers covering the two capability-gated synthesis tools.
Bodies lifted verbatim from the legacy _call_tool if-chain to preserve every
documented invariant: audit bracketing on both tools, the
SynthesisResponseError -> validation_error fork (no synth_failed_at stamp;
caller can retry), the ValueError -> state_error fork (caller must refetch
context), and the four serializer payload shapes the IDE-LLM consumes.

Both handlers are marked requires_synthesis=True so the dispatcher hides
them when backend.supports_synthesis is False. all_handlers() now returns
14 tools (4 obs + 4 sem + 4 ep + 2 ref).

Task 10 of 17 of the MCP dispatcher refactor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Task 11 of MCP server dispatcher refactor. Lifts the
memory.run_retention branch out of the _call_tool if-chain into
RetentionHandlers, registered via handlers/__init__.all_handlers().
Body and schema are verbatim from server.py; `retention.run(...)` is
re-routed through `services.retention.run(...)` on the
ServiceContainer.

The serialized payload is the 4-field RetentionReport (three
archived_via_* counters + pruned) — not 8 as the task description
suggested; the dataclass has 4 fields.

Tests cover the success serialization, default-arg fan-out, explicit
overrides, and the one-tool registration. all_handlers() now returns
15 handlers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Task 13 of the MCP server dispatcher refactor. Adds the three
rating-domain handlers as a RatingHandlers class behind the
ToolDispatcher / Handler abstraction, lifting the bodies and JSON
schemas verbatim from the legacy _call_tool if-chain.

- memory.list_session_exposures: coerces an unresolved session id to ""
  so the bootstrap service can return an empty exposures payload
  instead of raising.
- memory.apply_session_ratings: raises the verbatim 3-line ValueError
  when no session is active (the rate-session-memories skill matches
  the exact text).
- memory.credit: two payload shapes - {"applied": None,
  "skipped": "no_session"} when no session, otherwise the ApplyOutcome
  from MemoryRatingService.credit_one. Note the schema's class enum
  intentionally omits "ignored" (apply-only).

Registered in handlers/__init__.py so all_handlers() now returns 20
handlers across 7 domain modules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Task 14/17 of the MCP dispatcher refactor. Lands handlers/session.py
with SessionHandlers exposing memory.session_bootstrap (4-tier
session_id fallback: args -> CLAUDE_SESSION_ID -> CLAUDE_CODE_SESSION_ID
-> uuid; 5-key payload) and memory.start_ui. Uses the
services.session_bootstrap singleton from the container (built once in
Task 6) instead of re-instantiating SessionBootstrapService per call.

After this task all_handlers() returns 22 -- the full tool set.
Task 15 will wire ToolDispatcher into create_server.
server.py drops from 1568 to 401 LOC. _call_tool is now a 1-line
delegation to dispatcher.call(name, arguments or {}) (under _diag.trace).
_tool_definitions deleted as the 550-LOC schema function; replaced by a
thin shim built from all_handlers() so tests asserting tool registration
keep working. All 22 tools route through the dispatcher.

To break a server <-> handlers import cycle introduced by importing
all_handlers() at module load, extracted _run_best_effort into
mcp/_best_effort.py. server.py re-exports it so the existing test
import path (better_memory.mcp.server._run_best_effort) still works;
test_best_effort_logging now asserts the canonical logger name
(better_memory.mcp._best_effort).

ServerContext gains a `dispatcher` field LAST so positional construction
keeps compiling. Synth serializers (_serialize_synth_*, _serialize_queue)
re-exported from handlers.reflections for test backwards compat.
_serialize_knowledge_* deleted (no callers).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
5 LOC instead of 40. Same signature, same behaviour.
…617 → 377 LOC

All 22 tools route through ToolDispatcher. Handler bodies + schemas
co-located in mcp/handlers/. ServiceContainer built once at startup.
Final pytest: 1180 passed, 23 skipped, 0 failed (chunked across 4 junit runs
because full-suite stdout buffer was truncated by the harness).
Pyright clean on better_memory/mcp/ (0 errors, 0 warnings).
Ruff on better_memory/mcp/ + tests/mcp/: 5 pre-existing errors (down from 6
at base 22c3d26); none introduced by this refactor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@emp3thy

emp3thy commented Jun 13, 2026

Copy link
Copy Markdown
Owner Author

Superseded by PR #73 (already merged). Same target hit with a different architecture — closing to avoid duplicate work. Will file follow-up PRs for the architectural extras (frozen ServiceContainer, capability-gate dataclass) against the current main if desired.

@emp3thy emp3thy closed this Jun 13, 2026
@emp3thy
emp3thy deleted the refactor/mcp-server-dispatcher branch June 13, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant