name: myco:claude-plugin-config-transport description: >- Activate when working on Claude plugin configuration, MCP transport behavior, or plugin release mechanics in unifi-mcp. Covers: keeping UNIFI_MCP_HTTP_FORCE at its safe default in apps/*/config/config.yaml for all three apps (network, protect, access); the stdio-primary transport pattern in transport.py and why asyncio.wait(FIRST_COMPLETED) must not be reintroduced; local development with claude --plugin-dir and its marketplace-cache blind spot; running check-prereqs.sh before plugin setup; writing Bash 3.2-compatible shell scripts for plugins/; multi-target plugin support for Claude, Codex, and OpenClaw targets; atomic version sync across plugin manifests; and issuing no-op patch releases when only plugin config or scripts change. Apply even if the user doesn't explicitly mention transport races — activate whenever plugin behavior, MCP tool availability, or plugin-scoped shell scripts are being modified. managed_by: myco user-invocable: true allowed-tools: Read, Edit, Write, Bash, Grep, Glob
Claude Plugin Configuration and Transport Stability
This skill covers the full lifecycle of Claude MCP plugin integration in unifi-mcp: from configuring transport flags correctly, through understanding the asyncio transport architecture that keeps MCP tools available, to verifying plugin setup, writing portable shell scripts, and publishing plugin-only releases. The procedures share a common prerequisite — understanding the uvx launch context — because a wrong flag can trigger a transport race that silently drops all MCP tools with no visible error.
Relevant files:
apps/network/src/unifi_network_mcp/config/config.yamlapps/protect/src/unifi_protect_mcp/config/config.yamlapps/access/src/unifi_access_mcp/config/config.yamlplugins/unifi-network/scripts/set-env.sh,plugins/unifi-network/scripts/check-prereqs.shplugins/unifi-protect/scripts/set-env.sh,plugins/unifi-protect/scripts/check-prereqs.shplugins/unifi-access/scripts/set-env.sh,plugins/unifi-access/scripts/check-prereqs.shpackages/unifi-mcp-shared/src/unifi_mcp_shared/transport.pypackages/unifi-mcp-shared/src/unifi_mcp_shared/strict_dispatch.py.agents/plugins/marketplace.json(multi-target plugin registry: Claude, Codex, OpenClaw).codex-plugin/(Codex-specific manifest directory, PR #246).agents/plugins/openclaw/(OpenClaw-specific manifest directory, PR #248)
Prerequisites
Before modifying any plugin configuration or transport code:
- Understand the uvx launch context. When Claude launches a plugin via
uvx, the plugin process is not PID 1. This distinction controls which transport path activates insidetransport.pyand whether HTTP transport will be enabled. The PID check is inresolve_http_config()intransport.py. - Know all three app directories. Transport and HTTP config changes almost always need to be reflected in
apps/network/,apps/protect/, andapps/access/in parallel. Partial application leaves inconsistent behavior. - Know all three plugin directories. Script and setup changes apply to
plugins/unifi-network/,plugins/unifi-protect/, andplugins/unifi-access/. Applying to only one or two creates inconsistent behavior that is nearly impossible for users to diagnose. - Know the multi-target plugin registry and all three runtimes. As of PR #246 (Codex) and PR #248 (OpenClaw), unifi-mcp supports three plugin targets: Claude, Codex, and OpenClaw. Plugin configuration, version sync, and release procedures must account for target-specific manifests and initialization paths.
- Hold the current MCP shared package version. Plugin versions are slaved to MCP package tags; know the current tag before planning a release (see Procedure F).
- Run
check-prereqs.shfirst (see Procedure D) before activating any plugin setup change in a live environment.
Procedure A: UNIFI_MCP_HTTP_FORCE — Keep It at the Safe Default
Where the flag lives
UNIFI_MCP_HTTP_FORCE is an environment variable consumed by each app's config.yaml. It maps to the http.force config key:
# apps/network/src/unifi_network_mcp/config/config.yaml (same pattern in protect, access)
http:
enabled: ${oc.env:UNIFI_MCP_HTTP_ENABLED,true}
force: ${oc.env:UNIFI_MCP_HTTP_FORCE,false}
transport: ${oc.env:UNIFI_MCP_HTTP_TRANSPORT,streamable-http}
The safe default is false (or unset). Never set UNIFI_MCP_HTTP_FORCE=true in a uvx-launched context (non-PID-1). Setting it to true forces an HTTP bind attempt that is unavailable in the uvx sandbox.
Why UNIFI_MCP_HTTP_FORCE=true silently destroys all MCP tools
The failure chain when UNIFI_MCP_HTTP_FORCE=true in a uvx-launched (non-PID-1) context:
- HTTP bind is unavailable in the uvx sandbox.
run_http()intransport.pycatches theSystemExitfrom the failed bind and returns normally — no exception propagates.- The pre-fix
asyncio.wait(FIRST_COMPLETED)implementation saw the HTTP task as "done" and interpreted normal return as success. - FIRST_COMPLETED cancelled the stdio task (the only transport Claude actually uses).
- All MCP tools disappeared silently. Claude showed no error; the tools were simply gone.
This was the root cause of Issue #200, fixed in PR #202. The fix required changes in two places: the config flag handling AND the asyncio transport pattern (see Procedure B).
Checklist when changing HTTP transport config
- Change applied in all three:
apps/network/,apps/protect/,apps/access/config.yaml files -
UNIFI_MCP_HTTP_FORCEvalue isfalse(or the env var is unset) for all uvx-launched contexts - No environment variable injection is setting it to
truein the plugin setup path - A no-op patch release is planned if config is the only change (see Procedure F)
Procedure B: Transport Stability — Maintaining the Stdio-Primary Pattern
The current safe pattern in transport.py
The current implementation in packages/unifi-mcp-shared/src/unifi_mcp_shared/transport.py uses stdio as the primary control flow — it runs to completion. HTTP runs as a cancellable background task. An HTTP failure must never cancel stdio. This pattern extends to all transport targets including Codex (PR #246) and OpenClaw (PR #248).
# SAFE — current implementation (run_transports function)
http_task = asyncio.create_task(run_http(), name="http")
await asyncio.sleep(0) # yield so http_task can start
try:
await run_stdio() # blocks until stdio EOF / client disconnect
logger.info("FastMCP stdio server exited.")
finally:
if not http_task.done():
http_task.cancel()
try:
await http_task
except asyncio.CancelledError:
pass
run_http() catches its own SystemExit internally (port-bind failures from uvicorn), so a failed HTTP transport returns normally rather than propagating. Stdio lifecycle is unaffected. This design assumes the transport target (Claude, Codex, OpenClaw) uses stdio as the primary transport and HTTP as optional.
What NOT to do — the pre-fix anti-pattern
Do not replace the above pattern with asyncio.wait(FIRST_COMPLETED):
# UNSAFE — do not reintroduce this pattern
done, pending = await asyncio.wait(
{stdio_task, http_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel() # cancels stdio if http_task "succeeds" silently
asyncio.wait(FIRST_COMPLETED) is unsafe when any task can "succeed" by swallowing its own error. A task that catches an exception internally and returns None looks identical to a task that completed successfully. This was the pre-#202 implementation — it is described in the docstring of run_transports() as "the previous implementation."
Design rules for transport code:
- Stdio EOF / client disconnect is the authoritative shutdown signal.
- Never use
FIRST_COMPLETEDacross transport tasks where either task can swallow errors. - Use explicit exception inspection or a sentinel flag to distinguish "HTTP successfully bound and serving" from "HTTP failed silently."
- HTTP errors must not propagate to the stdio lifecycle.
Regression test strategy
Two-layer verification after any transport change:
- Mock race test — unit test that forces
run_http()to catch and swallow aSystemExit, then asserts that the stdio task was not cancelled and ran to completion. - E2E repro — bind a TCP listener on the HTTP port before launching the plugin so the HTTP bind fails, then verify MCP tools remain available throughout the stdio session.
Procedure C: Local Plugin Development — claude --plugin-dir and the Marketplace Cache
The canonical local-dev flag
claude --plugin-dir <path/to/plugins/unifi-network>
This loads the plugin directly from the local directory, bypassing the marketplace install step. It is the correct flag for iterating on plugin scripts or skills without publishing a release.
The marketplace cache blind spot
--plugin-dir does not deactivate a marketplace-installed version of the same plugin. If a marketplace-installed version has a conflicting config cached, it may take precedence over the local-dir version, or the two configs may interfere with each other in unpredictable ways.
To confirm the correct plugin is active:
# 1. Check which binary Claude is actually running
ps aux | grep unifi-mcp
# 2. Diff local scripts against the marketplace-installed versions
ls plugins/unifi-network/scripts/
# Compare against marketplace cache (location varies by OS — check Claude's
# plugin data directory)
# 3. If behavior doesn't match local config, temporarily uninstall the
# marketplace version and re-test with --plugin-dir alone
What --plugin-dir does NOT substitute for
- A marketplace install test — always do a final end-to-end test through the marketplace path before shipping a release.
- The
check-prereqs.shpre-flight (Procedure D). - Version verification — the plugin version shown in Claude may reflect the marketplace-installed version, not the local-dir version.
Procedure D: Plugin Setup Verification — check-prereqs.sh Pre-flight
What it does
plugins/unifi-*/scripts/check-prereqs.sh validates required environment variables and system state before plugin activation. It catches missing credentials and misconfigured endpoints before they produce cryptic user-facing failures. The scripts explicitly state Bash 3.2 compatibility. OpenClaw plugins have identical prerequisites to Claude and Codex — the check-prereqs.sh scripts are target-agnostic.
When to run it
Run before activating any plugin setup change:
bash plugins/unifi-network/scripts/check-prereqs.sh
bash plugins/unifi-protect/scripts/check-prereqs.sh
bash plugins/unifi-access/scripts/check-prereqs.sh
On Windows, each plugin's scripts/ directory also provides a PowerShell prereq companion script. Consult the plugin's runtime SKILL.md manifest for the exact invocation — it accepts a -Target <claude|codex|openclaw> parameter and -PluginName flag.
Run each plugin separately — the three plugins can have different prerequisite sets. A failure in unifi-protect does not imply a failure in unifi-network.
Adding new prerequisites
When a plugin gains a new required environment variable or system dependency, add a check to both the .sh and the PowerShell companion script in the same PR:
# Pattern for a required variable with a helpful error message
if [ -z "${NEW_VAR:-}" ]; then
echo "ERROR: NEW_VAR is not set. See README for setup instructions." >&2
exit 1
fi
# Pattern for a required command
if ! command -v my-tool >/dev/null 2>&1; then
echo "ERROR: my-tool is not installed." >&2
exit 1
fi
Do not rely on the plugin process itself to surface missing prerequisites — process-level errors are much harder to diagnose than a clean pre-flight failure with a human-readable message.
Procedure E: Shell Script Portability — Bash 3.2 on macOS
The constraint
macOS ships /bin/bash at version 3.2 (circa 2007). Homebrew may install a newer bash, but users run plugin scripts without Homebrew in their PATH. Any .sh script in plugins/ must work against the system shell. This constraint is documented in each check-prereqs.sh: "Bash 3.2 compatible (works on stock macOS /bin/bash)."
On Windows, use PowerShell (.ps1) — not .sh. Each plugin's scripts/ directory provides PowerShell equivalents for environment setup and prereq checking. Never add .sh scripts that assume a bash version higher than 3.2 and expect them to work on macOS.
declare -A (associative arrays) is bash 4+ only. Using it in any plugins/*/scripts/*.sh causes a silent no-op or unbound variable error on macOS. The set-env.sh scripts use sed-based manipulation instead of associative arrays for exactly this reason (fixed in PR #202).
POSIX-compatible alternatives for new scripts
If a new script needs key→value mapping:
# Option 1 — case/esac dispatch
get_default() {
case "$1" in
FOO_HOST) echo "192.168.1.1" ;;
FOO_PORT) echo "443" ;;
*) echo "" ;;
esac
}
FOO_HOST="${FOO_HOST:-$(get_default FOO_HOST)}"
# Option 2 — explicit variable pairs (simplest for small sets)
FOO_HOST="${FOO_HOST:-192.168.1.1}"
FOO_PORT="${FOO_PORT:-443}"
Verification rule for new plugin scripts
Every new shell script added to plugins/ must be tested against the system bash, not Homebrew bash:
# Syntax check
/bin/bash -n plugins/unifi-network/scripts/my-new-script.sh
# Runtime check
/bin/bash plugins/unifi-network/scripts/my-new-script.sh
If CI runs on Linux, add a macOS job or test locally — Linux bash is almost always 4+, so CI will not catch 3.2 incompatibilities.
Procedure F: Plugin Version Sync — No-Op Patch Releases and Multi-Target Registry
The versioning contract
Plugin versions are strictly slaved to MCP package release tags. The version referenced in plugin config must match a tagged MCP package version published to PyPI. There is no independent plugin version number.
Multi-target plugin registry (PR #246, PR #248)
As of PR #246 (Codex) and PR #248 (OpenClaw), unifi-mcp supports three plugin targets: Claude, Codex, and OpenClaw. The multi-target registry lives in .agents/plugins/marketplace.json. When a version bump is released, all target registries in .agents/plugins/marketplace.json must be updated in the same commit. Codex-specific manifests are in .codex-plugin/ and OpenClaw-specific manifests are in .agents/plugins/openclaw/ — these must be synchronized with the main app configs and the root marketplace.json entry.
When only plugin config or scripts change
If a PR modifies only plugin manifests or scripts (no Python code changes in shared/), the correct release mechanism is a no-op patch release:
Bump the patch version in
packages/unifi-mcp-shared/pyproject.toml.Commit, tag, and push the new tag.
Wait for the release pipeline to publish the wheel to PyPI.
Atomically update version fields in all target registries and manifests:
.agents/plugins/marketplace.json— all three target sections (claude, codex, openclaw) for all three plugins (network, protect, access).codex-plugin/plugin.json— version field in the Codex-specific manifest.agents/plugins/openclaw/plugin.json— version field in the OpenClaw-specific manifestapps/network/src/unifi_network_mcp/config/config.yaml— version stringapps/protect/src/unifi_protect_mcp/config/config.yaml— version stringapps/access/src/unifi_access_mcp/config/config.yaml— version string
Workflow automation: The
bump-plugin-versions.ymlCI workflow should atomically cover all three locations in a single workflow commit — do not bump them separately.Do not update the plugin version before the PyPI step completes (PyPI ordering gate — see
monorepo-release-pipelineskill).
Never skip the release for plugin-only changes. Without a new tag, the plugin version field cannot advance, the config change has no anchored release identity, and existing users stay pinned to the cached old config until a tagged release forces cache invalidation.
Change-type reference
| Change type | Release needed? | Release type |
|---|---|---|
Python code change in shared/ |
Yes | Functional patch / minor / major |
| HTTP transport config change only | Yes | No-op patch |
check-prereqs.sh or set-env.sh change |
Yes | No-op patch |
PowerShell plugin script (.ps1) change |
Yes | No-op patch |
| Plugin skill SKILL.md change | Yes | No-op patch |
Codex manifest or .codex-plugin/ change |
Yes | No-op patch |
OpenClaw manifest or .agents/plugins/openclaw/ change |
Yes | No-op patch |
| Multi-target registry (.agents/plugins/marketplace.json) change | Yes | No-op patch |
| README / docs only | No | — |
Cross-Cutting Gotchas
Silent tool loss is the hardest failure mode to diagnose
When all MCP tools disappear after a plugin or transport change, the natural instinct is to check credentials or network connectivity. Check UNIFI_MCP_HTTP_FORCE (is it set to true somewhere?) and the transport pattern in transport.py first. The stdio cancellation failure surfaces no error to Claude or the user — tools simply stop appearing in the tool list.
Both layers must be fixed together
The Issue #200 / PR #202 fix required changes in two places:
apps/*/config/config.yaml— ensureUNIFI_MCP_HTTP_FORCEis not forced totruein non-PID-1 contextstransport.py— the stdio-primary pattern prevents a swallowed HTTP failure from cancelling stdio
If you are investigating a transport issue, always audit both the config flag injection path and the asyncio pattern before concluding the fix is complete.
Three-app and three-plugin parity is non-negotiable
Config changes to HTTP transport behavior must be applied to all three apps (network, protect, access). Script changes must be applied to all three plugin directories (unifi-network, unifi-protect, unifi-access). Partial application produces inconsistent behavior depending on which app/plugin the user has installed.
--plugin-dir local tests are necessary but not sufficient
A passing local test with --plugin-dir does not guarantee the marketplace-installed version behaves the same way. Always do a final marketplace-path test before shipping, especially after config or script changes.
Three-runtime transport pattern — Claude, Codex, and OpenClaw
The three plugin targets (Claude, Codex, and OpenClaw) have identical transport initialization patterns. Changes to transport.py apply to all three; transport-specific gotchas that were previously labeled as Codex-only (PR #246) now apply equally to OpenClaw (PR #248). When debugging transport issues, test against all three runtime targets if possible — a transport bug may surface in one target before appearing in others due to timing or initialization order differences.
Version sync atomicity — Marketplace registry must stay consistent
The .agents/plugins/marketplace.json root registry, .codex-plugin/plugin.json, and .agents/plugins/openclaw/plugin.json must have matching version strings. If a release bumps only .agents/plugins/marketplace.json but forgets the target-specific manifests, the Codex and OpenClaw plugins will keep referencing the old version while the marketplace registry has advanced. This creates version skew that manifests as "tool unavailability for OpenClaw users while Codex works fine" — a confusing symptom that points to version mismatch, not transport issues. Always update all three registry locations atomically.
Skill-dir naming collision — Manifest registration gotcha
The .agents/skills/*/ directory structure can collide with plugin name patterns if not carefully scoped. Plugin skill manifests registered in .agents/plugins/*/skills/*/ must use fully qualified names (e.g., unifi-mcp:skill-name) to avoid colliding with agent-owned skills. Always verify that a new plugin skill manifest's fully qualified name does not collide with existing agent or plugin skill names. Test on all three targets (Claude, Codex, OpenClaw) — skill resolution may differ by target.
Redaction awareness — never echo ***REDACTED*** markers into mutation args (PR #350)
As of PR #350, all plugin tool responses redact sensitive fields by default — Wi-Fi passphrases, VPN private/preshared keys, SNMP community strings, and Access credential token/PIN values are replaced with ***REDACTED*** in read, list, and preview responses.
When you need an unredacted value for a write operation: Pass include_sensitive=true to the relevant read tool first to retrieve the real value, then use that value in the mutation call.
Never feed a ***REDACTED*** string back into a mutation argument. StrictKwargFastMCP (in packages/unifi-mcp-shared/src/unifi_mcp_shared/strict_dispatch.py) rejects tools/call requests that pass a redaction marker as an argument value. The rejection may not surface a clear diagnostic to the agent — the tool call fails without indicating that a redacted placeholder was the cause. This guard prevents accidental passthrough of masked values into real API writes.
Affected workflows: WLAN passphrase updates, VPN key rotation, SNMP community string changes, Access credential token/PIN mutations. Plugin skills that guide agents through these workflows must explicitly mention the include_sensitive=true pattern — agents that rely on a prior read response and pass the field value through verbatim will hit this guard.