boost / docs / mcp-hub · Phase 3
RAG Phase 3 · shipped in PR #64MCP 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.
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.
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.
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.
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.
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.
# 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…} ]
# 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
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.
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.
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.
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
| Rule | Why |
|---|---|
Handler returns (text, is_error) | Uniform shape lets the registry stay tool-agnostic. |
| Reach-out tools must degrade, never raise | The stdio server is long-lived; a missing gh, offline network, or absent [rag] extra must not kill it. |
Registration order = tools/list order | Deterministic 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. |