Skip to content

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-inExternal
Locationsrc/mcp/tools/<name>/{user data}/mcp_plugins/<id>/
Entryregister.py → register_*_toolsplugin.py → register(host)
DependenciesApp dependenciesVendored under plugin lib/
ReleaseWith the appIndependent plugin version
User actionUpgrade appInstall / remove plugin folder

Install directory ​

PlatformDefault path
macOS~/Library/Application Support/py-xiaozhi/mcp_plugins/
Windows%LOCALAPPDATA%\py-xiaozhi\mcp_plugins\
Linux~/.local/share/py-xiaozhi/mcp_plugins/

Config override:

json
"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 for host.get (music_player must be listed explicitly).

Package layout ​

text
com.example.hello/
  manifest.json
  plugin.py           # def register(host)
  lib/                # optional vendored deps
  native/             # optional per-platform binaries
  README.txt

manifest.json ​

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
}
FieldMeaning
idUnique id for enable/disable
api_versionMust not exceed host plugin API (currently 1)
min_host_versionOptional; skip if host is older
platformsOptional; skip if OS/arch mismatch
python_abiOptional; e.g. cp310
entrymodule:attr, default plugin:register
runtimepython-inprocess (default, same process) or python-subprocess (child process)
call_timeoutOptional; python-subprocess only — per-call timeout in seconds (default 60)
tool_name_prefixRecommended for tool names

Optional strict flags (default off): ENFORCE_PREFIX, REQUIRE_PYTHON_ABI, REQUIRE_PLATFORMS.

Minimal plugin.py ​

python
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 ​

MethodRole
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_VERSION
  • SYSTEM_OPTIONS.WINDOW_SIZE_MODE
  • SYSTEM_OPTIONS.NETWORK.OTA_VERSION_URL
  • SYSTEM_OPTIONS.NETWORK.AUTHORIZATION_URL
  • SYSTEM_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 typeOne zip for all OSes?What to do
Pure Python (e.g. markdown)Often yesSingle lib/ is fine
Native wheels (.so / .pyd / .dylib)NoBuild and publish per OS + arch
Environment SDKs (ROS2, rclpy, vendor SDKs)Usually cannot vendor fullySee “Environment dependencies” below
text
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.zip

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

bash
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 / -t into plugin lib/ — 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:

PracticeNotes
Document runtime requirementsROS 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 importse.g. import rclpy → “source ROS / install SDK first”
Ship per-platform packagesRobot plugins are often linux-aarch64 / linux-x86_64 only — do not market as universal desktop zips
Desktop exe/dmg ≠ robot imageA Unitree/ROS plugin is not expected to run on a stock desktop install

Example:

python
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
ProcessSame as hostDedicated worker (JSON lines over stdin/stdout)
Crash / hang isolationPoor (can take down the app)Better (timeout can kill the child)
host.get("logger")Host loggerLocal logger in the worker
host.get("config_readonly")Redacted read-only snapshot with get_configSame interface, built from a redacted JSON snapshot
Live objects (e.g. music_player)Allowed if whitelistedNot available (not serializable)
Best forLight, trusted plugins needing host servicesHeavy native deps / isolation from UI & audio

Enable subprocess explicitly in manifest.json:

json
{
  "id": "com.example.hello_sub",
  "entry": "plugin:register",
  "runtime": "python-subprocess",
  "tool_name_prefix": "example.",
  "call_timeout": 30
}

Samples in the repo:

text
examples/mcp_plugins/com.example.hello/        # in-process
examples/mcp_plugins/com.example.hello_sub/  # subprocess

Note: subprocess isolation is not an OS sandbox — same user privileges as the app; it only separates interpreter and address space.

User install steps ​

  1. Get the plugin folder or zip.
  2. Copy/extract under mcp_plugins so manifest.json is present.
  3. Fully quit and restart the app.
  4. Logs should show: [MCP插件] 已加载 <id> (N 工具).
  5. Confirm tools via chat or MCP tools/list.

Repo samples (stdlib only):

text
examples/mcp_plugins/com.example.hello/        # in-process
examples/mcp_plugins/com.example.hello_sub/  # subprocess runtime

Settings: 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_plugins manifests / source heuristics.
  • When disabled: omitted from MCP tools/list; tools/call is 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 ​

bash
python scripts/check_mcp_plugin.py /path/to/com.example.hello
python scripts/check_mcp_plugin.py /path/to/plugin --strict

Naming 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_IDS and restart, or delete the folder.

Runtime APIs (host) ​

APIRole
Startupadd_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=false to 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)