Patterns for building Claude Code plugins that bundle local MCP servers, orchestrate external tools, and manage dependencies without requiring separate installation steps.
Plugins can bundle an MCP server that has non-stdlib Python dependencies
(e.g., pydantic, mcp, httpx) and install them automatically on
first session start — no pipx install, no PyPI, no manual setup.
This is documented by Anthropic at Plugins reference — Persistent data directory:
${CLAUDE_PLUGIN_DATA}: a persistent directory for plugin state that survives updates. Use this for installed dependencies such asnode_modulesor Python virtual environments, generated code, caches, and any other files that should persist across plugin versions.
The recommended pattern
uses a SessionStart hook to detect dependency changes and install only
when needed:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "diff -q \"${CLAUDE_PLUGIN_ROOT}/requirements.txt\" \"${CLAUDE_PLUGIN_DATA}/requirements.txt\" >/dev/null 2>&1 || (cd \"${CLAUDE_PLUGIN_DATA}\" && cp \"${CLAUDE_PLUGIN_ROOT}/requirements.txt\" . && python3 -m pip install -t \"${CLAUDE_PLUGIN_DATA}/site-packages\" -r requirements.txt) || rm -f \"${CLAUDE_PLUGIN_DATA}/requirements.txt\""
}
]
}
]
},
"mcpServers": {
"my-server": {
"command": "python3",
"args": ["${CLAUDE_PLUGIN_ROOT}/server.py"],
"env": {
"PYTHONPATH": "${CLAUDE_PLUGIN_DATA}/site-packages"
}
}
}
}How it works:
diffchecks if the plugin'srequirements.txtmatches the cached copy in${CLAUDE_PLUGIN_DATA}- On first run or dependency change,
pip install -tinstalls into a persistentsite-packagesdirectory - If install fails, the cached manifest is removed so the next session retries
- The MCP server runs via
python3withPYTHONPATHpointing to the installed packages
- Local-only tools that read/write the local filesystem (config managers, bill trackers, project scaffolders)
- Tool orchestrators / proxies that wrap or compose calls to other MCP servers (see Orchestration below)
- Plugins with tightly coupled skills where the skill workflow depends on specific MCP tools being available and co-versioned
Separately installed MCP server (pipx install + claude mcp add).
This is the traditional approach: install the package globally, then
register it as an MCP server. It works but requires a manual setup step
outside the plugin system. The plugin's .mcp.json points to a command
that may or may not exist on the user's machine. If the binary is
missing, the plugin silently fails to start the MCP server.
Hook-based caching with external MCP servers. Another approach uses
PostToolUse hooks to intercept responses from a separately registered
MCP server and cache the data to disk. The plugin's own MCP server then
reads from cache instead of calling the external server directly. This
works but introduces coupling between two independently versioned
systems (the plugin's hooks must understand the external server's
response format), and the external server must still be installed and
registered separately.
Remote HTTP MCP servers. Hosting the MCP server on Cloud Run or similar. Eliminates local install entirely but requires internet, adds latency, and means local filesystem access requires a sync layer. Best for tools that are inherently cloud-based (SaaS API wrappers, shared data services), not for local config management.
The self-installing pattern is superior for local-only tools because:
- Zero setup beyond plugin install
- Plugin and MCP server are co-versioned (no compatibility drift)
- Works offline
- No external package registry (PyPI) required
- Dependencies auto-update when the plugin updates
The self-installing pattern above is the recommended shape. Two variants and one antipattern are worth naming explicitly because they look similar and produce very different outcomes.
The pattern already documented above:
- Source runs directly from
${CLAUDE_PLUGIN_ROOT}—.mcp.jsonpoints at${CLAUDE_PLUGIN_ROOT}/server.py(or equivalent), no copy step requirements.txtlists only external dependencies (e.g.,mcp>=1.0.0), not.or the plugin itself- Install hook uses
diff -q requirements.txtto detect changes — file-content signal, not version string - Only third-party deps end up in
${CLAUDE_PLUGIN_DATA}/site-packages
Result: source can never drift because it isn't duplicated. The only thing that can go out of sync is the dependency set, which the diff check catches.
# DON'T DO THIS
requirements.txt:
.
hook compares __version__ from the source against a cached installed_version file
This pattern installs the plugin's own source into site-packages alongside its deps, producing a second copy that can drift from the plugin cache. The drift is invisible until symptoms appear.
Why it breaks:
pip install .copies the plugin source into${CLAUDE_PLUGIN_DATA}/site-packages/<plugin>/, creating a duplicate of what's already at${CLAUDE_PLUGIN_ROOT}- The hook's comparison (e.g.,
__version__in__init__.pyvs a cachedinstalled_versionfile) can report "already installed" when the cache file is stale but the site-packages contents don't match the current source (e.g., install was partial, the marketplace refreshed the cache after the installed_version was written, or the version string didn't change even though the code did) - User runs
.mcp.json'spython3 -m <package>.serverwhich imports fromPYTHONPATH=${CLAUDE_PLUGIN_DATA}/site-packages— picking up the stale copy, not the fresh plugin cache - Debugging this is painful because the plugin looks installed (files are there, version string matches), but the code is old
Version strings are proxies for "content changed." They require human discipline to stay accurate and can silently lie. File diffs and content hashes are computed from reality and can't.
The version-string compare has a second, subtler failure mode that makes it fragile even independent of the requirements.txt = . problem above: it compounds with outer plugin-cache staleness. Claude Code decides whether to refresh the cached plugin on disk by looking at .claude-plugin/plugin.json's version field. If a plugin author forgets to bump that field before tagging a release, the outer plugin cache never updates — and when the outer cache is stale, the in-package version string the SessionStart hook reads is coming from the SAME stale source file. Both sides of the compare reference the same old value, the hook reports "no change," and pip install never runs. Users reinstall the plugin, see no errors, and believe they picked up the update. A requirements.txt-diff trigger avoids this because the diff is against an artifact the hook itself controls — the cached requirements.txt under ${CLAUDE_PLUGIN_DATA} — not against a field inside the potentially-stale plugin source.
Real-world symptom pattern: a plugin bumps its version and pushes a new release. The marketplace refreshes the cache. Sessions restart. Users see "still behaves like the old version" despite plugin list showing the new version number — because site-packages was never rewritten.
An emerging alternative that supports both plugin use and standalone (non-plugin) use from the same source:
{
"mcpServers": {
"my-tool": {
"command": "uvx",
"args": ["--from", "${CLAUDE_PLUGIN_ROOT}", "my-tool-mcp"]
}
}
}With a matching pyproject.toml:
[project]
name = "my-tool"
dependencies = ["mcp", ...]
[project.scripts]
my-tool-mcp = "my_tool.server:main"uvx (part of uv) creates an isolated environment from the local path, installs the package + deps, and runs the declared entry point. uv handles cache invalidation internally based on source and metadata, so no custom install hook is needed.
Advantages over the direct-source + explicit-deps pattern:
- Dual distribution from one repo: the same
pyproject.tomlthat serves the plugin also makes the package installable standalone viapipx install git+<url>@<tag>, giving users a non-plugin path (any MCP client, not just Claude Code) without duplicating code across repos. - No custom install hook: uv's cache handles reinstall decisions automatically.
- Entry points are declarative:
[project.scripts]is the source of truth for commands, usable by both the plugin and standalone install.
Tradeoffs:
- Requires
uvon the user's PATH.uvis rapidly becoming the standard for Python tool-running (Claude Code docs recommenduvx), and installs trivially (brew install uvor a curl script), but it's not yet guaranteed on every machine. - First launch is slower than a pre-installed package (uv creates the env), but subsequent launches are fast thanks to aggressive caching.
- If the plugin has no standalone-distribution ambition, this adds dependencies (
uv,pyproject.toml) without commensurate benefit over direct-source + explicit-deps.
| Property | Direct-source + explicit-deps (recommended) | pip install . + version-string cache (antipattern) |
uvx + pyproject.toml (future) |
|---|---|---|---|
| Source duplication risk | None (source runs from ROOT) | High (source copied to site-packages) | None (uv env references source) |
| Cache invalidation signal | File-content diff (reliable) | Version string (proxy, can lie) | uv-managed (reliable) |
| Custom install hook required | Yes (small, content-based) | Yes (custom, version-based) | No (uv handles it) |
| Works as standalone MCP server outside the plugin | Manual venv + pip (possible but clunky) | Same, with drift risk | One-liner via pipx install or uvx --from |
| Dep manifest | requirements.txt (external deps only) |
pyproject.toml or requirements.txt |
pyproject.toml (standard) |
| External tool requirement | python3 + pip | python3 + pip | python3 + uv |
| Ecosystem alignment | Matches Anthropic's self-installing recommendation | Legacy / pre-uvx workaround | Matches Claude Code docs on MCP invocation and Python tooling trend |
Current recommendation: direct-source + explicit-deps. Move to uvx + pyproject.toml when:
- A new plugin needs dual distribution from day one
- Or the plugin has existing or imminent demand for standalone MCP server use outside Claude Code
- Or the ecosystem reaches a point where
uvavailability is ambient (on par withpython3)
A plugin is more than a bundle of skills. It's an all-or-nothing unit that enables orchestration patterns individual skills can't safely do alone.
Standalone skills — those distributed individually via a marketplace, a skills install CLI, or manual copy — must function when installed alone, without assuming any sibling skill is present. This means:
- A skill can't confidently say "when you finish here, invoke skill X" because skill X may not be installed
- Cross-references must be soft hints, not structural dependencies
- Multi-skill workflows have no way to enforce order or completeness across skill boundaries
- Skills either duplicate content from their neighbors (fighting DRY) or live with broken references (fighting reliability)
A plugin bundles a set of skills together. When the plugin is installed, every skill it declares is present. When it isn't, none of them are.
This turns a collection of loosely-coupled skills into a cohesive product. Inside the plugin's scope — its agent definition (agents/<name>.md) and its bundled skills — the plugin knows exactly which sibling skills exist. It can orchestrate them with confidence.
Claude Code plugins can ship agent definition files (e.g., agents/<agent-name>.md) that declare preloaded skills in frontmatter and provide orchestration in the body.
---
name: my-agent
description: ...
skills:
- step-one-skill
- step-two-skill
- final-step-skill
---
# My Agent
You help users accomplish <workflow>. The workflow has three stages:
## Stage 1 — preparation
Use `step-one-skill` before starting any real work.
## Stage 2 — execution
...
## Stage 3 — verification
Before marking the task done, invoke `final-step-skill` to confirm ...Two narrow jobs for the agent .md:
- Preload critical skills via the
skills:frontmatter so their content is in context from the start of every session. This guarantees the model has them without relying on frontmatter-based discovery. - Orchestrate via a thin, high-level workflow outline in the body that names other skills at the moments they apply, with short summaries of what each one does and when to invoke it.
The agent .md is explicitly NOT the place to pack the full content of every skill. Skills remain the source of truth for their respective domains. The agent .md's orchestration is intentionally redundant with skills' own descriptions — the redundancy is a safety net for imperfect skill discovery, not a substitute for the skills themselves.
When writing or extending an agent .md:
- Does the content belong in a specific skill? → put it in the skill, reference it from the agent .md with a short summary
- Is it workflow-level orchestration that crosses skill boundaries? → goes in the agent .md body
- Is it a critical skill that should always be in context? → add to frontmatter
skills: - Otherwise → probably belongs in a skill, not here
Without the plugin's bundling guarantee, the same orchestration discipline is unsafe:
- A skill with hard references to siblings breaks when sibling is missing
- A standalone "orchestrator skill" doesn't have the guarantee either — it still depends on siblings being installed
- No other Claude Code mechanism provides "these skills are guaranteed present" scope for orchestration to rely on
So: multi-skill workflows that need reliable cross-references belong in a plugin, and the plugin's agent .md is where the orchestration lives. Plugins don't just deliver skills — they deliver composed workflows that couldn't exist otherwise.
- A plugin offers a set of skills that work better together than alone
- The skills compose into a multi-step workflow with natural ordering
- You want to ensure certain skills are always loaded in context (not just discoverable) when the plugin is active
- Cross-skill references need to be reliable, not conditional
Plugins with a single skill, or skills that are genuinely independent, don't need this pattern. It's for products that compose.
A plugin's MCP server can act as both server (exposing tools to Claude) and client (calling tools on other MCP servers). This is useful when a plugin needs to augment, filter, or compose data from an external service with local business logic.
We use the official MCP Python SDK
(pip install mcp) for both server and client. See
MCP framework for details on the SDK,
how it relates to other packages in the ecosystem, and working examples.
A single server.py can:
- Expose tools to Claude via stdio (the standard plugin MCP pattern)
- Call tools on a remote MCP server via HTTP using the SDK's client session
from mcp.server.fastmcp import FastMCP
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
mcp = FastMCP("my-orchestrator")
async def call_remote_tool(tool_name: str, arguments: dict) -> list:
"""Call a tool on a remote MCP server."""
async with streamablehttp_client(REMOTE_URL) as (r, w, _):
async with ClientSession(r, w) as session:
await session.initialize()
result = await session.call_tool(tool_name, arguments)
return result.content
@mcp.tool()
async def enriched_list(category: str) -> dict:
"""Fetch remote data and enrich with local logic."""
remote_data = await call_remote_tool("list_items", {"category": category})
local_config = load_local_config()
return cross_reference(remote_data, local_config)- The plugin adds value by combining data from an external service with local configuration or business rules
- The external service is already available as an MCP server (HTTP or stdio)
- You want a single plugin install to give the user the full workflow, rather than requiring them to separately install and register multiple MCP servers
- Skills in the plugin are tightly coupled to both the local tools and the remote data
- The external MCP server's tools are useful on their own (let the user register it separately)
- The plugin only reads local data (no external dependency needed)
- The remote service is unreliable and you want the local tools to work independently