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.
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") | Config object / read-only view | JSON-serializable config 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.). - Duplicate tool names are rejected (built-ins are not overwritten).
- 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)