MCP External Plugins
For packaged apps (exe / dmg) and dev installs: plugin authors ship a self-contained Python plugin package (dependencies vendored). End users drop it into a fixed directory—no pip install, no changes to the main repo.
For built-in tools, see the MCP Development Guide. Built-ins and external plugins coexist.
In one line
Author builds package (plugin.py + manifest.json + lib/) → user copies into mcp_plugins → restart app.
Built-in vs external
| Built-in | External | |
|---|---|---|
| Location | src/mcp/tools/<name>/ | {user data}/mcp_plugins/<id>/ |
| Entry | register.py → register_*_tools | plugin.py → register(host) |
| Dependencies | App dependencies | Vendored under plugin lib/ |
| Release | With the app | Independent plugin version |
| User action | Upgrade app | Install / remove plugin folder |
Install directory
| Platform | Default path |
|---|---|
| macOS | ~/Library/Application Support/py-xiaozhi/mcp_plugins/ |
| Windows | %LOCALAPPDATA%\py-xiaozhi\mcp_plugins\ |
| Linux | ~/.local/share/py-xiaozhi/mcp_plugins/ |
Config override:
"MCP_PLUGINS": {
"ENABLED": true,
"DIR": null,
"ENABLED_IDS": [],
"DISABLED_IDS": [],
"ALLOW_HOST_GET": ["config_readonly", "logger"]
}DIR: null→ default path above.DISABLED_IDS→ plugin not loaded at startup.ALLOW_HOST_GET→ whitelist forhost.get(music_playermust be listed explicitly).
Package layout
com.example.hello/
manifest.json
plugin.py # def register(host)
lib/ # optional vendored deps
native/ # optional per-platform binaries
README.txtmanifest.json
{
"id": "com.example.hello",
"name": "Hello Demo",
"version": "1.0.0",
"api_version": 1,
"entry": "plugin:register",
"runtime": "python-inprocess",
"tool_name_prefix": "example.",
"enabled_by_default": true
}| Field | Meaning |
|---|---|
id | Unique id for enable/disable |
api_version | Must not exceed host plugin API (currently 1) |
min_host_version | Optional; skip if host is older |
platforms | Optional; skip if OS/arch mismatch |
python_abi | Optional; e.g. cp310 |
entry | module:attr, default plugin:register |
runtime | python-inprocess (default, same process) or python-subprocess (child process) |
call_timeout | Optional; python-subprocess only — per-call timeout in seconds (default 60) |
tool_name_prefix | Recommended for tool names |
Optional strict flags (default off): ENFORCE_PREFIX, REQUIRE_PYTHON_ABI, REQUIRE_PLATFORMS.
Minimal plugin.py
def register(host):
@host.tool(
name="example.hello",
description="Say hello. Optional arg: name.",
props=[{"name": "name", "type": "string", "default": "world"}],
)
async def hello(args):
name = (args or {}).get("name") or "world"
return f"Hello, {name}!"You may also use host.add_tool(McpTool(...)) with the same types as built-ins (src.mcp.tooling).
Host API
| Method | Role |
|---|---|
host.add_tool(tool) | Register an McpTool |
host.tool(name, description, props=None) | Decorator sugar (no global registry) |
host.get(name) | Whitelisted capabilities; otherwise None |
Default whitelist: config_readonly, logger. Setting ALLOW_HOST_GET explicitly to [] denies all Host capabilities.
Both runtimes expose config_readonly as an independent, recursively read-only snapshot with get_config(path, default=None) and mapping access, without configuration write methods. Only these paths are available:
CONFIG_VERSIONSYSTEM_OPTIONS.WINDOW_SIZE_MODESYSTEM_OPTIONS.NETWORK.OTA_VERSION_URLSYSTEM_OPTIONS.NETWORK.AUTHORIZATION_URLSYSTEM_OPTIONS.NETWORK.ACTIVATION_VERSION
URLs omit credentials, query strings and fragments. All other paths, including tokens, MQTT credentials and camera API keys, return the requested default. Plugins that previously used the full configuration manager must migrate. Workers receive only authorized capability snapshots.
Do not rely on removed global @mcp_tool or get_instance singletons.
Vendoring dependencies and multi-platform releases
Rules of thumb
| Dependency type | One zip for all OSes? | What to do |
|---|---|---|
Pure Python (e.g. markdown) | Often yes | Single lib/ is fine |
Native wheels (.so / .pyd / .dylib) | No | Build and publish per OS + arch |
Environment SDKs (ROS2, rclpy, vendor SDKs) | Usually cannot vendor fully | See “Environment dependencies” below |
Recommended artifact names (best when natives are involved)
com.example.foo-1.0.0-macos-arm64.zip
com.example.foo-1.0.0-windows-amd64.zip
com.example.foo-1.0.0-linux-x86_64.zipEach zip contains a full plugin folder; set manifest.platforms to that platform and python_abi to the host Python tag used at build time (e.g. cp310).
Users download only their platform and extract into mcp_plugins/.
Build commands (authors)
On the target OS/arch, with the same Python minor as the host:
uv pip install -r requirements.txt --target lib/ --python /path/to/host/python
# or: python -m pip install -r requirements.txt -t lib/- Use
--target/-tinto pluginlib/— do not install into the app venv. - Native wheels must be built on matching CI/runners.
- Zip the entire plugin directory including
lib/.
Environment dependencies (ROS2 / Unitree Python SDK, etc.)
These usually live in the robot system environment (e.g. source /opt/ros/humble/setup.bash, vendor site-packages). They are not typical portable PyPI trees and often cannot be fully vendored into lib/ for arbitrary PCs.
Recommended practice:
| Practice | Notes |
|---|---|
| Document runtime requirements | ROS distro, vendor SDK, Python, arch (e.g. linux-aarch64) in README / release notes |
Vendor only portable PyPI deps in lib/ | HTTP clients, small pure-Python helpers, etc. |
| Fail clearly on missing env imports | e.g. import rclpy → “source ROS / install SDK first” |
| Ship per-platform packages | Robot plugins are often linux-aarch64 / linux-x86_64 only — do not market as universal desktop zips |
| Desktop exe/dmg ≠ robot image | A Unitree/ROS plugin is not expected to run on a stock desktop install |
Example:
def register(host):
try:
import rclpy # from system ROS, not lib/
except ImportError as e:
raise RuntimeError(
"ROS2 Python (rclpy) is required. "
"Run the host in a sourced ROS environment or install the distro."
) from e
...Vendored lib/ avoids end-user pip; it is not process isolation by itself.
Runtimes: python-inprocess vs python-subprocess
Subprocess is not the default. Omit runtime or set python-inprocess to load the plugin in the host Python process.
python-inprocess (default) | python-subprocess | |
|---|---|---|
| Process | Same as host | Dedicated worker (JSON lines over stdin/stdout) |
| Crash / hang isolation | Poor (can take down the app) | Better (timeout can kill the child) |
host.get("logger") | Host logger | Local logger in the worker |
host.get("config_readonly") | Redacted read-only snapshot with get_config | Same interface, built from a redacted JSON snapshot |
Live objects (e.g. music_player) | Allowed if whitelisted | Not available (not serializable) |
| Best for | Light, trusted plugins needing host services | Heavy native deps / isolation from UI & audio |
Enable subprocess explicitly in manifest.json:
{
"id": "com.example.hello_sub",
"entry": "plugin:register",
"runtime": "python-subprocess",
"tool_name_prefix": "example.",
"call_timeout": 30
}Samples in the repo:
examples/mcp_plugins/com.example.hello/ # in-process
examples/mcp_plugins/com.example.hello_sub/ # subprocessNote: subprocess isolation is not an OS sandbox — same user privileges as the app; it only separates interpreter and address space.
User install steps
- Get the plugin folder or zip.
- Copy/extract under
mcp_pluginssomanifest.jsonis present. - Fully quit and restart the app.
- Logs should show:
[MCP插件] 已加载 <id> (N 工具). - Confirm tools via chat or MCP
tools/list.
Repo samples (stdlib only):
examples/mcp_plugins/com.example.hello/ # in-process
examples/mcp_plugins/com.example.hello_sub/ # subprocess runtimeSettings: per-tool enable/disable (MCP_TOOLS)
In the app: Settings → MCP tools. Tools are grouped by package/plugin id; toggle one tool or a whole group.
- Config key:
MCP_TOOLS.DISABLED(array of full tool names; default[]= all on). - Built-in catalog is scanned from
src/mcp/tools/*/register.py(group = folder name, e.g.music/app). - External tools come from
mcp_pluginsmanifests / source heuristics. - When disabled: omitted from MCP
tools/list;tools/callis rejected. - After save: if a protocol session is open, the app disconnects and reconnects so the server can refresh the tool list; reconnect stays idle (does not auto-enter listening).
- If not connected, config is stored and applies on the next connect.
Difference from MCP_PLUGINS.DISABLED_IDS: the latter skips loading a whole package; the former trims exposure of already-registered tools.
Developer check
python scripts/check_mcp_plugin.py /path/to/com.example.hello
python scripts/check_mcp_plugin.py /path/to/plugin --strictNaming and conflicts
- Prefer
tool_name_prefix(e.g.example.). - A duplicate tool name rejects the entire plugin registration and preserves existing tools and ownership. Tools are committed together after
register(host)returns and prefix/name checks pass; exceptions publish no partial tools. Cached Hosts cannot register tools after this registration window closes. - Unload, reload and application shutdown reap workers belonging to that container, including zero-tool workers. In-process unload removes tools but does not clean up plugin-created threads, module caches or other side effects.
- Worker JSON uses UTF-8; plugin standard output is redirected to the logging channel, separate from responses.
- Disable: add id to
DISABLED_IDSand restart, or delete the folder.
Runtime APIs (host)
| API | Role |
|---|---|
| Startup | add_common_tools → load_mcp_plugins_from_config |
server.unload_plugin(plugin_id) | Remove that plugin’s tools; terminates subprocess workers if any |
server.reload_external_plugins(...) | Unload externals and rescan |
Signing and a plugin store remain future work.
Security
python-inprocess: same process and privileges as the app.python-subprocess: better crash isolation; still same OS user — not a security sandbox.- Install only trusted packages. Set
MCP_PLUGINS.ENABLED=falseto disable all external plugins.
See also
- Built-in MCP guide
- Samples:
examples/mcp_plugins/ - Code:
src/mcp/plugins/(host.py,loader.py,registry.py,subprocess_runtime.py,subprocess_worker.py)