ADR-006: Tool Registry Architecture β Modular 7-Module Design
Metadataβ
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-06 |
| Linear | CAB-841 (Refactoring), CAB-603 (Core vs Proxied), CAB-605 (Consolidation) |
Contextβ
The MCP Gateway's tool registry is the central component for managing AI-accessible tools. Initially implemented as a single 1984-line file, it became unmaintainable as features grew:
- Core platform tools (stoa_*)
- Proxied tenant API tools (
{tenant}:{api}:{operation}) - External MCP server tools (Linear, GitHub, etc.)
- Deprecation management for tool renaming
- K8s CRD-based tool discovery
The Problemβ
"The tool registry grew into a god object. Adding a feature means understanding 2000 lines of code."
The monolithic design caused:
- Merge conflicts on every PR
- Cognitive overload for contributors
- Difficulty testing individual concerns
- Slow iteration on new features
Decisionβ
Refactor the tool registry into a modular mixin-based architecture with single-responsibility modules.
Architectureβ
File Structureβ
mcp-gateway/src/services/tool_registry/
βββ __init__.py # ToolRegistry class (mixin composition)
βββ models.py # DeprecatedToolAlias dataclass
βββ exceptions.py # ToolNotFoundError
βββ singleton.py # Module-level get_tool_registry()
β
βββ registration.py # RegistrationMixin β register/unregister tools
βββ lookup.py # LookupMixin β get/list/search tools
βββ deprecation.py # DeprecationMixin β alias management
βββ invocation.py # InvocationMixin β main invoke() entry point
βββ core_routing.py # CoreRoutingMixin β route to core tools
βββ action_handlers.py # ActionHandlersMixin β handle_*_action methods
βββ proxied.py # ProxiedMixin β invoke proxied tenant tools
βββ external.py # ExternalMixin β external MCP servers
βββ legacy.py # LegacyMixin β backward compatibility
Module Responsibilitiesβ
RegistrationMixin (registration.py)β
Handles tool lifecycle:
class RegistrationMixin:
async def register_tool(self, tool: Tool) -> None: ...
async def unregister_tool(self, tool_name: str) -> None: ...
async def _register_core_tools(self) -> None: ...
async def _register_builtin_tools(self) -> None: ...
LookupMixin (lookup.py)β
Tool discovery and search:
class LookupMixin:
async def get_tool(self, name: str) -> Tool | None: ...
async def list_tools(self, tenant_id: str = None) -> list[Tool]: ...
async def search_tools(self, query: str) -> list[Tool]: ...
def _resolve_deprecated_alias(self, name: str) -> str | None: ...
DeprecationMixin (deprecation.py)β
Manages tool renaming with backward compatibility:
class DeprecationMixin:
_deprecated_aliases: dict[str, DeprecatedToolAlias]
async def _register_deprecation_aliases(self) -> None: ...
def add_deprecated_alias(self, old_name: str, new_name: str) -> None: ...
Aliases provide 60-day backward compatibility:
@dataclass
class DeprecatedToolAlias:
old_name: str
new_name: str
deprecated_at: datetime
expires_at: datetime # deprecated_at + 60 days
InvocationMixin (invocation.py)β
Main entry point for tool execution:
class InvocationMixin:
async def invoke(
self,
tool_name: str,
arguments: dict,
context: InvocationContext
) -> ToolResult: ...
Routes to appropriate handler based on tool type.
CoreRoutingMixin (core_routing.py)β
Routes to platform tools:
class CoreRoutingMixin:
async def _invoke_core_tool(
self,
tool: CoreTool,
action: str,
arguments: dict,
context: InvocationContext
) -> ToolResult: ...
ActionHandlersMixin (action_handlers.py)β
Domain-specific action handlers:
class ActionHandlersMixin:
async def _handle_catalog_action(self, action: str, args: dict) -> ToolResult: ...
async def _handle_subscription_action(self, action: str, args: dict) -> ToolResult: ...
async def _handle_observability_action(self, action: str, args: dict) -> ToolResult: ...
async def _handle_platform_action(self, action: str, args: dict) -> ToolResult: ...
async def _handle_uac_action(self, action: str, args: dict) -> ToolResult: ...
async def _handle_security_action(self, action: str, args: dict) -> ToolResult: ...
ProxiedMixin (proxied.py)β
Invokes tenant API tools:
class ProxiedMixin:
async def _invoke_proxied_tool(
self,
tool: ProxiedTool,
arguments: dict,
context: InvocationContext
) -> ToolResult: ...
ExternalMixin (external.py)β
Manages external MCP servers (Linear, GitHub, etc.):
class ExternalMixin:
async def register_external_server(self, server: ExternalMCPServer) -> None: ...
async def invoke_external_tool(self, tool_name: str, args: dict) -> ToolResult: ...
Tool Typesβ
Core Tools (stoa_*)β
Platform-provided tools with action-based design:
| Tool | Domain | Actions |
|---|---|---|
stoa_catalog | CATALOG | list_apis, get_api, search |
stoa_subscription | SUBSCRIPTION | list, create, revoke |
stoa_observability | OBSERVABILITY | get_metrics, get_logs |
stoa_platform | PLATFORM | get_status, list_tenants |
stoa_uac | UAC | validate, generate |
stoa_security | SECURITY | check_policy, audit |
Proxied Tools ({tenant}:{api}:{operation})β
Dynamically registered from tenant APIs:
# Example: acme:payment-api:create_payment
ProxiedTool(
name="acme:payment-api:create_payment",
tenant_id="acme",
api_id="payment-api",
operation="create_payment",
endpoint="https://api.<YOUR_DOMAIN>/acme/payment-api/v1/payments",
method="POST"
)
External Toolsβ
Tools from external MCP servers:
ExternalTool(
name="linear:create_issue",
server_id="linear-mcp",
original_name="create_issue"
)
Class Compositionβ
The main ToolRegistry class composes all mixins:
class ToolRegistry(
DeprecationMixin,
RegistrationMixin,
LookupMixin,
InvocationMixin,
CoreRoutingMixin,
ActionHandlersMixin,
ProxiedMixin,
ExternalMixin,
LegacyMixin,
):
def __init__(self) -> None:
self._core_tools: dict[str, CoreTool] = {}
self._proxied_tools: dict[str, ProxiedTool] = {}
self._external_tools: dict[str, ExternalTool] = {}
self._deprecated_aliases: dict[str, DeprecatedToolAlias] = {}
self._http_client: httpx.AsyncClient | None = None
Storage Strategyβ
Different tool types use separate dictionaries:
| Storage | Key Format | Tool Type |
|---|---|---|
_core_tools | stoa_DOMAIN | CoreTool |
_proxied_tools | TENANT:API:OPERATION | ProxiedTool |
_external_tools | SERVER:TOOL_NAME | ExternalTool |
_deprecated_aliases | OLD_NAME | DeprecatedToolAlias |
Consequencesβ
Positiveβ
- Single Responsibility β Each mixin handles one concern
- Testability β Mixins can be tested in isolation
- Readability β Files are 200-400 lines instead of 2000
- Parallelism β Multiple developers can work on different mixins
- Extensibility β New tool types add new mixins
Negativeβ
- Mixin Complexity β Multiple inheritance requires careful ordering
- Discoverability β Code spread across files
- Circular Dependencies β Mixins must avoid importing each other
Mitigationsβ
| Challenge | Mitigation |
|---|---|
| Mixin ordering | Documented in __init__.py, CI enforces |
| Discoverability | Clear file naming, docstrings |
| Circular deps | Pass dependencies via __init__, not imports |
Performanceβ
Tool lookup is O(1) via dictionary:
async def get_tool(self, name: str) -> Tool | None:
# Check core tools first (most common)
if name in self._core_tools:
return self._core_tools[name]
# Check deprecated aliases
resolved = self._resolve_deprecated_alias(name)
if resolved:
return await self.get_tool(resolved)
# Check proxied tools
if name in self._proxied_tools:
return self._proxied_tools[name]
# Check external tools
return self._external_tools.get(name)
Referencesβ
- mcp-gateway/src/services/tool_registry/
- ADR-012 β MCP RBAC Architecture
- CAB-841 β Tool Registry Refactoring
- CAB-603 β Core vs Proxied Separation
- CAB-605 β Tool Consolidation
Standard Marchemalo: A 40-year veteran architect understands in 30 seconds