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 with claude update if 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 holds marketplace.json; the one inside plugins/text-tools/ holds plugin.json. Only those two manifest files live in a .claude-plugin/ directory. The .mcp.json and the mcp/ 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.
  • .gitignore first. The dist/ archive from make 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

  • name is the namespace and half the install id. Installing this plugin is text-tools@text-tools-market, plugin name @ marketplace name.
  • version controls updates. With a version set, users only get an update when you bump it. Bump it on each release.
  • The manifest does not list the server. There is no mcpServers field here. Claude Code finds .mcp.json by 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 --script reads it, builds (and caches) an environment with fastmcp installed, and runs the file in it. Nothing is installed into the user’s global Python or a committed venv. Pin fastmcp>=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 in claude plugin details.
  • @mcp.tool registers each function as a callable tool. FastMCP derives the tool’s input schema from the type hints and its description from the docstring, so the Args: lines become parameter documentation the model sees. Both tools are pure functions of their input, which keeps the behavior deterministic and testable.
  • word_stats determinism. Counting is order-independent, but “top 3” is not unless ties are resolved. Building counts by first appearance and relying on Python’s stable sorted means 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

  • mcpServers is 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.
  • command and args are how the server is started. Here uv run --script <path> launches the PEP 723 file in its own environment. Because the command is uv, 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 url or transport field, 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 a url entry and would not need command/args at 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 an mcpServers dict directly, so the test launches the server through the exact uv run --script command the plugin ships. This catches a broken launch (missing dependency, wrong path) that importing the module in-process would hide.
  • list_tools then call_tool. The test asserts both tools are discovered, then calls each with a fixed input and checks the result. call_tool returns a result object whose .data holds the structured return value — a string for slugify, a dict for word_stats.
  • The assertions are hand-checkable. slugify("Hello, World! -- 2026") lowers to hello, world! -- 2026, collapses every non-alphanumeric run to a hyphen, and trims, giving hello-world-2026. word_stats("the cat sat on the mat the cat") sees eight words, five distinct (the, cat, sat, on, mat), with the (3) and cat (2) leading and sat first among the singletons, so top is ["the", "cat", "sat"].
  • This file is not shipped in the plugin. It lives under the repo’s scripts/, outside plugins/text-tools/, so it validates the server without adding weight to what consumers install. The pack target 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 := help makes a bare make list the targets. The grep/awk pipeline 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 checks marketplace.json; pointed at the plugin directory, it also checks plugin.json and the .mcp.json. --strict fails the build on any warning, which is what you want in CI.
  • test-server runs the Step 5 smoke test, the only test that exercises the server itself. Run it after any change to server.py.
  • pack zips the plugin’s contents (note the cd), excluding __pycache__, so the archive root holds .claude-plugin/plugin.json and .mcp.json. It works with claude --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

  • name is what users type. Installing is text-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. Listing version here and in plugin.json is intentional; the validator warns if they disagree, catching the classic mistake of bumping one and forgetting the other.
  • owner is required. name is mandatory; email is 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-market
    

    The GitHub owner/repo shorthand is the marketplace source. Because the plugin entry uses a relative source, Claude Code clones the repo and resolves ./plugins/text-tools inside 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 version in both plugin.json and marketplace.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:

  1. A FastMCP stdio server (mcp/server.py) that declares its dependencies inline with PEP 723, so uv run --script launches it with no pre-install.
  2. A .mcp.json at the plugin root that registers the server and points at the bundled script through ${CLAUDE_PLUGIN_ROOT}.
  3. A headless smoke test that launches the server over stdio and asserts each tool’s output, wired into make test-server.
  4. 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 /mcp to list MCP servers. Confirm .mcp.json sits at the plugin root, not inside .claude-plugin/.
  • Server fails to start. Launch Claude Code with claude --debug to see the spawn command and the server’s stderr. The most common causes are uv not on the PATH and a typo in the PEP 723 header. Reproduce outside Claude with make 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-server once after installing uv.
  • Validation passes but a tool misbehaves. claude plugin validate checks manifests and schemas, not tool logic. make test-server is what verifies behavior; add assertions there for each tool.

Next improvements

  • Add more tools to server.py; no manifest or .mcp.json change 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 env block in .mcp.json (for example, an API base URL), so one server adapts per install.
  • Pin fastmcp to 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.