Plugin Author Guide — Shape 1 MCP Subprocess
This is the author-facing companion to docs/plugin-contract.md (the normative contract) and docs/catalog-abi.md (the runtime catalog ABI). The contract tells you *what* the host validates and rejects; this guide walks you through *how to ship* a Shape 1 MCP-subprocess plugin from scratch — manifest, ABI wire format, packaging, install workflow, and a complete worked example.
In v0.1.0 through v0.3.x, Shape 1 (MCP subprocess) is the only externally authorable plugin shape. Shape 2 (gRPC subprocess) is reserved for a v0.4.0 freeze; third-party Shape 2 manifests are rejected at install time with PLUGIN_SHAPE_UNSUPPORTED.
---
1. What a Shape 1 plugin is
A Shape 1 plugin is a subprocess that GUM launches when one of its tools is invoked. The subprocess speaks MCP over stdio (JSON-RPC 2.0 with the standard MCP envelope) — the same wire format any MCP client uses to talk to any MCP server.
Concretely you ship:
- A manifest (
manifest.json) declaring identity, capabilities, sandbox requirements, and the tools the subprocess advertises. - An executable that, when launched with no arguments and its stdio connected to the host, behaves as a conforming MCP stdio server. It MUST implement
initialize,tools/list, andtools/call. - A package (currently any of: GitHub release artifact, Git repo at pinned commit, PyPI wheel/sdist, or a local directory for development).
GUM verifies the executable's SHA-256 at install time, records (executable_path, executable_sha256, argv_normalized, install_root) in the active profile's plugins.lock, and re-hashes on every spawn (spec §8 line 1690). Shell interpreters in executable_path and path escapes outside the install root are rejected with PLUGIN_EXECUTABLE_UNTRUSTED.
---
2. Manifest reference
The manifest is the v1 schema enforced by plugins.LoadManifest. Every field below is required unless marked optional.
{
"manifest_schema_version": 1,
"plugin_id": "fli",
"name": "Flight Search",
"version": "0.1.0",
"namespace_owner": "io.example.flights",
"shape": "mcp-plugin",
"executable": "bin/fli-mcp",
"advertised_tools": [
{
"name": "search",
"description": "Search Google Flights for a route on a date range",
"risk_class": "read"
}
],
"declared_capabilities": {
"network": true,
"fs_write_dir": "",
"env_allow": ["FLI_USER_AGENT"]
}
}Field-by-field
| Field | Type | Constraint | Failure code |
|---|---|---|---|
manifest_schema_version |
integer | Must be exactly 1 in v0.1.0. Must be a sibling of [plugin] in TOML manifests, never nested. |
PLUGIN_MANIFEST_SCHEMA_UNSUPPORTED |
plugin_id |
string | Matches ^[a-z][a-z0-9-]{0,63}$. |
PLUGIN_MANIFEST_INVALID |
name |
string | Free-form display name. | PLUGIN_MANIFEST_INVALID if empty |
version |
string | Free-form (typically semver). | — |
namespace_owner |
string | Reverse-DNS (io.example.foo) or package-registry identity. Required for third-party plugins; first-party bundled fixtures may omit. |
PLUGIN_NAMESPACE_CONFLICT if missing on a third-party install |
shape |
string | Must be "mcp-plugin". |
PLUGIN_SHAPE_UNSUPPORTED |
executable |
string | Path relative to the install directory; absolute paths are rejected during install. | PLUGIN_MANIFEST_INVALID, PLUGIN_EXECUTABLE_UNTRUSTED at install |
advertised_tools[].name |
string | Unprefixed. Host adds plug.<plugin_id>. prefix. |
PLUGIN_MANIFEST_INVALID if empty |
advertised_tools[].risk_class |
string | One of read, write, destructive. |
PLUGIN_MANIFEST_INVALID |
declared_capabilities.network |
bool | Enforced for hosted plugin subprocesses on supported OS backends. Unsupported OS backends fail closed. | PLUGIN_SANDBOX_UNSUPPORTED or sandbox spawn error |
declared_capabilities.fs_write_dir |
string | Enforced for hosted plugin subprocesses. Empty means no writes outside the host-managed data dir. A relative path scopes writes to that subtree. | PLUGIN_FS_WRITE_OUTSIDE_SANDBOX |
declared_capabilities.env_allow |
string[] | Env-var names allowed through to the subprocess. Subject to the GUM_ prefix denylist and the exact-name denylist (GOOGLE_APPLICATION_CREDENTIALS, OPENAI_API_KEY, ANTHROPIC_API_KEY, _GUM*). See docs/plugin-contract.md §needs_user_creds. |
PLUGIN_ENV_PROHIBITED |
Cross-references
- Namespace ownership rules:
docs/plugin-contract.md§third-party namespace ownership. - Reserved first-party prefixes (
gmail,drive,calendar, and similar Google service prefixes):docs/plugin-contract.md§third-party namespace ownership. - Credential descriptors required when
env_allowcarries OAuth-bearing vars:docs/plugin-contract.md§credential descriptors.
---
3. Wire ABI
The host launches the executable, connects its stdin/stdout to the JSON-RPC transport, and immediately issues an MCP initialize. The subprocess MUST:
- Respond to
initializewith its capabilities (in particulartools: {listChanged: true}if tools change at runtime — most plugins set this tofalse). - Respond to
tools/listwith the list of advertised tools and their inputSchemas. Tool names returned here MUST match theadvertised_tools[].namein the manifest (unprefixed; the host adds theplug.<plugin_id>.prefix downstream). - Respond to
tools/callwith either a successful MCPCallToolResultor an MCP error envelope.
3.1 The four messages your plugin must handle
(a) initialize request:
// host → plugin
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18",
"capabilities":{},
"clientInfo":{"name":"gum","version":"0.1.0"}
}}
// plugin → host
{"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"2025-06-18",
"capabilities":{"tools":{"listChanged":false}},
"serverInfo":{"name":"Flight Search","version":"0.1.0"}
}}(b) tools/list:
// host → plugin
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
// plugin → host
{"jsonrpc":"2.0","id":2,"result":{"tools":[
{
"name":"search",
"description":"Search Google Flights for a route on a date range",
"inputSchema":{
"type":"object",
"additionalProperties":false,
"properties":{
"origin":{"type":"string","minLength":3,"maxLength":3},
"destination":{"type":"string","minLength":3,"maxLength":3},
"depart_date":{"type":"string","format":"date"},
"return_date":{"type":"string","format":"date"}
},
"required":["origin","destination","depart_date"]
}
}
]}}(c) tools/call:
// host → plugin
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
"name":"search",
"arguments":{"origin":"SFO","destination":"JFK","depart_date":"2026-06-15"}
}}
// plugin → host (success)
{"jsonrpc":"2.0","id":3,"result":{
"content":[
{"type":"text","text":"{\"success\":true,\"data\":{\"itineraries\":[…]}}"}
],
"isError":false
}}(d) Plugin-local error envelope (spec §8 lines 1624–1641):
When the operation fails, the plugin returns a CallToolResult with isError: true and the body string is a JSON object:
{"success": false,
"error_code": "RATE_LIMIT|AUTH_EXPIRED|PARSE_FAILURE|SERVICE_DOWN|INVALID_INPUT",
"error": "human-readable message",
"retryable": true,
"retry_after_ms": 5000}The host maps these to stable GUM codes (plugins.MapPluginError):
| Plugin emits | Host translates to | Retry/timing rules |
|---|---|---|
RATE_LIMIT |
RATE_LIMITED |
preserve retryable and retry_after_ms (positive only) |
AUTH_EXPIRED |
AUTH_REQUIRED |
force retryable=false; drop retry_after_ms |
PARSE_FAILURE |
SERVICE_DOWN |
preserve retryable from envelope |
SERVICE_DOWN |
SERVICE_DOWN |
preserve both |
INVALID_INPUT |
INVALID_ARGS |
force retryable=false; drop retry_after_ms |
| anything else | SERVICE_DOWN |
force retryable=false; source_error_code preserved |
Plugins MUST NOT emit stable GUM codes directly (RATE_LIMITED, AUTH_REQUIRED, etc.) — those are the host's vocabulary. Always emit one of the five plugin-local codes above.
---
4. Packaging
4.1 Directory layout
my-plugin/
├── manifest.json # the v1 manifest above
├── bin/
│ └── fli-mcp # the executable (executable bit set)
├── README.md # optional but recommended
└── LICENSE # required for non-local installsFor Python (FastMCP-style) plugins, ship a console-script entry point:
my-plugin/
├── manifest.json
├── pyproject.toml # declares console_scripts = fli-mcp = fli_mcp:main
├── src/
│ └── fli_mcp/
│ ├── __init__.py
│ └── server.py
└── requirements.lockRemote package installers are not part of the v1 CLI. Package the plugin as a local directory with a manifest and executable, then run gum plugin install on that directory.
4.2 Install commands
| Source | Command |
|---|---|
| Local directory (dev) | gum plugin install ./my-plugin |
Run gum plugin list to confirm the install:
$ gum plugin list
fli 0.1.0 Flight SearchTo clear a quarantine after a fix:
$ gum plugin reload fli
reloaded fligum plugin remove fli removes the install but preserves the namespace_owner entry in plugins.lock, so a reinstall by the same owner succeeds without re-asserting consent (spec §5.1 transfer procedure).
v0.1.0 note: a dedicated
gum plugin validatesubcommand is not yet wired. Until it lands (planned for a v0.2.0 ergonomics pass), validate by runninggum plugin install ./my-pluginagainst a scratch profile (--profile=dev+XDG_DATA_HOME=/tmp/...); the install path runs the full v1 manifest validator, namespace check, executable-binding rehash, and (if defined) the canary.
---
5. End-to-end example: a hello plugin in Python
This minimal plugin advertises one tool, hello, which returns a deterministic greeting. It uses the mcp Python SDK's stdio server.
Directory layout:
hello-plugin/
├── manifest.json
├── pyproject.toml
└── src/hello_plugin/__init__.pymanifest.json:
{
"manifest_schema_version": 1,
"plugin_id": "hello",
"name": "Hello",
"version": "0.1.0",
"namespace_owner": "io.example.hello",
"shape": "mcp-plugin",
"executable": "hello-mcp",
"advertised_tools": [
{
"name": "hello",
"description": "Return a deterministic greeting for the given name",
"risk_class": "read"
}
],
"declared_capabilities": {
"network": false,
"fs_write_dir": "",
"env_allow": []
}
}pyproject.toml:
[project]
name = "hello-plugin"
version = "0.1.0"
dependencies = ["mcp>=1.0.0"]
[project.scripts]
hello-mcp = "hello_plugin:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"src/hello_plugin/__init__.py:
import json
import sys
from mcp.server.fastmcp import FastMCP
app = FastMCP("Hello")
@app.tool()
def hello(name: str) -> str:
"""Return a deterministic greeting for the given name."""
# GUM expects either a successful payload or a plugin-local error envelope
# per spec §8 lines 1624–1641.
if not name or not isinstance(name, str):
return json.dumps({
"success": False,
"error_code": "INVALID_INPUT",
"error": "name must be a non-empty string",
"retryable": False,
})
return json.dumps({"success": True, "data": {"greeting": f"Hello, {name}!"}})
def main() -> None:
app.run() # stdio transport, blocks until parent closes stdinInstall + smoke-test:
# 1. Build the wheel.
python -m build hello-plugin
# 2. Install into a scratch profile.
GUM_PROFILE=dev gum plugin install ./hello-plugin
# 3. Invoke through the host.
gum plugin run hello hello '{"name":"world"}'
# → {"success":true,"data":{"greeting":"Hello, world!"}}
# 4. Trigger the validator with a bad arg.
gum plugin run hello hello '{"name":""}'
# → host maps INVALID_INPUT → INVALID_ARGS envelopeWhat was validated end-to-end:
- Manifest schema (
manifest_schema_version=1,shape=mcp-plugin, plugin_id regex, namespace_owner). - Namespace-ownership lock for the
hello.prefix inplugins.lock. - Executable binding (SHA-256 hashed into
plugins.lock, re-hashed on every spawn). - MCP handshake (
initialize,tools/list,tools/call). - Plugin-local error envelope mapped to a stable GUM code (
INVALID_INPUT→INVALID_ARGS).
---
6. Common authoring mistakes
| Symptom | Likely cause | Fix |
|---|---|---|
Install fails with PLUGIN_MANIFEST_SCHEMA_UNSUPPORTED |
Forgot to set manifest_schema_version: 1, or placed it inside [plugin] in TOML |
Hoist it to the top level. |
Install fails with PLUGIN_NAMESPACE_CONFLICT |
Another plugin already owns the prefix in this profile's lock, or namespace_owner is missing on a third-party manifest |
Either rename your plugin_id, declare the actual owner string the previous install used, or — in dev only — re-run with --dev-allow-namespace-conflict. |
Spawn fails with PLUGIN_EXECUTABLE_UNTRUSTED |
executable resolves outside the install root, points to a shell interpreter (sh, bash, python), or the file's SHA-256 changed since install |
Repackage with a real entry-point binary; rerun gum plugin install to record the new digest. |
Calls fail with SERVICE_DOWN and source_error_code: <something-weird> |
Plugin emitted an unknown error code; host maps unknowns to SERVICE_DOWN per spec §8 line 1641 |
Use only the five plugin-local codes: RATE_LIMIT, AUTH_EXPIRED, PARSE_FAILURE, SERVICE_DOWN, INVALID_INPUT. |
gum plugin run returns PLUGIN_ENV_PROHIBITED |
env_allow lists a GUM_* var, GOOGLE_APPLICATION_CREDENTIALS, OPENAI_API_KEY, ANTHROPIC_API_KEY, or _GUM* |
Drop the denylisted entry; if you need an OAuth credential, declare it under [requirements].credential_descriptors instead. |
| Plugin crashes on second invocation | Plugin is not stdio-safe: it wrote print(...) to stdout outside the JSON-RPC framing |
Route all logging to stderr; stdout is reserved for the JSON-RPC transport. |
---
7. Going further
- Output profiles: ship an
output_profileper tool to keep responses compact. Seedocs/profile-dsl-reference.mdfor the operator catalogue and worked examples. - Canaries: declare a
[requirements].canaryblock socmd/gen-catalogcan run a known-good call at build time. Use relative date specifiers (+7d,+2w) to avoid stale fixtures. - Quarantine + crash recovery: spec §8.6 describes the exponential-backoff window and the
gum plugin reload/gum plugin unquarantinerecovery commands. - Plugin-shipped profiles: see
docs/plugin-contract.md§profiles for the rules that govern profiles bundled inside a plugin.
For runtime catalog ABI (variant IDs, capability atoms, binding shape), read docs/catalog-abi.md. For the normative install contract and field-level wire requirements, read docs/plugin-contract.md.
