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 with claude update if 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:

  • uvx from the GitHub release (used here). The server is a Python package with a console script. uvx --from git+<repo>@<tag> mcp-hello-server builds it in an isolated, cached environment and runs it. Needs only uv and git; pins to a tag for reproducibility.
  • uvx from PyPI. If the package were published to PyPI, uvx mcp-hello-server would be enough. At the time of writing it is not on PyPI (a plain uvx mcp-hello-server fails 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.0 speaks 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 holds marketplace.json; the one inside plugins/hello-mcp/ holds plugin.json. The .mcp.json sits 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.
  • .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/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-mcp is at 1.0.0; the server it installs is at v0.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.
  • description states 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.json by 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/mcp-hello-server@v0.3.0",
        "mcp-hello-server"
      ],
      "env": {
        "MCP_TRANSPORT": "stdio"
      }
    }
  }
}

Detailed breakdown

  • command: "uvx" is the launcher. uvx runs a Python console script in a throwaway, cached environment. The consumer only needs uv on their PATH; uvx handles the rest.
  • --from git+...@v0.3.0 tells uvx where to get the package and which version. git+https://.../mcp-hello-server@v0.3.0 fetches the tagged release and builds it; the trailing mcp-hello-server names the console script to run from it. Pinning to the v0.3.0 tag makes every install reproducible — without it, uvx would build whatever main happens 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 hello is the key under mcpServers. 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": "you@example.com"
  },
  "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

  • name is what users type. Installing is hello-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 version here (and in plugin.json) is the plugin’s version; the server’s v0.3.0 lives only in .mcp.json. The validator warns if the plugin’s two version fields disagree, but it has no opinion about the server tag.
  • owner is 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 git+...@v0.3.0 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/mcp-hello-server@v0.3.0",
                "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.json shape. FastMCP’s client accepts an mcpServers dict, so the test spawns the server through the identical uvx command 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_info is checked partially. Its uptime field changes every run, so the test asserts only the stable fields (status is OK, version is 0.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, so make pack never 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 := 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.
  • 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 on any warning.
  • test-server runs the Step 5 smoke test — the only check that exercises the real external server. Run it after changing the pinned tag.
  • pack zips the plugin’s contents, so the archive root holds .claude-plugin/plugin.json and .mcp.json — just two files, because there is no bundled server. It works with claude --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

  • -i is 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.
  • --rm deletes the container when the session ends, so repeated launches do not pile up stopped containers.
  • -e MCP_TRANSPORT=stdio selects stdio inside the container, the same switch the uv path sets. The image tag 0.3.0 pins 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 git@github.com: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-market
    

    Their 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.0 tag in .mcp.json, bump the plugin version in both plugin.json and marketplace.json, run make test-server against 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:

  1. A .mcp.json that launches mcp-hello-server over stdio with uvx --from git+...@v0.3.0, pinned for reproducibility.
  2. A plugin manifest and marketplace catalog that version and publish the config behind /plugin install hello-mcp@hello-mcp-market.
  3. A headless smoke test that runs the exact launch command and asserts the tools’ output, wired into make test-server.
  4. 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

  • uvx cannot resolve the server. Confirm the git URL and that the pinned tag exists upstream. A plain uvx mcp-hello-server (no --from) fails because the package is not on PyPI; the --from git+... form is required here. Reproduce outside Claude with make test-server.
  • Server missing after install. Run /reload-plugins, then /mcp. 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. For the Docker variant, confirm the daemon is running and the -i flag 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) or docker 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 with make test-server before 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 bare mcp-hello-server (optionally mcp-hello-server@0.3.0 to pin), a one-line change that drops the git dependency.
  • List several servers in one .mcp.json by adding more keys under mcpServers, turning the plugin into a curated bundle of external tools for your team.
  • Pass per-install configuration through the env block (an API key, a base URL) so one plugin adapts to each consumer’s environment.