boost / docs / mcp-hub · Phase 3

RAG Phase 3 · shipped in PR #64

MCP as a hub.

boost's Model Context Protocol server used to dispatch tools through a flat if/elif ladder. Phase 3 turns it into an extensible registry: every tool self-registers a spec + handler, the server just iterates, and adding a capability — like the new boost_discover_github reach-out — is one register() call with zero dispatcher edits.

6Registered MCP tools
0Dispatcher edits to add one
89.6%Mutation kill on core/
0New runtime deps
Architecture

One registry, many handlers

The JSON-RPC server never knows what tools exist — it asks the Registry in core/mcp.py. Handlers that reach out to the world (like GitHub) probe for their dependency and degrade to a message instead of crashing.

MCP client Claude Code · Cursor · Windsurf JSON-RPC 2.0 · newline-delimited over stdio _mcp_serve_stdio() read line → dispatch → send initialize · ping · tools/list · tools/call REGISTRY — core/mcp.py ordered name → (spec, handler) .specs() → tools/list .call(name, args) → tools/call Handlers (self-registered) boost_search boost_list boost_info boost_install boost_doctor boost_discover_github ✦ new gh CLI → GitHub code search github_skill_search(query, limit) one page · no cache · SKILL.md repos no gh / offline / failure → helpful message, never crash core engine (read-only from here) rag · catalog · store · lockfile · registry handlers call the engine

Fig 1 — The server holds no tool knowledge; it delegates every tools/list / tools/call to the registry. New tools slot in on the right without touching the loop on the left.

Sequence

A boost_discover_github call, end to end

The happy path reaches GitHub; the shaded branch is what happens when gh is missing or the search fails — the server answers, never dies.

Client Server Registry Handler gh · GitHub tools/call · boost_discover_github {query,limit} REGISTRY.call(name, args) handler(args) probe shutil.which("gh") github_skill_search → gh api search/code items[] (repo · path · desc) (text, is_error=False) result.content[0].text alt · no gh / gh returns error / bad JSON handler returns ("install gh …" / "search failed …", is_error=True) — server still replies

Fig 2 — Steps 5–6 are the only ones that touch the network. Everything below step 4 has a mirror in the shaded alt branch, so a missing dependency is a message, not an exception.

The refactor

From an if/elif ladder to a registry

Same behaviour, same wire protocol — but adding tool #7 no longer means editing the dispatcher, and an unknown tool resolves in exactly one place.

before — configuration.py
# flat dispatch: every tool is a branch
def _mcp_tool(tool, args):
    if tool == "boost_search":
        ...
    if tool == "boost_list":
        ...
    # +1 tool = +1 branch here
    return None, False

_MCP_TOOLS = [ {…5 hand-kept specs…} ]
after — core/mcp.py + configuration.py
# the seam: an ordered name → (spec, handler) map
REGISTRY = mcp.Registry()

REGISTRY.register(
    "boost_discover_github",
    "Discover new SKILL.md repos…",
    {"type": "object", "properties": {…}},
    _tool_discover_github)          # ← the only edit

_MCP_TOOLS = REGISTRY.specs()       # list stays in sync
Cheat sheet

Explain it two ways

Every concept, twice: the ELI5 on the left, the deep technical on the right. Open both and compare — they describe the same thing at different altitudes.

🧸 Explain like I'm 5
🔬 Deep technical
The toy box registry

Imagine a toy box with labelled slots. Each toy knows its own name and what it does, and it puts itself into a slot. When someone asks "what toys do you have?" you just read the labels. When they say "play with the drum," you grab the drum from its slot. You never keep a giant list in your head — the box does.

Registry: ordered name → (spec, handler) core/mcp.py

Registry holds three parallel structures keyed by tool name: an insertion-ordered name list, a specs map ({name, description, inputSchema}), and a handlers map. register() rejects empty or duplicate names; specs() returns the JSON payload in registration order; call(name, args) looks up the handler and returns (None, False) for anything unknown. It's pure, stdlib-only, and mutation-gated.

What each toy promises handler

Every toy promises the same thing: "give me your ask, and I'll hand back some words, plus a thumbs-up or thumbs-down." That's it. Because they all promise the exact same shape, the toy box doesn't care which toy it grabbed — they're interchangeable.

The handler contract (text, is_error)

A handler is fn(args: dict) -> (text, is_error). text is the human-readable result; is_error becomes the MCP result.isError flag. A handler may return text is None to mean "no result," which the server renders identically to an unknown tool — so the -32602 path lives in exactly one place. Handlers close over the core engine (rag, catalog, store) but the registry knows nothing about them.

The scout that looks outside boost_discover_github

Most toys play with things already in the room. This one is a scout: it opens the door and looks down the street (GitHub) for new skills to bring home. If the door is locked (no gh tool installed), it doesn't panic — it just says "hey, you need a key, here's how to get one" and comes back inside.

First reach-out tool gh code search

_tool_discover_github probes shutil.which("gh"), then calls discovery.github_skill_search(query, limit) — a single gh api search/code?q=filename:SKILL.md… page (no cache write, limit clamped to 100, query URL-encoded and appended to the filename filter). Results map to {repo, path, url, description} and render one per line. Every failure mode (TimeoutExpired, OSError, non-zero exit, bad JSON) returns None so the handler emits a hint instead of raising.

Never cry, always answer degradation

The rule for any toy that reaches outside: never throw a tantrum. If something it needs is missing, it must still say something friendly and useful. That way the whole toy box keeps working even when one toy can't reach its toy.

Probe-then-degrade core/ai.py pattern

Mirrors the established core/ai.py discipline: detect the dependency, and if absent return a short actionable message rather than raising. The MCP server is a long-lived stdio loop — an uncaught exception in one tools/call would be caught by the server's blanket handler, but degrading inside the tool gives a precise, testable message and keeps is_error semantically correct.

Old handles still turn shims

We rearranged the room, but we kept the old light switches on the wall so anything that used to flip them still works. Nobody has to relearn where the switches are.

Back-compat: _MCP_TOOLS / _mcp_tool no churn

_MCP_TOOLS = REGISTRY.specs() and a one-line _mcp_tool() that delegates to REGISTRY.call() preserve the two symbols the server loop and the existing test suite reference. The refactor is behaviour-preserving: the tools/list order is unchanged for the first five tools, and only the new sixth entry is appended — so TestMcp needed a single one-line assertion update.

Extension contract

Adding tool #7

The whole point of the seam: a new capability — a database query, another external API — is a handler plus one register(). No server edit, no dispatcher edit.

your_new_tool.py
def _tool_x(args):                       # fn(args) -> (text, is_error)
    if not _dependency_available():
        return "needs <dep> — here's how to get it", True   # degrade
    return _do_the_work(args), False

REGISTRY.register(
    "boost_x",
    "one-line description shown to the agent",
    {"type": "object", "properties": {…}},
    _tool_x)                              # tools/list + tools/call: automatic
RuleWhy
Handler returns (text, is_error)Uniform shape lets the registry stay tool-agnostic.
Reach-out tools must degrade, never raiseThe stdio server is long-lived; a missing gh, offline network, or absent [rag] extra must not kill it.
Registration order = tools/list orderDeterministic surface; existing clients see a stable ordering with new tools appended.
Put reusable logic in core/It gets unit-tested and mutation-gated — the registry itself lives in core/mcp.py for exactly this reason.