Plugin Contract
This document is normative for GUM plugin authoring and install-time validation. It is the plugin-authoring entry point; docs/catalog-abi.md owns the runtime catalog state model, and spec.md §13 owns MCP resource wire behavior.
Companion Contracts
Plugin authors must also follow these supporting contracts:
spec.md§8 for manifest field semantics, host services by shape, the canary spawn probe, crash/quarantine behavior, atomic registry updates, and install/restart semantics.spec.md§13 for MCP inventory resource-template wire behavior.docs/expression-profile-dsl.mdanddocs/expression-profile-dsl.jsonfor output-profile syntax, validation, tests, and MCP-root-based project-local lookup.docs/catalog-abi.mdfor stable IDs,backend_kind, capability atoms, variant lifecycle, andnull_elision_safe_fields.docs/test-matrix.mdfor required proof artifacts.
The author-facing walkthrough — manifest field-by-field, wire ABI with worked JSON-RPC exchanges, packaging layouts, install workflow, and an end-to-end hello plugin — is docs/plugin-author-guide.md. Read this contract for the *what is required*; read the author guide for *how to ship one*.
Plugin Shapes
| Shape | Status | Expansion role |
|---|---|---|
| Shape 1: MCP subprocess | Supported since v2.0.0 | Accepts existing FastMCP/Python plugins. The plugin owns HTTP/TLS/cookies/retry/rate-limit internals and is fully trusted as user-level code. Not policy-complete. |
| Shape 2: gRPC subprocess | Future authoring model; no target release | Host provides HTTP client, cookie jar, retry, rate limiter, credential access, cache, logging, and optional headless browser. This is the future preferred unofficial expansion substrate once the public SDK/proto is frozen. |
Shape 1 is the only supported external authoring path. Shape 1 plugins are allowed only when their manifest declares the infrastructure they own, ships schemas/output profiles/canaries, and accepts the trust warning. Shape 2 is the long-term unofficial expansion substrate, but it is not an authoring contract until a release freezes the public plugin SDK and proto.
gum plugin install rejects third-party Shape 2 manifests with PLUGIN_SHAPE_UNSUPPORTED. LoadManifest accepts shape = "mcp-plugin" and nothing else, so any other shape and any grpc-plugin binding fails there. The Shape 2 gate runs immediately after structural manifest parsing has identified shape and backend_kind, wins over PLUGIN_BINDING_INVALID, and happens before schema copy, executable staging, canary execution, or registry writes. Shape 2 binding examples in the docs are ABI fixtures only, not an external authoring surface.
Shape 2 notes (future authoring contract)
Shape 2 plugins are Go subprocesses that communicate with the host via GUM's plugin gRPC interface. Until a release publishes a public importable SDK/proto package, plugin authors MUST NOT depend on GUM internal/... packages or treat any local development path as stable. A canonical go.work stub and public module path will be published when the Shape 2 interface is frozen.
Manifest ABI
The manifest is manifest.json, a JSON object. LoadManifest (internal/plugins/host.go) reads <dir>/manifest.json and unmarshals it; there is no TOML form and no [plugin] table. Every field below is a top-level member unless stated otherwise.
Every plugin manifest MUST declare:
manifest_schema_version, exactly1, at the top level. A copy nestedplugin_id, matching^[a-z][a-z0-9-]{0,63}$. It is also the namespacename,version,namespace_owner,shape("mcp-plugin"), anddeclared_capabilities:network,fs_write_dir,env_allow.- optional
command, an install-time selector; non-dev runtime execution - optional
package:source,ref,checksum.
inside a plugin member is refused even when it names a supported version.
prefix the install claims.
executable (a relative path inside the install root).
always uses the normalized executable binding recorded in the selected profile's plugins.lock.
License, ToS status, and risk are not manifest fields. Install derives the lock row's risk from the package source: a source with no pin and no checksum behind it is marked dev-untrusted.
Command normalization (normative). Install resolves command once and records the result as argv_normalized in the selected profile's plugins.lock, alongside executable_path, executable_sha256, and install_root. The spawn path launches that argv and never re-reads the selector. For every source except pypi, resolution is exact: command[0] MUST name the manifest's executable (./bin/mcp and bin/mcp both match bin/mcp), and the residual tokens become argv_normalized[1:]. A PATH-only token, a shell interpreter, an absolute path, a traversal, and a wrapper script outside the declared executable each fail with PLUGIN_EXECUTABLE_UNTRUSTED before any file is copied. An omitted command means the executable takes no arguments. Dev profiles accept an unresolvable command[0] and still bind the declared executable. For source = "pypi", install drops an optional leading uvx or pipx selector token, reads the next token as the console-script name, requires executable = "venv/bin/<script>", and records argv_normalized = [<install_root>/venv/bin/<script>] + rest; the manifest example command = ["uvx", "fli", "mcp"] becomes <install_root>/venv/bin/fli mcp and uvx is never spawned. The dev escape hatch does not apply to pypi.
Package sources (normative). package.source selects the resolver: local (the default when package is omitted), bundled, github_release, git, or pypi. checksum is sha256: plus 64 hex digits. Manifest load validates the block (validatePackageDecl in internal/plugins/source.go): pypi requires ref = "<name>==<exact version>" and a checksum; github_release requires an https URL ending in .tar.gz, .tgz, or .zip and a checksum; git takes an https or file URL, optionally suffixed @<40-hex commit>. Install materializes the declared source into the install root, then copies the manifest tree over it, so curated manifest files and schemas always win over fetched artifact contents. pypi selects the index artifact whose digest equals the declared checksum, re-hashes the download, builds venv/ with the host python3, and runs pip install --no-index --no-deps on the verified artifact; no network resolver ever runs. github_release re-hashes the download (PLUGIN_ARTIFACT_CHECKSUM_MISMATCH on any disagreement, nothing unpacked) and unpacks with pinned modes: directories 0755, the declared executable 0755, every other file 0644; non-regular entries (symlinks, links, devices) and absolute or traversal paths are refused. git clones, checks out the pinned commit, verifies HEAD equals the pin, and strips .git; an unpinned ref fails non-dev installs with PLUGIN_PACKAGE_SOURCE_UNTRUSTED. After the copy the declared executable MUST be a regular file inside the install root, or install fails with PLUGIN_EXECUTABLE_UNTRUSTED before any hash or registry write. plugins.lock rows record source, ref, and checksum; local and unpinned-git installs carry risk = "dev-untrusted".
- optional
requirements:needs_user_creds,credential_descriptors, and advertised_tools, one object per exposed tool, each withname
auth_components (see needs_user_creds denylist and credential descriptors below).
(^[a-z0-9][a-z0-9_.-]{0,63}$), description, and risk_class (read, write, or destructive), plus optional auth_strategy (from the §7 closed enum) and schema_ref. Each description MUST pass the spec §7 13-rule description sanitizer, evaluated at tool kind plugin and at the tool's own risk_class; a violation fails the manifest load with PLUGIN_MANIFEST_INVALID naming the tool and the rule. Tool kind plugin carries the convenience token budget plus the rule 13 cap of 400 Unicode codepoints on the description. The check runs inside LoadManifest, so it covers install, plugin list, and every subprocess spawn, not install alone.
Derived, never declared. A manifest carries no op_id, variant_id, backend_kind, interface_kind, adapter_key, capabilities, scopes, output_profile, confirmation_policy, or null_elision_safe_fields. Install synthesizes one plugin-catalog.json variant row per advertised tool: op_id = plug.<plugin_id>.<name>, variant_id = <op_id>.v1, owner_plugin = <plugin_id>, risk_class copied from the tool, and a binding fixed to adapter_key = "plugin.mcp" with tool_name = <name>. schema_ref is the one selector the author supplies: install appends .request and .response to derive the served refs.
Unsupported manifest_schema_version, missing manifest_schema_version on a third-party manifest, or manifest_schema_version nested inside a plugin member fails before subprocess start with PLUGIN_MANIFEST_SCHEMA_UNSUPPORTED.
Missing or malformed plugin binding selector fields fail before subprocess start with PLUGIN_BINDING_INVALID. For Shape 1 MCP plugins, tool_name is required. For bundled ABI fixtures and future Shape 2 manifests, backend_kind = "grpc-plugin" requires rpc_service and rpc_method. Third-party manifests that declare Shape 2 are rejected earlier with PLUGIN_SHAPE_UNSUPPORTED, regardless of selector completeness.
confirmation_policy is optional and defaults to none. The only non-default value is high_stakes_write, valid only for risk_class = "write" tools. It makes gum.write and gum call --risk=write require user confirmation before dispatch while preserving MCP destructiveHint=false.
needs_user_creds denylist (normative). The requirements.needs_user_creds field lists environment variable names that the host MUST pass through to the plugin subprocess from the user's environment. To prevent plugin authors from siphoning GUM's own configuration, credentials, or operational state into a plugin's address space:
- Variable names matching the case-sensitive prefix
GUM_are PROHIBITED inneeds_user_creds. Listing one fails build/install withPLUGIN_ENV_PROHIBITED: needs_user_creds entry '<name>' on plugin '<plugin>' is a prohibited env var name.(single canonical message form, shared with spec.md §8.1; applies to both theGUM_prefix rule and the exact-name denylist). - The denylist is enforced from a single curated in-binary source of truth shared by
cmd/gen-catalog,gum plugin install, and runtime env scrubbing. It may be embedded viago:embedor compiled as a constant slice, but behavior must be identical in all three paths. The list contains, at minimum: theGUM_prefix rule, exact namesGOOGLE_APPLICATION_CREDENTIALS(use catalog-managed ADC instead),OPENAI_API_KEY,ANTHROPIC_API_KEY, and any env var beginning with_GUM. Future additions require a normative spec patch. - The validation runs at both build time (catalog-bundled plugins) and install time (runtime
gum plugin install). Bypassing it via runtime env injection by the host is PROHIBITED; the dispatch layer MUST scrub the plugin subprocess environment of any denylisted variable regardless of manifest declarations. TestPluginEnvProhibited(ininternal/plugins/env_prohibited_test.go) MUST verify rejection on a fixture manifest that listsGUM_PROFILEinneeds_user_creds, assertingPLUGIN_ENV_PROHIBITEDis returned and the subprocess is never started.
Credential descriptors (normative). needs_user_creds is a raw env allowlist for process launch; it is not safe UX copy. Any manifest with non-empty needs_user_creds MUST also declare requirements.credential_descriptors, one descriptor per env var, with fields alias, env, kind, display_name, and setup_hint as specified in spec.md §8.2. Inventory resources, AUTH_REQUIRED messages, and setup prompts use aliases/display names/hints only. Missing, duplicate, or extra descriptor entries fail build/install with PLUGIN_CREDENTIAL_DESCRIPTOR_INVALID.
Plugins that require product setup beyond a secret value (for example a Google Ads developer token, customer ID, manager login customer ID, billing-enabled account, or user-owned OAuth client) MUST also declare auth prerequisites using the auth_strategy / auth_components[] taxonomy from spec.md §7. Secret components are collected by gum plugin setup <name> and stored in the OS keychain. External components are displayed as checklist items that GUM cannot complete, one line per component, before setup prompts for any secret. The field is requirements.auth_components; each entry carries kind, optional, secret, external, and setup_hint. A kind outside the spec.md §7 closed enum, and an empty kind, fail build/install with AUTH_COMPONENT_UNKNOWN; x- prefixed kinds are informational and accepted. A plugin like Google Keyword Planner is therefore compound, not ordinary OAuth: setup must collect/store the token-like fields and explicitly tell the user which Ads account/billing/access-level prerequisites remain outside GUM. When a compound plugin needs the user's Google access token, it uses the reserved google_access_token component and receives only the short-lived access token documented in spec.md §7; the host never forwards refresh tokens, service-account material, ADC files, or unrelated GUM credentials. plugin_managed means the plugin owns its upstream auth stack and does not receive GUM's official Google OAuth flow. Setup canaries for compound plugins MUST validate that the selected credential subject can actually access the declared account identifiers; storing syntactically valid secrets is not enough to clear needs_configuration.
Forwarded-token strategy gate (normative). advertised_tools[].auth_strategy is an optional manifest field on each tool record and takes the same closed §7 strategy enum as a catalog variant; an unrecognised value fails install as an invalid manifest. A plugin subprocess gets one environment block, so the host cannot scope GOOGLE_ACCESS_TOKEN to one tool of several. A manifest that declares google_access_token in needs_user_creds therefore MUST advertise at least one tool and MUST set auth_strategy = "compound" on every advertised tool. Mixing strategies, omitting the field, or advertising no tools fails install with PLUGIN_ENV_PROHIBITED. Ship a plugin that needs both a compound tool and a non-compound one as two plugins.
The host resolves the forwarded token from the profile's already-granted scopes, so starting a plugin never opens a consent window. When the profile has granted no Google scopes the spawn fails with AUTH_REQUIRED naming gum login. When there is no active session the host leaves GOOGLE_ACCESS_TOKEN unset: it does not fall back to a gum plugin setup secret stored under that name, and it does not pass through an ambient GOOGLE_ACCESS_TOKEN from the parent environment, even if env_allow lists it. A plugin cannot tell a stale string from the host's live token, so either fallback would be spent upstream and surface as an opaque 401. Each spawn that does forward a token appends one plugin_token_forwarded entry to the profile audit log with the plugin id, the credential subject fingerprint, and the forwarded scopes.
If an output profile strips null or empty values, the catalog variant that binds the profile must declare the exact dot paths where that elision is safe, for example null_elision_safe_fields = ["price.currency", "segments[].aircraft"]. Use "*" only for curator-reviewed whole-response elision. Missing or insufficient declarations fail catalog build with PROFILE_STRIP_NULLS_UNSAFE: cmd/gen-catalog resolves every variant's output_profile against the built-in profile set and runs the check with that variant's null_elision_safe_fields. gum plugin install runs no profile validation. A registry variant row becomes a dispatchable catalog op at process start, when the session merge described in spec.md §4.2 reads plugin-catalog.json, but the merged variant carries no output_profile. Shaping falls back to the default profile, so there is no profile binding to check at install time. A plugin that needs a named profile must wait for a release that binds one, and PROFILE_STRIP_NULLS_UNSAFE stays a build-time gate over the generated catalog.
Schema Refs
schema_ref resolves to schemas/<schema_ref>.json inside the plugin artifact or bundled plugin directory. The document is a JSON Schema 2020-12 bundle and MUST contain object-valued $defs.request and $defs.response. Build/install derives the resolved binding refs as request_ref = "<schema_ref>.request" and response_ref = "<schema_ref>.response"; plugin manifests do not declare these refs directly. The ref strings MUST match the safe served-ref grammar in spec.md §8.2 before any path is constructed; path separators, traversal markers, URI-encoded separators, and control characters fail with PLUGIN_SCHEMA_REF_INVALID. Runtime-installed third-party request/response schemas are copied into the active profile's plugin schema store as plugin-schemas/<request_ref>.<sha256>.json and plugin-schemas/<response_ref>.<sha256>.json; the corresponding schema_hashes are recorded in plugin-catalog.json.
Missing or invalid refs, or bundles missing $defs.request or $defs.response, fail with PLUGIN_SCHEMA_REF_INVALID. A plugin whose schema ref collides with any schema ref already present in the selected profile's full inventory (embedded catalog plus active, pending-restart, needs-configuration, and quarantined plugin schemas) with a different JCS-canonical schema digest fails install with SCHEMA_REF_COLLISION; identical-body reuse is allowed.
Runtime Registry
Installed plugin variants are recorded in ~/.local/share/gum/<profile>/plugin-catalog.json, a versioned JSON object:
{
"plugin_catalog_schema_version": 1,
"updated_at": "2026-05-19T00:00:00Z",
"variants": []
}Updates use the full-state install transaction in spec.md: plugin-catalog.json, plugins.lock, and plugin-state.json are staged and published together under one profile-scoped generation. Runtime visibility, activation timestamps, inventory-vs-active snapshot reads, and quarantine precedence are owned by the runtime catalog state model in docs/catalog-abi.md; this document does not restate that state machine.
Deterministic Discovery
MCP clients enumerate plugins via:
gum://pluginsgum://plugin/{name}
CLI users use gum plugin list. Search may surface plugin operations, but search phrasing is not the inventory contract.
Plugin inventory status is a closed enum: active, installed_pending_restart, needs_configuration, or quarantined. A plugin installed while an MCP server is already running is inventory-only in that session with status installed_pending_restart; its operations are not searchable, operation-completable, describable as active, usable from code mode, or invokable until the MCP server restarts and marks it activated. A credentialed plugin installed without required credentials is needs_configuration: install validation succeeded, live canary was skipped, and the user must supply the declared credentials with gum plugin setup <name> before activation. The restart affects operation reachability through the existing Tier A/meta-tool surface only; plugin variants never add individual MCP tools. Standalone CLI commands see install-valid configured plugins on their next process start. If a plugin is both quarantined and another inactive state, quarantined wins in every MCP and CLI surface.
gum://plugin/{name} metadata is assembled from the selected profile's plugin-catalog.json plus plugin-state.json; the same profile's plugins.lock is consulted for package source/ref/checksum fields and the runtime executable binding (executable_path, executable_sha256, argv_normalized, install_root). Lock lookups are keyed by (profile, plugin_name) and MUST NOT cross profile boundaries. If those sources disagree, runtime status from plugin-state.json wins for quarantine/retry state, variant records from plugin-catalog.json win for dispatch metadata, and lockfile package fields are surfaced with metadata_warning: "lock_catalog_mismatch". The resource shape is fixed in spec.md §13 and includes safe credential descriptors for needs_configuration; raw env var names must never appear in this resource. Users configure missing plugin credentials through gum plugin setup <name>, which prompts using descriptor display names/setup hints, stores secrets in the OS keychain for the active profile, and runs a live canary before clearing needs_configuration.
For non-dev profiles, the launched executable must be inside the host-managed install root derived from the verified artifact. Runtime PATH-only lookup, shell wrappers, and fresh uvx/package-manager resolution are prohibited. GUM re-verifies executable_sha256 before each spawn; mismatch quarantines the plugin and refuses execution. Source-specific normalization is fixed: PyPI commands resolve to a console script inside the profile-scoped virtualenv; GitHub release and Git sources must declare one executable artifact path inside the unpacked install root; local paths are dev-only.
Reserved Namespaces
Every op_id your plugin produces is plug.<plugin_id>.<tool_name>. The host prepends plug. itself, and plugin_id must match ^[a-z][a-z0-9-]{0,63}$, so it cannot carry a dot. No first-party catalog op_id sits under plug.. Google service prefixes are therefore out of reach by construction, and no reserved list of Google prefixes needs to ship: declaring plugin_id = "gmail" gives you plug.gmail.*, never the first-party gmail.* ops.
Inside the plug. namespace, ownership is first claim wins. ValidateNamespaceOwnership (internal/plugins/namespace.go) admits any plugin_id that no other namespace_owner holds in the selected profile's plugins.lock, and fails one already held by a different owner with PLUGIN_NAMESPACE_CONFLICT.
Third-party plugins must also declare namespace_owner in the manifest. The owner is a reverse-DNS or package-registry publisher identity displayed at install time and recorded in the selected profile's plugins.lock. A non-dev profile rejects any plugin whose op_id prefix is already owned by a different namespace_owner; local development may bypass only with --dev-allow-namespace-conflict.
Canaries
A manifest declares no canary. The shipped canary is a spawn probe: start the subprocess through the plugin host, complete the MCP handshake, then stop it. Spec §8.7's manifest canary string, its relative date specifiers, and its build-time gates (CANARY_DATE_TOO_SOON, CANARY_DUPLICATE_ARG, CANARY_RELATIVE_DATE_OUT_OF_RANGE, canary_ingested_date) are unimplemented; none of those identifiers appears in any Go file (gum-upd4).
Canary failures soft-quarantine the plugin rather than blocking the registry write. A quarantined plugin is not searchable, invokable, or auto-started until gum plugin reload <name> retries the spawn or gum plugin unquarantine <name> clears the state without a restart.
Missing required user credentials skip the canary at install and record needs_configuration instead of quarantine. gum plugin setup <name> is the only path that clears it: it prompts for each declared credential, stores the secrets in the OS keychain, runs the spawn probe, and then writes active on success or quarantined with CANARY_FAILED on failure (internal/plugins/setup.go).
gum canary --plugin=<id> [--live] is a diagnostic. It spawns the plugin once, prints a JSON envelope, and writes no plugin state, so it never clears needs_configuration and never advances the §8.6 backoff ladder. --live sets the envelope's live field and changes nothing else. There is no --canary-args flag.
Output Profiles and Tests
output_profile is not a manifest field, and install binds none: a merged plugin variant carries no profile, so shaping falls back to the default. Profile files that ship with a plugin are validated with gum profile validate; fixture-backed profiles are tested with gum profile test. The rules that a lossy profile keeps recovery enabled and declares null_elision_safe_fields when strip_nulls=true are enforced by cmd/gen-catalog over the generated catalog, not over a plugin manifest.
Trust Posture
Shape 1 install is equivalent to running arbitrary user-level code. GUM verifies package and executable checksums, displays ToS/risk, narrows env vars, validates schemas/output, and enforces declared network=false plus fs_write_dir with the OS sandbox backend on supported macOS/Linux hosts. That confinement is a defense-in-depth boundary, not a multi-tenant sandbox or a substitute for trusting the installed package. Unsupported sandbox platforms fail closed for enforced plugin execution. Shape 1 plugins do not implicitly inherit GUM's official Google OAuth credential; the only host-mediated Google-token path is the explicit compound google_access_token forwarding rule in spec.md §7, and that rule forwards a short-lived access token only. plugin_managed plugins own their own auth. Shape 2 narrows ambient authority through host services, but still is not a multi-tenant sandbox.
