Aliasing MCP tools
Give a tool a new name, or override it with another implementation — no code edits, no daemon restart: the alias registry, the resolve path, the REST API, and the manifest declaration with its approval gate.
An MCP tool alias gives a tool a new name (rename) or overrides an existing tool with a different one (override) — without editing code and without restarting the daemon. Aliases are managed under Plugins → Alias (Web UI and desktop app), stored in the SQLite table mcp_tool_aliases, and applied on every tool-call path an agent has.
The typical uses:
- Shorten an unwieldy name (
mcp__ssh-manager-mcp__ssh_execute_command→mcp__ssh__run) so the model calls it more reliably and skill docs read better. - Swap an implementation: point
mcp__senclaw-browser__browser_navigateat another app's navigate tool — every skill and persona calling the old name runs the new tool, untouched. - A Space App proposes aliases in its manifest (
mcp.toolAliases), and they take effect only after the user approves them.
One mapping, two behaviours
An alias is a mapping alias → target. There is no mode flag — rename versus override is inferred from whether the alias name collides with a registered tool:
| Situation | Behaviour |
|---|---|
alias is a name that does not exist | Rename: the roster sent to the LLM shows the target tool under the alias name (with the alias's description, if one was given). The original name still resolves (via the tool's renamed-from record), so old transcripts, skill docs and stale whitelists keep working |
alias collides with a registered tool | Override: every call to that name is redirected to target before exact-match, hiding the original. The roster does not change — the LLM keeps seeing the old name and description |
target does not exist (app off, typo) | Fallback to the original tool plus a logged warning — an alias never kills a working tool |
A chain a → b → c | Followed to the final target. Cycle-guarded: capped at 8 hops, and a loop (a → b → a) degrades to the original name |
Both behaviours, same data shape:
# Rename — "mcp__ssh__run" does not exist yet → the roster shows the new name
alias = mcp__ssh__run
target = mcp__ssh-manager-mcp__ssh_execute_command
# Override — the alias collides with a real registered tool → every call is redirected
alias = mcp__senclaw-browser__browser_navigate
target = mcp__mini-browser-mcp__mb_navigateHow it hangs together
WRITE LOAD APPLY
┌────────────────────────┐
│ Web UI / Desktop │ REST /api/tool-aliases*
│ (Plugins → Alias) │────────────┐
└────────────────────────┘ ▼
┌────────────────────────┐ ┌──────────────────┐ reload_from_db() ┌──────────────────────┐
│ Space App manifest │ │ mcp_tool_aliases │ (boot + after │ in-process registry │
│ (mcp.toolAliases — │──►│ (SQLite) │──── every mutation)─►│ enabled aliases only │
│ imported DISABLED) │ └──────────────────┘ └──────────┬───────────┘
└────────────────────────┘ │
┌─────────────────────────────────┴──────┐
▼ ▼
dispatch — stage 0 of roster decoration —
resolve_tool_by_name() apply_alias_names()
(override + rename) (names the LLM sees; rename only)The pieces, and where they live in the SenClaw repo:
| Piece | Where | Role |
|---|---|---|
| In-process registry | src/tools/tool_alias.rs | A process-wide map (OnceLock<RwLock<HashMap<alias, AliasEntry>>>) holding enabled aliases only. Loaded at boot (right after the DB opens) and reloaded after every REST mutation or app import — changes apply from the next turn, no daemon restart |
| DB layer | src/db/tool_aliases.rs | CRUD, the app import/prune, and the enabled-only map the registry consumes |
| Dispatch resolve | src/tools/tool_search.rs | Stage 0 of resolve_tool_by_name — every tool-call path goes through it |
| Roster decoration | src/tools/tool_alias.rs and src/zen_core/engine.rs | apply_alias_names runs in the two root funnels (main-agent tools, deferred tools); every other funnel derives from those |
| REST API | src/gateway/ui_server/tool_aliases.rs | The five endpoints below |
| App import | src/gateway/ui_server/space_mcp.rs | sync_app_tool_aliases runs inside run_and_register — every install, update, boot and supervisor-respawn path |
| Web UI | web/src/components/plugins/AliasPanel.tsx | The Alias tab of the Plugins page (/plugins?nav=alias) |
| Desktop app | desktop_app/lib/features/plugins/plugins_screen.dart | The equivalent Alias section |
The resolve path, when an agent calls a tool
resolve_tool_by_name runs two stages:
- Stage 0 — the alias map. Look the name up in the registry (enabled aliases only), follow chains with the cycle guard (8-hop cap; a loop returns the original name). If the result differs from the called name, resolve the target with the normal cascade minus aliases (so the map is never re-entered). Found → return the target tool; this is how an override hides the original. Not found (app off, typo) → log a warning and fall through to stage 2 with the original name — a broken alias never breaks a working tool.
- The plain cascade: exact match → normalized MCP name → renamed-from (a renamed tool still resolves by its original registered name — old transcripts, skill docs and hardcoded lists keep working) → hyphen/underscore folding → server-plus-verb-suffix match.
Every caller goes through this one function — the execution loop, ToolSearch select, the use_tools whitelist, isolated runners — so an alias behaves identically system-wide.
Roster decoration: rename only
apply_alias_names runs in the engine's two root tool funnels and decorates what the LLM sees:
- An alias that does not collide with a registered tool wraps the target in an AliasedTool: the name becomes the alias, the description is replaced by the alias's (when given), and everything else — schema, read-only flag, permissions, display title — delegates untouched to the original. The wrapper records the original name for the resolve cascade above.
- An alias that collides (an override) is skipped — the roster stays as it was; the redirect happens at dispatch.
- A target that is not registered is skipped.
- Each tool is renamed at most once (a tool already renamed keeps its current name; later aliases still work at dispatch).
- Decoration is idempotent and deterministic (applied in sorted order) — repeated funnel calls cannot reshuffle the roster, which keeps prompt caching stable.
Storage
CREATE TABLE IF NOT EXISTS mcp_tool_aliases (
alias TEXT PRIMARY KEY,
target_tool TEXT NOT NULL,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'user', -- 'user' | 'app:<app_id>'
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);source = 'user': created from the UI or REST.source = 'app:<id>': imported from a Space App manifest.- The registry loads only rows with
enabled = 1.
REST API
Every mutation reloads the registry immediately — effective from the next turn.
GET /api/tool-aliases → { aliases: [...] } # every source
POST /api/tool-aliases { alias, target, description?, enabled? }
# source=user; enabled defaults to true
PUT /api/tool-aliases/:alias { target, description? }
# user-owned aliases ONLY
POST /api/tool-aliases/:alias/enabled { enabled } # the approval gate for app aliases
DELETE /api/tool-aliases/:aliasThe error semantics:
| Code | When |
|---|---|
| 400 | alias or target empty or containing whitespace; alias equals target; PUT on an app-managed alias (source is not user) — its target is the manifest's decision, so it can only be toggled or deleted |
| 404 | the alias does not exist (PUT, toggle, DELETE) |
| 409 | POST with an alias name that already exists (create never overwrites) |
curl -s localhost:18788/api/tool-aliases | jq
curl -s -X POST localhost:18788/api/tool-aliases \
-H 'Content-Type: application/json' \
-d '{"alias":"mcp__ssh__run","target":"mcp__ssh-manager-mcp__ssh_execute_command","description":"Run a command on a saved host"}'The UI (Plugins → Alias)
On the web (/plugins?nav=alias) and in the desktop app's matching section:
- A table of every alias: alias, target tool, an override / new name badge (inferred by checking the alias name against the live tool list of /api/mcp-servers — a collision means override), the source (User, or App plus the app id), description, an enable switch, edit and delete.
- Add alias: a two-field form with autocompletion over the registered tool names, plus an optional description.
- App-declared aliases can only be toggled or deleted — editing the target is blocked, matching the REST rule above.
Declaring aliases from a Space App manifest
In senclaw-manifest.json, inside the mcp block (which needs autoRegister: true — the alias import runs in the same step that registers the app's MCP; see Building a Space App):
"mcp": {
"name": "ssh-manager-mcp",
"transport": "http",
"path": "/api/mcp/sse",
"autoRegister": true,
"toolAliases": [
{ "alias": "mcp__ssh__run", "tool": "ssh_execute_command", "description": "Run a command on a saved host" },
{ "alias": "mcp__senclaw-browser__browser_navigate", "target": "mcp__ssh-manager-mcp__ssh_open_url" }
]
}The parse rules:
toolandtargetare synonyms. A bare name expands tomcp__<mcp.name>__<name>; a fullmcp__*name is kept as-is — which is what lets an app override another server's tool.- The
aliasmust be of the formmcp__*— an app cannot shadow a builtin tool (Bash, Read, Write…). A bad entry (missing fields, whitespace, alias equal to target, notmcp__*) is skipped with a warning and never blocks the app from running. - The import runs inside run_and_register — every install, update, boot and supervisor-respawn path — so the stored aliases always match the current manifest.
The approval rules (the safety story):
- App-declared aliases are imported disabled. The user must switch them on under Plugins → Alias before they do anything.
- A re-import (app restart or update) refreshes
targetanddescriptionbut never touchesenabled— the user's opt-in survives updates. (The upsert updates only rows whose source matches.) - An app cannot take over an alias owned by another source (the user, or another app) — the same source guard turns the upsert into a no-op when owners differ.
- Aliases the manifest no longer declares are pruned on the next import; removing the app deletes every alias of its source and reloads the registry at once.
- Deleting an app's alias by hand in the UI brings it back disabled the next time the app starts (the manifest still declares it) — to keep it off permanently, leave it disabled instead.
How aliases interact with the rest of the system
- Read-only classification (which decides whether tools run in parallel or sequentially) goes through the same resolver — overriding a read-only tool with a side-effectful one will not slip into the parallel branch.
- Permissions key off the tool that actually executes after resolve: an override uses the target's key; a rename uses the alias name (the roster tool carries the alias).
- The use_tools whitelist (personas, groups) resolves every entry through the resolver, so a whitelist written with original names still matches renamed tools, and vice versa.
- ToolSearch and deferred tools show alias names (the deferred funnel is decorated) — the model searches for and loads schemas under the new name.
- Prompt caching: decoration is deterministic and idempotent, so the roster cannot shuffle between turns.
Troubleshooting
| Symptom | The usual cause |
|---|---|
| The alias has no effect | (1) An app alias that was never enabled — they import disabled; switch it on under Plugins → Alias. (2) Changes apply from the next turn — a session mid-turn does not change. (3) The target is not registered (app not running, autoRegister missing) — check GET /api/mcp-servers |
| A tool "does not behave like its docs" | It may be overridden — GET /api/tool-aliases and look for an alias colliding with that tool's name |
| An app's alias vanished after an app update | The new manifest no longer declares it → pruned (by design) |
| An app's alias came back after being deleted | The app restart re-imported it from the manifest (disabled) — by design; leave it disabled to keep it off |
| A "target not registered, falling back" warning in the log | The target is misspelled or its app is off — fix the target or start the app; the original tool keeps working meanwhile |
In the SenClaw repo
The UI has a standalone dev harness (cargo build --example alias_ui_harness, serving /plugins?nav=alias on port 18988 over a throwaway DB seeded with two sample aliases — it never touches a running daemon or ~/.senclaw). Two test suites cover the mechanism: cargo test --lib tool_alias (registry chains and cycles, manifest parsing, DB round-trips, enabled-preservation on re-import, the cross-source theft guard, prune, and rename/override resolution plus roster decoration) and cargo test --test tool_alias_api (REST end-to-end on the real router: create → 409 → validation → app import → blocked PUT → the enable gate → update → disable → delete).