MCP Nexus is a self-hosted control plane for MCP servers — "Kubernetes for
MCP." Agents connect to one endpoint; Nexus discovers services, installs the
right MCP servers, aggregates them behind a namespaced router, secures access
with auth/RBAC, and heals/updates them via a reconcile loop.
This first commit is design-phase only (no runnable code yet):
- README.md project front door + module map
- docs/ARCHITECTURE.md target design: tenets, two-plane split, reconcile
loop, domain types, storage, security, deployment
- docs/ROADMAP.md phased delivery (foundations -> walking skeleton ->
discover+install -> secure -> operate -> extend/scale)
- docs/modules/01-20 one design doc per module, all cross-linked
Backend stack decision: Go (single static binary, embedded SQLite + embedded
React dashboard). Repo initialized in /root with a whitelist .gitignore so
only project files are tracked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.5 KiB
Dynamic Tool Registry
Module 05 · Plane: Data · Roadmap phase: 1 Part of MCP Nexus architecture.
Purpose
The Dynamic Tool Registry is the live, continuously-updated catalog of every
tool exposed by every healthy MCPInstance, each
namespaced as {namespace}.{tool} (Architecture §7). It is the authoritative
answer to two questions on the data-plane hot path:
- "What tools exist?" — it serves
tools/listto agents, filtered to the caller's RBAC-visible set. - "Who owns this tool?" — it maintains the
tool → owning instancemapping the Router uses to dispatchtools/call.
It is live because upstream MCP servers appear, disappear, and change their
tool sets at runtime. The Registry reacts to instances coming and going (from
the Installer / Health) and
to upstream tools/list results and notifications/tools/list_changed
notifications, keeping the aggregated catalog current without a restart.
Responsibilities
- Aggregate the tool lists of all healthy instances into one namespaced catalog; drop tools of unhealthy/absent instances immediately.
- Assign and enforce the
{namespace}.{tool}naming and collision rules (see Data). - Maintain the
{namespace}.{tool} → instanceIDdispatch map consumed by the Router. - Serve
tools/listresults and theGET /toolsview, always filtered by the caller's RBAC-resolved visibility. - Subscribe to lifecycle events (instance added/removed, health transitions)
and to upstream
list_changednotifications; refresh incrementally. - Cache upstream tool schemas (input/output JSON Schema) so
tools/listdoes not fan out to every upstream on each agent call. - Emit change events so the Gateway can forward
list_changedto connected agents whose visible set changed.
Non-goals
- Transport / sessions / the agent edge — owned by the Gateway.
- Dispatching
tools/calland streaming results — owned by the Router; the Registry only supplies the ownership mapping. - Deciding what an agent may see — the Registry applies an RBAC filter it is given; policy lives in RBAC.
- Installing or health-checking instances — control-plane modules feed the Registry; it does not manage instance lifecycle.
Interfaces
// Registry is the live namespaced tool catalog. Concurrency-safe; reads are
// hot-path, writes are event-driven.
type Registry interface {
// List returns the tools visible to a principal, already RBAC-filtered.
List(ctx context.Context, view rbac.ToolView) ([]Tool, error)
// Resolve maps a namespaced tool name to its owning instance for dispatch.
Resolve(name string) (instanceID string, t Tool, ok bool)
// Subscribe streams catalog change events (tool added/removed/schema-changed).
Subscribe(ctx context.Context) (<-chan ChangeEvent, func())
}
// Ingest is the write side, driven by lifecycle + upstream events.
type Ingest interface {
// UpsertInstance (re)loads an instance's tools under its namespace.
UpsertInstance(ctx context.Context, inst MCPInstance, tools []UpstreamTool) error
// RemoveInstance drops all tools owned by an instance.
RemoveInstance(ctx context.Context, instanceID string) error
// OnListChanged handles an upstream notifications/tools/list_changed.
OnListChanged(ctx context.Context, instanceID string) error
}
type Tool struct {
Name string // "{namespace}.{tool}", e.g. "postgres.query"
Namespace string
Bare string // upstream tool name, e.g. "query"
InstanceID string
InputSchema json.RawMessage
Title, Description string
}
HTTP / MCP surface:
| Method / path | Purpose |
|---|---|
MCP tools/list |
Served via the Gateway; RBAC-filtered namespaced catalog. |
MCP notifications/tools/list_changed |
Forwarded to agents when their visible set changes. |
GET /tools |
Dashboard/API view of the full catalog with owning instance, namespace, health, and schema (control-plane, RBAC-guarded). |
Data
- Catalog (in-memory, source of truth at runtime):
map[name]Tool, plus anamespace → instanceIDindex and reverseinstanceID → []name. - Namespacing rule: the namespace is the instance's stable, human-readable
name (from its Recipe/config, e.g.
homeassistant,postgres,github). The fully-qualified tool name is{namespace}.{tool}. - Collision rules:
- Two instances may never share a namespace — enforced at instance registration; a duplicate namespace is a config/reconcile error surfaced to the operator, not silently merged.
- Within a namespace, upstream tool names are already unique per MCP server,
so
{namespace}.{tool}is globally unique by construction. - Bare tool names never reach the agent; only fully-qualified names are
exposed, so unrelated servers with the same tool name (
query) never clash.
- Persistence: the catalog is derived and rebuilt from Inventory +
upstream
tools/liston boot; it is a cache, not a store. Namespace assignments live with theMCPInstancein Inventory.
Dependencies
- Gateway — consumes
Listfortools/list, forwardslist_changed. - RBAC — supplies the
ToolViewfilter applied to every list. - Health Monitoring — health transitions add/remove instances from the catalog.
- Auto Installer — instance create/destroy events.
- AI Agent Profiles — identity → roles → tool view.
- Service Graph — consumes the tool→instance mapping as graph edges.
Failure modes & handling
- Upstream
tools/listfails on ingest: keep the last-known good tool set for that instance, mark it stale, and retry with backoff; do not blank the namespace on a transient error. list_changedstorm: debounce/coalesce refreshes per instance so a flapping upstream cannot thrash the hot path.- Instance vanishes:
RemoveInstancedrops its tools atomically; in-flightResolvefor a removed tool returnsok=false, and the Router returns a clean JSON-RPC "unknown tool" error. - Namespace collision at registration: reject the newer instance's tools, emit an operator notification, and keep the existing namespace intact.
- Cold start: serve an empty catalog that fills as instances report; never block the Gateway waiting for a full rebuild.
Security notes
- Every
tools/listandGET /toolsresponse is RBAC-filtered — an agent cannot even see a tool outside its role (Architecture §9, request scoping). - Cached schemas contain no secret material; secrets live only in the running MCP container (see Secrets Manager).
- Namespaces are validated (charset/length) to prevent spoofing a well-known
namespace like
github.
Open questions
- Namespace assignment when two instances legitimately wrap the same service
type (two Postgres servers): auto-suffix (
postgres,postgres-2) vs. operator-chosen aliases? - How aggressively to pre-fetch/refresh schemas vs. lazy-load on first
tools/call. - Whether to surface tool deprecation/version metadata in the catalog for the Update Manager and Dashboard.
Milestone
Phase 1 (Walking skeleton), alongside the Gateway. Exit:
with a filesystem MCP and one HTTP MCP registered in YAML, an agent's
tools/list shows filesystem.* and other.*, and Resolve maps a chosen
tool to its owning instance for a successful tools/call.