A Claude Code plugin can ship an MCP server the same way it ships a command or a
hook: drop a .mcp.json at the plugin root, and Claude Code starts the server for
anyone who installs the plugin. The user runs no pip install, edits no config,
and copies no path. They install the plugin, and the server’s tools appear.
The trick that makes this painless is launching the server with uv run --script
against a file that declares its own dependencies inline (PEP 723). The consumer
needs only uv on their PATH; uv builds an ephemeral environment with the right
packages on first run. No committed virtualenv, nothing to install ahead of time.
This tutorial builds a plugin named text-tools that bundles a small FastMCP
stdio server exposing two deterministic tools, slugify and word_stats. You
will drive the server headlessly to prove it works, validate the manifests,
install it through a local marketplace so claude plugin details reports the
server, and publish the repository to GitHub.
This is a companion to
Create and Distribute a Claude Code Plugin, which
covers commands, subagents, and hooks. Everything about the plugin manifest,
marketplace catalog, validation, and distribution is the same here; the new
surface is .mcp.json and the server it points at. If you have not packaged a
plugin before, skim that article first for the manifest and marketplace details,
which this one summarizes rather than repeats.
What you will build
A plugin named text-tools, listed in a marketplace named text-tools-market.
The repository doubles as the marketplace:
text-tools-marketplace/
├── .claude-plugin/
│ └── marketplace.json # Catalog: lists the plugin and its source
├── plugins/
│ └── text-tools/
│ ├── .claude-plugin/
│ │ └── plugin.json # Plugin manifest: name, version, metadata
│ ├── .mcp.json # Registers the MCP server
│ └── mcp/
│ └── server.py # FastMCP stdio server (self-contained)
├── scripts/
│ └── smoke_test.py # Headless client that drives the server
├── Makefile # validate / test-server / pack targets
└── .gitignore
Prerequisites
- macOS (the commands are macOS-first; they work on Linux unchanged).
- Claude Code v2.1.128 or later. This tutorial was validated on v2.1.210.
Check with
claude --version; upgrade withclaude updateif needed. - uv 0.5 or later, the package and project manager that launches the server. Install from docs.astral.sh/uv; this tutorial used uv 0.11.26. The consumer needs uv too, but nothing else.
- git, make, and zip — preinstalled on macOS except git; used by the Makefile and the final distribution step.
- A GitHub account, for distribution.
No manual Python setup is required. The server declares fastmcp inline, and uv
resolves it on first launch.
How a bundled MCP server loads
Claude Code discovers a plugin’s MCP servers from a single file, .mcp.json, at
the plugin root. Its format matches the mcpServers block you would otherwise put
in a project’s own .mcp.json or in Claude Code settings, with one addition: the
${CLAUDE_PLUGIN_ROOT} variable expands to the plugin’s install directory at
launch time. That is what lets the config point at a bundled script no matter
where the plugin was installed.
When a user installs the plugin, Claude Code reads .mcp.json, runs the command
with its args, and speaks MCP to the process over stdio. The server’s tools then
show up alongside every other tool Claude can call. Unlike a command or a skill,
an MCP server adds almost nothing to the session’s always-on token cost: tool
schemas are fetched from the running server when needed, not injected into every
prompt.
Step 1: Scaffold the repository and ignore build artifacts
Create the file
mkdir -p text-tools-marketplace/.claude-plugin
mkdir -p text-tools-marketplace/plugins/text-tools/.claude-plugin
mkdir -p text-tools-marketplace/plugins/text-tools/mcp
mkdir -p text-tools-marketplace/scripts
cd text-tools-marketplace
touch .gitignore
Add the code: text-tools-marketplace/.gitignore
# Build artifacts produced by `make pack`
dist/
*.zip
# uv / Python ephemera
__pycache__/
.venv/
# macOS clutter
.DS_Store
# Local Claude Code state, if you test inside this repo
.claude/
Detailed breakdown
.claude-plugin/at two levels. The one at the repo root holdsmarketplace.json; the one insideplugins/text-tools/holdsplugin.json. Only those two manifest files live in a.claude-plugin/directory. The.mcp.jsonand themcp/folder sit at the plugin root, not inside.claude-plugin/.__pycache__/and.venv/. Because uv runs the server from an inline-script environment, there is no project virtualenv to commit, but Python still writes__pycache__/next to the script. Ignoring both keeps the repo to source only..gitignorefirst. Thedist/archive frommake pack(Step 6) is gitignored up front so it never lands in a commit.
Step 2: Write the plugin manifest
Create the file
touch plugins/text-tools/.claude-plugin/plugin.json
Add the code: plugins/text-tools/.claude-plugin/plugin.json
{
"name": "text-tools",
"description": "Bundles a FastMCP stdio server exposing slugify and word_stats tools, launched with uv so consumers need no pre-install.",
"version": "1.0.0",
"author": {
"name": "Your Name"
},
"homepage": "https://github.com/your-org/text-tools-marketplace",
"license": "MIT"
}
Detailed breakdown
nameis the namespace and half the install id. Installing this plugin istext-tools@text-tools-market, plugin name@marketplace name.versioncontrols updates. With aversionset, users only get an update when you bump it. Bump it on each release.- The manifest does not list the server. There is no
mcpServersfield here. Claude Code finds.mcp.jsonby its conventional location at the plugin root. The manifest only names and versions the plugin.
Step 3: Write the MCP server
The server is an ordinary FastMCP stdio server. The one detail that makes it
plugin-friendly is the PEP 723 header, which declares its dependencies inside the
file so uv run --script can build an environment for it on demand.
Create the file
touch plugins/text-tools/mcp/server.py
Add the code: plugins/text-tools/mcp/server.py
# /// script
# requires-python = ">=3.10"
# dependencies = ["fastmcp>=3.4.2"]
# ///
"""A small, dependency-declaring MCP server bundled inside a Claude Code plugin.
The PEP 723 header above lets `uv run --script` create an ephemeral environment
with FastMCP installed, so a consumer needs only `uv` on their PATH — no venv to
set up and nothing to pip-install first.
"""
import re
from fastmcp import FastMCP
# The name is what clients (and `claude mcp` / plugin details) show for this
# server during discovery.
mcp = FastMCP("text-tools")
@mcp.tool
def slugify(text: str) -> str:
"""Convert text to a URL-safe slug.
Lowercases the input, replaces every run of non-alphanumeric characters with
a single hyphen, and trims leading/trailing hyphens.
Args:
text: Arbitrary text, e.g. an article title.
"""
lowered = text.lower()
hyphenated = re.sub(r"[^a-z0-9]+", "-", lowered)
return hyphenated.strip("-")
@mcp.tool
def word_stats(text: str) -> dict[str, int | list[str]]:
"""Report word counts for a block of text.
Returns the total word count, the number of distinct words (compared
case-insensitively), and the three most frequent words in descending order
of count, ties broken by first appearance.
Args:
text: The text to analyze.
"""
words = re.findall(r"[a-z0-9']+", text.lower())
total = len(words)
counts: dict[str, int] = {}
for word in words:
counts[word] = counts.get(word, 0) + 1
# Sort by count descending; Python's sort is stable, so equal counts keep
# first-appearance order, which makes the "top" list deterministic.
top = sorted(counts, key=lambda w: counts[w], reverse=True)[:3]
return {"total": total, "unique": len(counts), "top": top}
if __name__ == "__main__":
# No transport argument means stdio, which is what a plugin-launched server
# speaks: Claude Code starts this process and talks to it over stdin/stdout.
mcp.run()
Detailed breakdown
- The PEP 723 header is the whole point. The
# /// script ... # ///block is a standard way to embed dependency metadata in a single Python file.uv run --scriptreads it, builds (and caches) an environment withfastmcpinstalled, and runs the file in it. Nothing is installed into the user’s global Python or a committed venv. Pinfastmcp>=3.4.2(validated here on 3.4.4) so a future major cannot silently change behavior. FastMCP("text-tools")creates the server; the name is what appears during discovery and inclaude plugin details.@mcp.toolregisters each function as a callable tool. FastMCP derives the tool’s input schema from the type hints and its description from the docstring, so theArgs:lines become parameter documentation the model sees. Both tools are pure functions of their input, which keeps the behavior deterministic and testable.word_statsdeterminism. Counting is order-independent, but “top 3” is not unless ties are resolved. Buildingcountsby first appearance and relying on Python’s stablesortedmeans equal-frequency words keep encounter order, so the result never depends on dict iteration luck.mcp.run()under__main__starts the stdio transport when the file is executed directly. Guarding it means a test can import the module without spawning the server loop.
Step 4: Register the server with .mcp.json
Create the file
touch plugins/text-tools/.mcp.json
Add the code: plugins/text-tools/.mcp.json
{
"mcpServers": {
"text-tools": {
"command": "uv",
"args": ["run", "--script", "${CLAUDE_PLUGIN_ROOT}/mcp/server.py"]
}
}
}
Detailed breakdown
mcpServersis a map of server name to launch config. One plugin can bundle several servers by adding more keys. The key (text-tools) is the server’s name as Claude Code refers to it.commandandargsare how the server is started. Hereuv run --script <path>launches the PEP 723 file in its own environment. Because the command isuv, the only thing the consumer’s machine must provide is uv itself.${CLAUDE_PLUGIN_ROOT}resolves at launch. It expands to wherever the plugin was installed, so the same config works from your working tree, a local marketplace, or a cloned GitHub repo. A hard-coded absolute path would break the moment someone else installed the plugin.- stdio is implied. There is no
urlortransportfield, so Claude Code launches the process and communicates over stdin/stdout — the right transport for a server that ships inside a plugin and runs locally. A remote HTTP server would instead use aurlentry and would not needcommand/argsat all.
Step 5: Drive the server headlessly with a smoke test
Before wiring the plugin into Claude Code, confirm the server launches and its
tools return what you expect. This test connects the same way Claude Code will —
by running uv run --script mcp/server.py over stdio — so a green run proves the
real launch path, not just the Python logic.
Create the file
touch scripts/smoke_test.py
Add the code: scripts/smoke_test.py
# /// script
# requires-python = ">=3.10"
# dependencies = ["fastmcp>=3.4.2"]
# ///
"""Headless smoke test for the bundled MCP server.
Connects to the plugin's server exactly the way Claude Code would — by launching
`uv run --script mcp/server.py` over stdio — then lists the tools and calls each
one, asserting the deterministic results. Run with `make test-server`.
"""
import asyncio
import sys
from pathlib import Path
from fastmcp import Client
SERVER = Path(__file__).resolve().parent.parent / "plugins" / "text-tools" / "mcp" / "server.py"
# The same shape as the plugin's .mcp.json, so this test exercises the real
# launch command rather than importing the module in-process.
CONFIG = {
"mcpServers": {
"text-tools": {
"command": "uv",
"args": ["run", "--script", str(SERVER)],
}
}
}
async def main() -> int:
async with Client(CONFIG) as client:
tools = sorted(t.name for t in await client.list_tools())
assert tools == ["slugify", "word_stats"], tools
print(f"tools: {tools}")
slug = await client.call_tool("slugify", {"text": "Hello, World! -- 2026"})
assert slug.data == "hello-world-2026", slug.data
print(f"slugify -> {slug.data}")
stats = await client.call_tool(
"word_stats", {"text": "the cat sat on the mat the cat"}
)
assert stats.data == {"total": 8, "unique": 5, "top": ["the", "cat", "sat"]}, stats.data
print(f"word_stats -> {stats.data}")
print("OK: server launched over stdio, tools discovered and called")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
Detailed breakdown
Client(CONFIG)takes the same config as.mcp.json. FastMCP’s client accepts anmcpServersdict directly, so the test launches the server through the exactuv run --scriptcommand the plugin ships. This catches a broken launch (missing dependency, wrong path) that importing the module in-process would hide.list_toolsthencall_tool. The test asserts both tools are discovered, then calls each with a fixed input and checks the result.call_toolreturns a result object whose.dataholds the structured return value — a string forslugify, a dict forword_stats.- The assertions are hand-checkable.
slugify("Hello, World! -- 2026")lowers tohello, world! -- 2026, collapses every non-alphanumeric run to a hyphen, and trims, givinghello-world-2026.word_stats("the cat sat on the mat the cat")sees eight words, five distinct (the,cat,sat,on,mat), withthe(3) andcat(2) leading andsatfirst among the singletons, sotopis["the", "cat", "sat"]. - This file is not shipped in the plugin. It lives under the repo’s
scripts/, outsideplugins/text-tools/, so it validates the server without adding weight to what consumers install. Thepacktarget in the next step zips only the plugin directory.
Step 6: Add a Makefile
Create the file
touch Makefile
Add the code: text-tools-marketplace/Makefile
# text-tools marketplace — validation, testing, and packaging tasks.
PLUGIN_DIR := plugins/text-tools
PLUGIN_NAME := text-tools
DIST := dist
.DEFAULT_GOAL := help
.PHONY: help validate validate-plugin test-server pack clean
help: ## Show this help screen
@echo "text-tools marketplace tasks:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "} {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
validate: ## Validate the marketplace catalog (strict)
claude plugin validate . --strict
validate-plugin: ## Validate the plugin manifest and its .mcp.json (strict)
claude plugin validate ./$(PLUGIN_DIR) --strict
test-server: ## Launch the bundled server over stdio and call each tool
uv run --script scripts/smoke_test.py
pack: ## Build a distributable zip of the plugin for --plugin-dir
@mkdir -p $(DIST)
@rm -f $(DIST)/$(PLUGIN_NAME).zip
cd $(PLUGIN_DIR) && zip -r ../../$(DIST)/$(PLUGIN_NAME).zip . -x '*.DS_Store' -x '*__pycache__*'
@echo "Wrote $(DIST)/$(PLUGIN_NAME).zip"
clean: ## Remove build artifacts
rm -rf $(DIST)
Detailed breakdown
- Default target prints help.
.DEFAULT_GOAL := helpmakes a baremakelist the targets. Thegrep/awkpipeline reads the##comment after each target, so the help screen stays in sync as you add targets. validate/validate-plugin. Pointed at the repo root, the validator checksmarketplace.json; pointed at the plugin directory, it also checksplugin.jsonand the.mcp.json.--strictfails the build on any warning, which is what you want in CI.test-serverruns the Step 5 smoke test, the only test that exercises the server itself. Run it after any change toserver.py.packzips the plugin’s contents (note thecd), excluding__pycache__, so the archive root holds.claude-plugin/plugin.jsonand.mcp.json. It works withclaude --plugin-dir ./dist/text-tools.zip.- Tabs, not spaces. Recipe lines must be indented with a real tab. Copy the block verbatim.
Step 7: Write the marketplace catalog
Create the file
touch .claude-plugin/marketplace.json
Add the code: text-tools-marketplace/.claude-plugin/marketplace.json
{
"name": "text-tools-market",
"owner": {
"name": "Your Name",
"email": "you@example.com"
},
"metadata": {
"description": "MCP-backed text utilities for Claude Code."
},
"plugins": [
{
"name": "text-tools",
"source": "./plugins/text-tools",
"description": "Bundles a FastMCP stdio server exposing slugify and word_stats tools, launched with uv so consumers need no pre-install.",
"version": "1.0.0",
"category": "mcp",
"tags": ["mcp", "text", "uv", "fastmcp"]
}
]
}
Detailed breakdown
nameis what users type. Installing istext-tools@text-tools-market. Names resembling an official Anthropic marketplace are reserved and blocked.source: "./plugins/text-tools"is a relative path from the marketplace root (the directory containing.claude-plugin/) and must start with./. It resolves when a user adds the marketplace from git or a local path.- The duplicated
version. Listingversionhere and inplugin.jsonis intentional; the validator warns if they disagree, catching the classic mistake of bumping one and forgetting the other. owneris required.nameis mandatory;emailis optional but useful, because installing this plugin lets it launch a local process, which users weigh before trusting it.
Step 8: Validate, then drive the server
Validate the manifests first — this catches a malformed .mcp.json before you try
to load it.
make validate
make validate-plugin
Both print ✔ Validation passed and exit 0. Then launch the server and call its
tools:
make test-server
The first run pauses briefly while uv resolves fastmcp into a cached
environment; later runs reuse it. FastMCP prints a startup banner to stderr, then
the test prints its assertions:
tools: ['slugify', 'word_stats']
slugify -> hello-world-2026
word_stats -> {'total': 8, 'unique': 5, 'top': ['the', 'cat', 'sat']}
OK: server launched over stdio, tools discovered and called
If uv is missing, the launch fails with a command-not-found error; install uv and
re-run. If fastmcp cannot be resolved, check the PEP 723 header spelling — the
block must be exactly # /// script … # /// with the dependency on its own line.
Step 9: Install from the local marketplace
To exercise the full install path the way your users will, add the marketplace and
install from it. Point the add at the repo root (the directory above
.claude-plugin/):
claude plugin marketplace add ./
claude plugin install text-tools@text-tools-market
claude plugin marketplace add accepts a local path, a git URL, or a GitHub
owner/repo shorthand. The equivalent in-session commands are /plugin marketplace add and /plugin install. Inspect what got installed:
claude plugin details text-tools
The component inventory lists one MCP server and no other components:
text-tools 1.0.0
...
Component inventory
Skills (0)
Agents (0)
Hooks (0)
MCP servers (1) text-tools (tool schemas resolved at runtime; not counted)
LSP servers (0)
Projected token cost
Always-on: ~0 tok added to every session
The ~0 tok always-on cost is the payoff of shipping tools as an MCP server: the
schemas are pulled from the running server when Claude needs them, so the plugin
adds nothing to every prompt the way an always-loaded skill would. In an
interactive session you can then ask Claude to “slugify this title” or “get word
stats for this paragraph” and it calls the bundled server; /mcp lists the server
and its two tools.
Remove the local test install before publishing for real:
claude plugin marketplace remove text-tools-market
Removing a marketplace from its last scope also uninstalls the plugins you installed from it.
Step 10: Distribute through GitHub
Publishing is pushing the repository. The marketplace.json at the root is what
makes it an installable marketplace.
Create the file
git init
git add .
git commit -m "text-tools plugin with bundled MCP server v1.0.0"
git branch -M main
git remote add origin git@github.com:your-org/text-tools-marketplace.git
git push -u origin main
Detailed breakdown
What consumers run. Once the repo is public, anyone installs with two commands:
/plugin marketplace add your-org/text-tools-marketplace /plugin install text-tools@text-tools-marketThe GitHub
owner/reposhorthand is the marketplace source. Because the plugin entry uses a relativesource, Claude Code clones the repo and resolves./plugins/text-toolsinside its local copy, where${CLAUDE_PLUGIN_ROOT}then points.The consumer’s only dependency is uv. They do not install
fastmcp; uv resolves it from the PEP 723 header the first time the server launches. Say so in your README so users know to install uv.Releasing an update. Change the server, bump
versionin bothplugin.jsonandmarketplace.json, and push. Users pull it with/plugin marketplace update text-tools-market.Private distribution. Host the marketplace repo privately; teammates add it with the same command as long as their git credentials grant access.
Recap
You shipped an MCP server as part of a plugin:
- A FastMCP stdio server (
mcp/server.py) that declares its dependencies inline with PEP 723, souv run --scriptlaunches it with no pre-install. - A
.mcp.jsonat the plugin root that registers the server and points at the bundled script through${CLAUDE_PLUGIN_ROOT}. - A headless smoke test that launches the server over stdio and asserts each
tool’s output, wired into
make test-server. - A plugin manifest and a marketplace catalog that publish the whole thing
behind
/plugin install text-tools@text-tools-market.
The consumer installs one plugin and gains two tools, with no environment setup and
near-zero always-on token cost. The .mcp.json plus a self-contained,
dependency-declaring script is the pattern; swap in any FastMCP server and the rest
of the plugin machinery is unchanged.
Troubleshooting
- Server missing after install. Run
/reload-plugins, then/mcpto list MCP servers. Confirm.mcp.jsonsits at the plugin root, not inside.claude-plugin/. - Server fails to start. Launch Claude Code with
claude --debugto see the spawn command and the server’s stderr. The most common causes areuvnot on the PATH and a typo in the PEP 723 header. Reproduce outside Claude withmake test-server. ${CLAUDE_PLUGIN_ROOT}shows up literally. The variable is only expanded inside a plugin’s.mcp.json. If you copied the config into a project or user.mcp.json, it will not expand there; use an absolute path in that context.- First tool call is slow. uv resolves and caches the environment on the first
launch. Subsequent starts reuse the cache. To warm it ahead of time, run
make test-serveronce after installing uv. - Validation passes but a tool misbehaves.
claude plugin validatechecks manifests and schemas, not tool logic.make test-serveris what verifies behavior; add assertions there for each tool.
Next improvements
- Add more tools to
server.py; no manifest or.mcp.jsonchange is needed, since tools are discovered from the running server. - Combine surfaces: add a
commands/slash command to the same plugin that calls the server’s tools, so the plugin ships both the tools and the workflow that drives them. See the command, agent, and hook article. - Pass configuration to the server with an
envblock in.mcp.json(for example, an API base URL), so one server adapts per install. - Pin
fastmcpto an exact version in the PEP 723 header for fully reproducible launches across a team. - Reference an external server instead of bundling one when the server already exists elsewhere, so the plugin is pure config. See the companion article Distribute a Claude Code Plugin That Installs an External MCP Server.