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_commandmcp__ssh__run) so the model calls it more reliably and skill docs read better.
  • Swap an implementation: point mcp__senclaw-browser__browser_navigate at 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:

SituationBehaviour
alias is a name that does not existRename: 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 toolOverride: 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 → cFollowed 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_navigate

How 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:

PieceWhereRole
In-process registrysrc/tools/tool_alias.rsA 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 layersrc/db/tool_aliases.rsCRUD, the app import/prune, and the enabled-only map the registry consumes
Dispatch resolvesrc/tools/tool_search.rsStage 0 of resolve_tool_by_name — every tool-call path goes through it
Roster decorationsrc/tools/tool_alias.rs and src/zen_core/engine.rsapply_alias_names runs in the two root funnels (main-agent tools, deferred tools); every other funnel derives from those
REST APIsrc/gateway/ui_server/tool_aliases.rsThe five endpoints below
App importsrc/gateway/ui_server/space_mcp.rssync_app_tool_aliases runs inside run_and_register — every install, update, boot and supervisor-respawn path
Web UIweb/src/components/plugins/AliasPanel.tsxThe Alias tab of the Plugins page (/plugins?nav=alias)
Desktop appdesktop_app/lib/features/plugins/plugins_screen.dartThe equivalent Alias section

The resolve path, when an agent calls a tool

resolve_tool_by_name runs two stages:

  1. 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.
  2. 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/:alias

The error semantics:

CodeWhen
400alias 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
404the alias does not exist (PUT, toggle, DELETE)
409POST 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:

  • tool and target are synonyms. A bare name expands to mcp__<mcp.name>__<name>; a full mcp__* name is kept as-is — which is what lets an app override another server's tool.
  • The alias must be of the form mcp__* — an app cannot shadow a builtin tool (Bash, Read, Write…). A bad entry (missing fields, whitespace, alias equal to target, not mcp__*) 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):

  1. App-declared aliases are imported disabled. The user must switch them on under Plugins → Alias before they do anything.
  2. A re-import (app restart or update) refreshes target and description but never touches enabled — the user's opt-in survives updates. (The upsert updates only rows whose source matches.)
  3. 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.
  4. 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.
  5. 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

SymptomThe 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 updateThe new manifest no longer declares it → pruned (by design)
An app's alias came back after being deletedThe 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 logThe 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).