Two earlier articles bookend this one. Package and Distribute a FastMCP Server
as a uvx Tool gets you to uvx macmcp in your own terminal, and Register a
FastMCP Server with Claude Desktop and Claude Code wires a server into clients,
but launches it from a local path or git+https. This closes the loop: publish
the package to PyPI once, and from then on anyone — and any MCP client — runs
it with uvx <name>, no clone, no virtualenv, no path.
The distribution mechanism is uv’s uvx: given a
package name, it fetches the package into a cached, throwaway environment and
runs its console script. Because an MCP stdio server is a console script, a
client config that says uvx mac-mcp-greeter gets a fresh, isolated install with
zero setup on the user’s side.
On the publish step. Publishing to PyPI is irreversible (you cannot reuse a version number) and pushes your code to a public index, so this article treats
uv publishas a deliberate manual step. Everything before it — building the wheel, running it throughuvx, and registering it with a client — is verified here against the local wheel, which is the exact mechanismuvx <name>uses once the package is on PyPI.
What you will build
- A packaged FastMCP server whose console script is
mac-mcp-greeter. - A build +
uvxverification loop that proves the wheel before you publish. - The
uv publishflow (PyPI and TestPyPI), token-guarded. - Registration of the
uvx-run server with Claude Code and Claude Desktop.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv;uvxships with it. Verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - A PyPI account (and a TestPyPI account for rehearsals) plus an API token, only for the actual publish in Step 6.
- Familiarity with FastMCP servers (see Build an MCP Server with FastMCP).
Step 1: Scaffold a packaged project
A publishable project needs the src/ package layout and a build backend, which
uv init --package sets up. Create the .gitignore first — it must exclude
dist/ and any publish tokens.
Create the files
mkdir -p publish-mcp-server-pypi-uvx-macos
cd publish-mcp-server-pypi-uvx-macos
touch .gitignore
uv init --package --name mac-mcp-greeter --no-workspace
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
# Build artifacts
dist/
build/
*.egg-info/
# Secrets — never commit publish tokens
.pypirc
.env
# OS / editor noise
.DS_Store
Detailed breakdown
uv init --package(note--package, not a plainuv init) scaffolds a distributable project: asrc/mac_mcp_greeter/package, a[project.scripts]entry, and theuv_buildbackend.dist/is ignored becauseuv buildregenerates it; committing built wheels is an anti-pattern..pypircand.envare ignored so a PyPI token can never slip into a commit.
Step 2: Configure the package
Add FastMCP and the test tools, then confirm pyproject.toml.
Create the files
uv add fastmcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "mac-mcp-greeter"
version = "0.1.0"
description = "A FastMCP server published to PyPI and run with uvx"
readme = "README.md"
authors = [
{ name = "Your Name", email = "[email protected]" }
]
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.4",
]
[project.scripts]
mac-mcp-greeter = "mac_mcp_greeter:main"
[build-system]
requires = ["uv_build>=0.11.26,<0.12.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
name = "mac-mcp-greeter"must be globally unique on PyPI. Pick your own; check availability athttps://pypi.org/project/<name>/. This is the name users type afteruvx.[project.scripts] mac-mcp-greeter = "mac_mcp_greeter:main"is the whole point: it declares a console script namedmac-mcp-greeterthat calls the package’smain. That script is whatuvx mac-mcp-greeterruns. The script name and the package name match here, which keeps theuvxcommand short (more on the mismatch case in Step 7).dependencieslistsfastmcp, so installing the package pulls FastMCP in automatically — the user needs nothing preinstalled.build-backend = "uv_build"is whatuv build,uvx, andpipuse to produce and install the wheel.
Step 3: Write the server
Create the file
touch src/mac_mcp_greeter/server.py
Add the code: src/mac_mcp_greeter/server.py
"""A small, installable FastMCP server.
Nothing here is unusual for a FastMCP server — the point of this project is the
*distribution*: it ships as a package whose console script (`mac-mcp-greeter`)
is exactly what `uvx mac-mcp-greeter` runs once it is on PyPI. Transport is
chosen at runtime so the same entry point works as a stdio subprocess (what a
client launches) or over HTTP.
"""
import os
from fastmcp import FastMCP
mcp = FastMCP("mac-mcp-greeter")
@mcp.tool
def greet(name: str) -> str:
"""Return a greeting for name."""
return f"Hello, {name}! Served by mac-mcp-greeter."
@mcp.tool
def word_count(text: str) -> int:
"""Count the words in text."""
return len(text.split())
def run() -> None:
"""Start the server on the transport named by MCP_TRANSPORT (default stdio)."""
if os.environ.get("MCP_TRANSPORT", "stdio").lower() == "http":
mcp.run(
transport="http",
host=os.environ.get("MCP_HOST", "127.0.0.1"),
port=int(os.environ.get("MCP_PORT", "8000")),
)
else:
mcp.run() # stdio: launched as a subprocess by a client
Detailed breakdown
- Two ordinary tools plus a
run()that picks the transport.runis separate from the package entry point (next step) so the tests can importmcpwithout starting a server. - stdio is the default, which is what a client launches over
uvx. HTTP is available for a hosted deployment.
Step 4: The package entry point
uv init --package created a placeholder main. Replace it with one that starts
the server, plus a --check flag that verifies an install without serving.
Create/replace the file
# already exists from `uv init --package`; replace its contents
touch src/mac_mcp_greeter/__init__.py
Add the code: src/mac_mcp_greeter/__init__.py
"""Package entry point.
`main` is the target of the `mac-mcp-greeter` console script declared in
pyproject.toml, so `uvx mac-mcp-greeter` (or the installed command) calls it. A
`--check` flag verifies the package resolves, imports, and registers its tools
without starting the server — handy in CI and in the smoke test.
"""
import asyncio
import sys
from .server import mcp, run
def _check() -> None:
"""Print server identity and tools, then exit — verifies an install."""
tools = asyncio.run(mcp.list_tools())
names = sorted(t.name for t in tools)
print(f"mac-mcp-greeter OK — tools: {names}")
def main() -> None:
if "--check" in sys.argv[1:]:
_check()
return
run()
Detailed breakdown
mainis the console-script target. Whenuvx mac-mcp-greeterruns, this is the function it calls. With no arguments it starts the stdio server; with--checkit lists the tools and exits._checkis the fast “did the install work?” probe. Running it throughuvx(Step 5) exercises the full path — download, build, install, run the script — without needing a client.
Step 5: Build and verify with uvx before publishing
First a quick in-memory test, then build the wheel and run it exactly as a user would.
Create the files
mkdir -p tests
touch tests/__init__.py tests/test_server.py pytest.ini
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
Add the code: tests/test_server.py
"""Drive the server in-memory so the package is proven before it is published."""
from fastmcp import Client
from mac_mcp_greeter.server import mcp
async def test_tools_are_registered():
async with Client(mcp) as client:
names = sorted(t.name for t in await client.list_tools())
assert names == ["greet", "word_count"]
async def test_greet():
async with Client(mcp) as client:
result = await client.call_tool("greet", {"name": "Ada"})
assert result.data == "Hello, Ada! Served by mac-mcp-greeter."
async def test_word_count():
async with Client(mcp) as client:
result = await client.call_tool("word_count", {"text": "one two three"})
assert result.data == 3
Run the tests, build, and verify the wheel through uvx:
uv run pytest -q
uv build
uvx --from dist/mac_mcp_greeter-0.1.0-py3-none-any.whl mac-mcp-greeter --check
3 passed
Successfully built dist/mac_mcp_greeter-0.1.0.tar.gz
Successfully built dist/mac_mcp_greeter-0.1.0-py3-none-any.whl
mac-mcp-greeter OK — tools: ['greet', 'word_count']
Detailed breakdown
uv buildproduces both a wheel (.whl) and a source distribution (.tar.gz) indist/; PyPI wants both.uvx --from dist/…whl mac-mcp-greeterinstalls that wheel into a throwaway environment and runs the console script — the same thinguvx mac-mcp-greeterdoes after publishing, minus the download. A green--checkhere means the packaging is correct before you make an irreversible publish.
Step 6: Publish to PyPI
Publishing needs an API token. Create one at https://pypi.org/manage/account/
(scope it to this project after the first upload). Rehearse against TestPyPI
first — it is a throwaway index with separate accounts and tokens.
# Rehearse on TestPyPI (separate account + token from pypi.org):
UV_PUBLISH_TOKEN=pypi-<testpypi-token> \
uv publish --publish-url https://test.pypi.org/legacy/
# Then the real thing:
UV_PUBLISH_TOKEN=pypi-<pypi-token> uv publish
Detailed breakdown
uv publishuploads everything indist/.UV_PUBLISH_TOKENis the token; keep it in your environment or a secret store, never in a committed file (the.gitignorefrom Step 1 already blocks.pypircand.env).- A version can be uploaded only once. To ship a fix you must bump
versioninpyproject.tomland rebuild — PyPI rejects a re-upload of0.1.0. - In CI, prefer trusted publishing (OIDC) over a long-lived token: configure
the project’s publisher on PyPI and
uv publishauthenticates from the GitHub Actions run with no stored secret.
Verify the published package the same way, now without --from:
uvx mac-mcp-greeter --check
# mac-mcp-greeter OK — tools: ['greet', 'word_count']
Step 7: Run it with uvx
Once published, running the server is a single command:
uvx mac-mcp-greeter # start the stdio server
uvx mac-mcp-greeter --check # verify it
Three things worth knowing:
- Pin a version for reproducibility:
uvx [email protected], oruvx --from 'mac-mcp-greeter==0.1.0' mac-mcp-greeter. - Refresh after publishing a new version.
uvxcaches environments, so a freshly published release may not appear until you runuvx --refresh mac-mcp-greeter. - When the command name differs from the package name, name both:
uvx --from <package> <command>. They match here, so the short form works.
Step 8: Register the uvx server with a client
This is the payoff: a client launches uvx <name> and gets the server with no
local checkout. Before publishing, prove it with the local wheel — the same
command shape, with --from pointing at dist/.
Claude Code. From the project directory:
claude mcp add mac-mcp-greeter -- \
"$(command -v uvx)" --from "$(pwd)/dist/mac_mcp_greeter-0.1.0-py3-none-any.whl" mac-mcp-greeter
claude mcp get mac-mcp-greeter
mac-mcp-greeter:
Scope: Local config (private to you in this project)
Status: ✔ Connected
Type: stdio
Command: /opt/homebrew/bin/uvx
Args: --from /Users/you/publish-mcp-server-pypi-uvx-macos/dist/mac_mcp_greeter-0.1.0-py3-none-any.whl mac-mcp-greeter
✔ Connected means Claude Code ran uvx, which built the environment, launched
the stdio server, and completed the MCP handshake. After publishing, the
command is just the package name — no --from, no path:
claude mcp add mac-mcp-greeter -- uvx mac-mcp-greeter
Claude Desktop. It has no CLI and no shell PATH, so use the absolute path
to uvx (find it with command -v uvx) in
~/Library/Application Support/Claude/claude_desktop_config.json:
Create the file
touch ~/Library/Application\ Support/Claude/claude_desktop_config.json
Add the code: ~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"mac-mcp-greeter": {
"command": "/opt/homebrew/bin/uvx",
"args": ["mac-mcp-greeter"]
}
}
}
Detailed breakdown
- The published form (
uvx mac-mcp-greeter) is what users copy. It carries no machine-specific path, so the same config block works on any Mac — the reason to publish rather than ship a local path or agit+httpsURL. - Absolute
uvxpath for Claude Desktop. As covered in the registration article, the GUI app does not read your shell profile, so a bareuvxfails. Restart Claude Desktop fully (⌘Q) after editing the config. - First launch is slower.
uvxbuilds the environment on first run, which can exceed a client’s default startup timeout. In Claude Code, raise it withMCP_TIMEOUT=60000 claude; once cached, later launches are fast.
Step 9: The Makefile
Wrap the lifecycle so plain make prints help, with token-guarded publish
targets.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
PKG := mac-mcp-greeter
WHEEL = $(shell ls dist/$(subst -,_,$(PKG))-*.whl 2>/dev/null | head -1)
.PHONY: help install test build check publish publish-test \
register-code unregister-code clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
test: ## Run the test suite
uv run pytest -q
build: ## Build the wheel and sdist into dist/
uv build
check: build ## Run the built wheel through uvx in an ephemeral env
uvx --from $(WHEEL) $(PKG) --check
publish-test: build ## Upload to TestPyPI (needs UV_PUBLISH_TOKEN)
@test -n "$(UV_PUBLISH_TOKEN)" || (echo "Set UV_PUBLISH_TOKEN (TestPyPI token) first" && exit 1)
uv publish --publish-url https://test.pypi.org/legacy/
publish: build ## Upload to PyPI (needs UV_PUBLISH_TOKEN) — do this deliberately
@test -n "$(UV_PUBLISH_TOKEN)" || (echo "Set UV_PUBLISH_TOKEN (PyPI token) first" && exit 1)
uv publish
register-code: build ## Register the built wheel with Claude Code via uvx (local scope)
claude mcp add $(PKG) -- $(shell command -v uvx) --from $(abspath $(WHEEL)) $(PKG)
unregister-code: ## Remove the server from Claude Code
-claude mcp remove $(PKG)
clean: ## Remove build artifacts and caches
rm -rf dist build *.egg-info .pytest_cache __pycache__ tests/__pycache__
Detailed breakdown
- Plain
makeprints the help screen listing every target. make checkbuilds and runs the wheel throughuvx— the pre-publish gate.publishandpublish-testboth guard onUV_PUBLISH_TOKEN, so a missing token fails fast instead of prompting mid-upload. Keep publishing a conscious act.register-codewires the local wheel into Claude Code for the pre-publish smoke; after publishing, registeruvx $(PKG)directly.
Troubleshooting
uv publishrejects the upload as a duplicate. That version already exists on PyPI. Bumpversioninpyproject.toml,uv build, and publish again.uvx mac-mcp-greeterruns an old version after you published a new one.uvxcached the environment. Runuvx --refresh mac-mcp-greeter.uvxerrors that it cannot find the command. The command name differs from the package name; useuvx --from <package> <command>.- A client shows
failed to connectright after adding the server. Firstuvxlaunch is building the environment and may exceed the startup timeout. Retry, or raiseMCP_TIMEOUT. Confirm the server runs standalone withuvx mac-mcp-greeter --check. - Claude Desktop never starts the server. Use the absolute path to
uvx(command -v uvx), not a bareuvx, and quit/reopen the app. name … already existsat first publish. The PyPI project name is taken. Choose a uniquenameinpyproject.toml.
Recap
- A publishable FastMCP server is a
uv init --packageproject whose[project.scripts]entry is the commanduvxruns. uv buildmakes the wheel;uvx --from dist/…whl <name>verifies it before you publish;uv publish(token-guarded, TestPyPI first) puts it on PyPI.- Once published,
uvx <name>runs the server anywhere with no setup, and a client config that saysuvx <name>gets an isolated install for free. - Registration with Claude Code (
claude mcp add … -- uvx <name>) and Claude Desktop (absoluteuvxpath) turns the published package into a one-line install for your users.
Next improvements
- Automate releases with GitHub Actions: build and
uv publishon a tag via trusted publishing (OIDC), so a version tag ships the package with no stored token. - Add a CHANGELOG and semantic versioning so
uvx <name>@<version>pins are meaningful. - Ship a Docker image alongside the wheel for clients that launch servers via
docker runinstead ofuvx. - Publish the server to an MCP registry/directory so users discover it without knowing the package name.