Sometimes the MCP server you want your team to use already exists — published to
a registry, a GitHub repo, or a container image by someone else. You do not need
to fork it or vendor its source into your plugin. A Claude Code plugin can be
pure configuration: a .mcp.json that tells Claude Code how to launch that
external server over stdio, wrapped in a manifest and a marketplace so a teammate
installs it with one command.
This tutorial builds hello-mcp, a plugin whose only real content is a
.mcp.json pointing at mcp-hello-server,
a small public FastMCP server exposing two tools, server_info and greet. The
plugin launches it with uvx straight from the server’s GitHub release, so the
only thing a consumer needs is uv — no clone, no pip install, no vendored
code. You will validate the manifest, drive the server headlessly to prove the
launch command works, install the plugin through a local marketplace, and publish
it to GitHub.
This is the third plugin article in a set. The first covers commands, subagents, and hooks; the second bundles a server’s source inside the plugin. This one is the opposite of bundling: the server lives and is versioned somewhere else, and the plugin only references it. Reach for this pattern when you want to hand a team a ready-made server without owning its code.
What you will build
A plugin named hello-mcp, listed in a marketplace named hello-mcp-market. The
plugin ships two files — a manifest and a .mcp.json — plus a smoke test that
lives in the repo but is not part of what consumers install:
hello-mcp-plugin/
├── .claude-plugin/
│ └── marketplace.json # Catalog: lists the plugin and its source
├── plugins/
│ └── hello-mcp/
│ ├── .claude-plugin/
│ │ └── plugin.json # Plugin manifest: name, version, metadata
│ └── .mcp.json # Launch config for the external server
├── scripts/
│ └── smoke_test.py # Headless client that drives the server
├── Makefile # validate / test-server / pack targets
└── .gitignore
Note what is absent: there is no server code. The plugin is config that points at a server maintained in another repository.
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 and git.
uvx(bundled with uv) builds and runs the external server from its GitHub tag; git is how it fetches the source and how the marketplace is distributed. Install uv from docs.astral.sh/uv; this tutorial used uv 0.11.26. - make and zip — preinstalled on macOS; used by the Makefile.
- A GitHub account, for the distribution step.
The consumer’s machine needs only uv and git. They do not install
mcp-hello-server themselves; uvx resolves it on first launch.
Choosing a launch command
The whole plugin hinges on one line: the command Claude Code runs to start the
server over stdio. How you write it depends on how the external server is
distributed. For mcp-hello-server, three options exist, and the difference
matters:
uvxfrom the GitHub release (used here). The server is a Python package with a console script.uvx --from git+<repo>@<tag> mcp-hello-serverbuilds it in an isolated, cached environment and runs it. Needs only uv and git; pins to a tag for reproducibility.uvxfrom PyPI. If the package were published to PyPI,uvx mcp-hello-serverwould be enough. At the time of writing it is not on PyPI (a plainuvx mcp-hello-serverfails to resolve), so this tutorial uses the git source instead. If the maintainer publishes to PyPI later, switching is a one-line change.- Docker. The server also ships as a container image.
docker run -i --rm -e MCP_TRANSPORT=stdio mitchallen/mcp-hello-server:0.3.0speaks stdio over the-i(interactive) pipe. Choose this when you would rather depend on Docker than on a Python toolchain. Step 8 shows the alternate.mcp.json.
All three speak the same MCP-over-stdio protocol; they differ only in what the consumer must have installed. This tutorial standardizes on the uv path because it needs no Docker daemon and matches a uv-based workflow.
Step 1: Scaffold the repository and ignore build artifacts
Create the file
mkdir -p hello-mcp-plugin/.claude-plugin
mkdir -p hello-mcp-plugin/plugins/hello-mcp/.claude-plugin
mkdir -p hello-mcp-plugin/scripts
cd hello-mcp-plugin
touch .gitignore
Add the code: hello-mcp-plugin/.gitignore
# Build artifacts produced by `make pack`
dist/
*.zip
# uv / Python ephemera (the smoke test runs uv)
__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/hello-mcp/holdsplugin.json. The.mcp.jsonsits at the plugin root, next to (not inside) that folder.__pycache__/and.venv/. The plugin ships no Python, but the smoke test in Step 5 runs under uv, which can leave a__pycache__/. Ignoring it 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/hello-mcp/.claude-plugin/plugin.json
Add the code: plugins/hello-mcp/.claude-plugin/plugin.json
{
"name": "hello-mcp",
"description": "Installs the external mcp-hello-server (server_info + greet tools) over stdio, launched from its GitHub release with uvx so consumers need only uv.",
"version": "1.0.0",
"author": {
"name": "Your Name"
},
"homepage": "https://github.com/your-org/hello-mcp-plugin",
"license": "MIT"
}
Detailed breakdown
- The plugin’s version is its own, not the server’s.
hello-mcpis at1.0.0; the server it installs is atv0.3.0. They version independently. You bump the plugin when you change the launch config (for example, to point at a newer server tag); you do not have to match the server’s numbers. descriptionstates what it installs. Because this plugin’s entire value is the server it wires up, the description names the server and its tools so a user browsing a marketplace knows what they are getting.- The manifest does not list the server. Claude Code finds
.mcp.jsonby its conventional location at the plugin root; the manifest only names and versions the plugin.
Step 3: Write the launch config
This is the plugin. .mcp.json tells Claude Code how to start the external
server and talk to it over stdio.
Create the file
touch plugins/hello-mcp/.mcp.json
Add the code: plugins/hello-mcp/.mcp.json
{
"mcpServers": {
"hello": {
"command": "uvx",
"args": [
"--from",
"git+https://github.com/mitchallen/[email protected]",
"mcp-hello-server"
],
"env": {
"MCP_TRANSPORT": "stdio"
}
}
}
}
Detailed breakdown
command: "uvx"is the launcher.uvxruns a Python console script in a throwaway, cached environment. The consumer only needs uv on their PATH; uvx handles the rest.--from [email protected]tells uvx where to get the package and which version.git+https://.../[email protected]fetches the tagged release and builds it; the trailingmcp-hello-servernames the console script to run from it. Pinning to thev0.3.0tag makes every install reproducible — without it, uvx would build whatevermainhappens to be.env.MCP_TRANSPORT: "stdio"selects the stdio transport. This server defaults to stdio already, so the variable is belt-and-suspenders here, but setting it documents intent and protects against a server whose default is different. Claude Code launches the process and exchanges MCP messages over its stdin/stdout.- The server name
hellois the key undermcpServers. It is how Claude Code refers to the server; its tools appear as the server exposes them (greet,server_info). - No
${CLAUDE_PLUGIN_ROOT}here. The previous article used that variable to point at a script inside the plugin. This plugin references nothing local, so there is no path to resolve — the server comes entirely from the git URL.
Step 4: Write the marketplace catalog
Create the file
touch .claude-plugin/marketplace.json
Add the code: hello-mcp-plugin/.claude-plugin/marketplace.json
{
"name": "hello-mcp-market",
"owner": {
"name": "Your Name",
"email": "[email protected]"
},
"metadata": {
"description": "A Claude Code plugin that installs the mcp-hello-server over stdio."
},
"plugins": [
{
"name": "hello-mcp",
"source": "./plugins/hello-mcp",
"description": "Installs the external mcp-hello-server (server_info + greet tools) over stdio, launched from its GitHub release with uvx so consumers need only uv.",
"version": "1.0.0",
"category": "mcp",
"tags": ["mcp", "stdio", "uvx", "greeting"]
}
]
}
Detailed breakdown
nameis what users type. Installing ishello-mcp@hello-mcp-market, plugin name@marketplace name.source: "./plugins/hello-mcp"is a relative path from the marketplace root and must start with./. It resolves when a user adds the marketplace from git or a local path.- Two versions, two meanings. The
versionhere (and inplugin.json) is the plugin’s version; the server’sv0.3.0lives only in.mcp.json. The validator warns if the plugin’s twoversionfields disagree, but it has no opinion about the server tag. owneris required. Installing this plugin lets it launch a process that fetches and runs third-party code, so a contact matters more here than usual.
Step 5: Drive the external server headlessly
Before installing the plugin, prove the launch command actually starts the server
and returns what you expect. This test connects the same way Claude Code will — by
running the exact uvx command from .mcp.json over stdio — so a green run
validates the real dependency, not a local stand-in.
Create the file
touch scripts/smoke_test.py
Add the code: scripts/smoke_test.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["fastmcp>=2.9.0"]
# ///
"""Headless smoke test for the externally-installed MCP server.
Launches mcp-hello-server exactly the way the plugin's .mcp.json does — with
`uvx --from [email protected] mcp-hello-server` over stdio — then lists the tools
and calls each one, asserting the deterministic results. Run with
`make test-server`. Needs only `uv` and `git`; uvx builds the server from its
GitHub release on first run and caches it.
"""
import asyncio
import sys
from fastmcp import Client
# The same launch config the plugin ships in .mcp.json, so this test exercises
# the real stdio command rather than a stand-in.
CONFIG = {
"mcpServers": {
"hello": {
"command": "uvx",
"args": [
"--from",
"git+https://github.com/mitchallen/[email protected]",
"mcp-hello-server",
],
"env": {"MCP_TRANSPORT": "stdio"},
}
}
}
async def main() -> int:
async with Client(CONFIG) as client:
tools = sorted(t.name for t in await client.list_tools())
assert tools == ["greet", "server_info"], tools
print(f"tools: {tools}")
default = await client.call_tool("greet", {})
assert default.data == {
"language": "english",
"greeting": "Hello",
"message": "Hello!",
}, default.data
print(f"greet() -> {default.data['message']}")
french = await client.call_tool("greet", {"language": "french", "name": "Alice"})
assert french.data["message"] == "Bonjour, Alice!", french.data
print(f"greet(french, Alice) -> {french.data['message']}")
info = await client.call_tool("server_info", {})
# uptime varies between runs, so assert only the stable fields.
assert info.data["status"] == "OK", info.data
assert info.data["version"] == "0.3.0", info.data
print(f"server_info -> status={info.data['status']} version={info.data['version']}")
print("OK: external server launched over stdio, tools discovered and called")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
Detailed breakdown
Client(CONFIG)reuses the.mcp.jsonshape. FastMCP’s client accepts anmcpServersdict, so the test spawns the server through the identicaluvxcommand the plugin ships. A broken launch (wrong tag, unresolvable git URL, a server that does not speak stdio) fails here, before a user ever installs.- The assertions are hand-checkable.
greet()with no arguments defaults to English and returns{"language": "english", "greeting": "Hello", "message": "Hello!"}.greet(language="french", name="Alice")returns"Bonjour, Alice!". Both come straight from the server’s greeting table. server_infois checked partially. Itsuptimefield changes every run, so the test asserts only the stable fields (statusisOK,versionis0.3.0). Asserting a varying value would make the test flaky — a general rule for any tool that reports time.- This file is not shipped. It lives under the repo’s
scripts/, outside the plugin directory, somake packnever includes it. Consumers install two config files; the test is yours.
Step 6: Add a Makefile
Create the file
touch Makefile
Add the code: hello-mcp-plugin/Makefile
# hello-mcp marketplace — validation, testing, and packaging tasks.
PLUGIN_DIR := plugins/hello-mcp
PLUGIN_NAME := hello-mcp
DIST := dist
.DEFAULT_GOAL := help
.PHONY: help validate validate-plugin test-server pack clean
help: ## Show this help screen
@echo "hello-mcp 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 external 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. 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 on any warning.test-serverruns the Step 5 smoke test — the only check that exercises the real external server. Run it after changing the pinned tag.packzips the plugin’s contents, so the archive root holds.claude-plugin/plugin.jsonand.mcp.json— just two files, because there is no bundled server. It works withclaude --plugin-dir ./dist/hello-mcp.zip.- Tabs, not spaces. Recipe lines must be indented with a real tab.
Step 7: Validate, then drive the server
Validate the manifests first:
make validate
make validate-plugin
Both print ✔ Validation passed and exit 0. Then launch the external server and
call its tools:
make test-server
The first run pauses while uvx clones the tagged release and builds it into a cached environment; later runs reuse the cache. FastMCP prints a startup banner to stderr, then the test prints its assertions:
tools: ['greet', 'server_info']
greet() -> Hello!
greet(french, Alice) -> Bonjour, Alice!
server_info -> status=OK version=0.3.0
OK: external server launched over stdio, tools discovered and called
If uvx cannot resolve the source, check the git URL and that the v0.3.0 tag
exists in the upstream repo. If uv is missing, install it and re-run.
Step 8: (Optional) Use Docker instead of uv
If your team would rather depend on Docker than on a Python toolchain, swap the
.mcp.json for the container launch. mcp-hello-server publishes an image, and
docker run -i gives the server the stdio pipe it needs:
Add the code (alternative): plugins/hello-mcp/.mcp.json
{
"mcpServers": {
"hello": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "MCP_TRANSPORT=stdio",
"mitchallen/mcp-hello-server:0.3.0"
]
}
}
}
Detailed breakdown
-iis mandatory. It keeps the container’s stdin open so Claude Code can speak MCP to the process. Without-i, the server starts and immediately sees end-of-input.--rmdeletes the container when the session ends, so repeated launches do not pile up stopped containers.-e MCP_TRANSPORT=stdioselects stdio inside the container, the same switch the uv path sets. The image tag0.3.0pins the version, mirroring the git tag.- Tradeoff. Docker needs a running daemon but no Python; the uv path needs uv but no daemon. Pick one and state the requirement in your README so consumers install the right prerequisite. This tutorial’s validated project uses the uv path from Step 3.
Step 9: Install from the local marketplace
To exercise the full install path, 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 hello-mcp@hello-mcp-market
Inspect what got installed:
claude plugin details hello-mcp
The inventory lists one MCP server and nothing else:
hello-mcp 1.0.0
...
Component inventory
Skills (0)
Agents (0)
Hooks (0)
MCP servers (1) hello (tool schemas resolved at runtime; not counted)
LSP servers (0)
Projected token cost
Always-on: ~0 tok added to every session
MCP servers (1) hello confirms the plugin registered the external server, and
~0 tok always-on is the usual MCP payoff: tool schemas are fetched from the
running server when needed, not injected into every prompt. In an interactive
session, /mcp lists the hello server and its two tools, and asking Claude to
“greet me in French” calls greet. The first tool call is slower while uvx builds
the server; it is cached afterward.
Remove the local test install before publishing for real:
claude plugin marketplace remove hello-mcp-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 "hello-mcp plugin installing mcp-hello-server over stdio v1.0.0"
git branch -M main
git remote add origin [email protected]:your-org/hello-mcp-plugin.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/hello-mcp-plugin /plugin install hello-mcp@hello-mcp-marketTheir only prerequisites are uv and git (or Docker, if you chose Step 8’s variant). Say which in your README.
Two repos, two owners. Your plugin repo (
hello-mcp-plugin) and the server repo (mcp-hello-server) are independent. You control the launch config and the pinned tag; the server’s maintainer controls the server. When they cut a new release, you decide whether to adopt it.Releasing an update. To move to a newer server, change the
@v0.3.0tag in.mcp.json, bump the pluginversionin bothplugin.jsonandmarketplace.json, runmake test-serveragainst the new tag, and push. Users pull it with/plugin marketplace update hello-mcp-market.Trust boundary. This plugin runs third-party code on the consumer’s machine. Pinning to a specific tag (not a moving branch) means you review a fixed version before recommending it, and consumers get exactly what you tested.
Recap
You distributed an external MCP server as a Claude Code plugin without owning its code:
- A
.mcp.jsonthat launchesmcp-hello-serverover stdio withuvx --from [email protected], pinned for reproducibility. - A plugin manifest and marketplace catalog that version and publish the
config behind
/plugin install hello-mcp@hello-mcp-market. - A headless smoke test that runs the exact launch command and asserts the
tools’ output, wired into
make test-server. - A Docker alternative for teams that prefer a container to a Python toolchain.
The plugin carries no server source — just the knowledge of how to start one. That
is the pattern for adopting any external MCP server: pin a version, wrap the launch
command in .mcp.json, and let the marketplace hand it to your team.
Troubleshooting
uvxcannot resolve the server. Confirm the git URL and that the pinned tag exists upstream. A plainuvx mcp-hello-server(no--from) fails because the package is not on PyPI; the--from git+...form is required here. Reproduce outside Claude withmake test-server.- Server missing after install. Run
/reload-plugins, then/mcp. 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. For the Docker variant, confirm the daemon is running and the-iflag is present. - First tool call is slow. uvx builds the server on first launch and caches it;
Docker pulls the image once. Warm the cache with
make test-server(uv) ordocker pull mitchallen/mcp-hello-server:0.3.0(Docker) after install. - A new server release changed a tool. Because you pinned
v0.3.0, upstream changes do not reach your users until you bump the tag. Test the new tag withmake test-serverbefore you push.
Next improvements
- Add a slash command in
commands/that calls the server’s tools, so the plugin ships both the server and a workflow around it. See the command, agent, and hook article. - Switch to PyPI if the server gets published there: replace the
--from git+...args with a baremcp-hello-server(optionally[email protected]to pin), a one-line change that drops the git dependency. - List several servers in one
.mcp.jsonby adding more keys undermcpServers, turning the plugin into a curated bundle of external tools for your team. - Pass per-install configuration through the
envblock (an API key, a base URL) so one plugin adapts to each consumer’s environment.