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 publish as a deliberate manual step. Everything before it — building the wheel, running it through uvx, and registering it with a client — is verified here against the local wheel, which is the exact mechanism uvx <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 + uvx verification loop that proves the wheel before you publish.
  • The uv publish flow (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; uvx ships with it. Verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • 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 plain uv init) scaffolds a distributable project: a src/mac_mcp_greeter/ package, a [project.scripts] entry, and the uv_build backend.
  • dist/ is ignored because uv build regenerates it; committing built wheels is an anti-pattern. .pypirc and .env are 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 at https://pypi.org/project/<name>/. This is the name users type after uvx.
  • [project.scripts] mac-mcp-greeter = "mac_mcp_greeter:main" is the whole point: it declares a console script named mac-mcp-greeter that calls the package’s main. That script is what uvx mac-mcp-greeter runs. The script name and the package name match here, which keeps the uvx command short (more on the mismatch case in Step 7).
  • dependencies lists fastmcp, so installing the package pulls FastMCP in automatically — the user needs nothing preinstalled.
  • build-backend = "uv_build" is what uv build, uvx, and pip use 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. run is separate from the package entry point (next step) so the tests can import mcp without 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

  • main is the console-script target. When uvx mac-mcp-greeter runs, this is the function it calls. With no arguments it starts the stdio server; with --check it lists the tools and exits.
  • _check is the fast “did the install work?” probe. Running it through uvx (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 build produces both a wheel (.whl) and a source distribution (.tar.gz) in dist/; PyPI wants both.
  • uvx --from dist/…whl mac-mcp-greeter installs that wheel into a throwaway environment and runs the console script — the same thing uvx mac-mcp-greeter does after publishing, minus the download. A green --check here 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 publish uploads everything in dist/. UV_PUBLISH_TOKEN is the token; keep it in your environment or a secret store, never in a committed file (the .gitignore from Step 1 already blocks .pypirc and .env).
  • A version can be uploaded only once. To ship a fix you must bump version in pyproject.toml and rebuild — PyPI rejects a re-upload of 0.1.0.
  • In CI, prefer trusted publishing (OIDC) over a long-lived token: configure the project’s publisher on PyPI and uv publish authenticates 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], or uvx --from 'mac-mcp-greeter==0.1.0' mac-mcp-greeter.
  • Refresh after publishing a new version. uvx caches environments, so a freshly published release may not appear until you run uvx --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 a git+https URL.
  • Absolute uvx path for Claude Desktop. As covered in the registration article, the GUI app does not read your shell profile, so a bare uvx fails. Restart Claude Desktop fully (⌘Q) after editing the config.
  • First launch is slower. uvx builds the environment on first run, which can exceed a client’s default startup timeout. In Claude Code, raise it with MCP_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 make prints the help screen listing every target.
  • make check builds and runs the wheel through uvx — the pre-publish gate.
  • publish and publish-test both guard on UV_PUBLISH_TOKEN, so a missing token fails fast instead of prompting mid-upload. Keep publishing a conscious act.
  • register-code wires the local wheel into Claude Code for the pre-publish smoke; after publishing, register uvx $(PKG) directly.

Troubleshooting

  • uv publish rejects the upload as a duplicate. That version already exists on PyPI. Bump version in pyproject.toml, uv build, and publish again.
  • uvx mac-mcp-greeter runs an old version after you published a new one. uvx cached the environment. Run uvx --refresh mac-mcp-greeter.
  • uvx errors that it cannot find the command. The command name differs from the package name; use uvx --from <package> <command>.
  • A client shows failed to connect right after adding the server. First uvx launch is building the environment and may exceed the startup timeout. Retry, or raise MCP_TIMEOUT. Confirm the server runs standalone with uvx mac-mcp-greeter --check.
  • Claude Desktop never starts the server. Use the absolute path to uvx (command -v uvx), not a bare uvx, and quit/reopen the app.
  • name … already exists at first publish. The PyPI project name is taken. Choose a unique name in pyproject.toml.

Recap

  • A publishable FastMCP server is a uv init --package project whose [project.scripts] entry is the command uvx runs.
  • uv build makes 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 says uvx <name> gets an isolated install for free.
  • Registration with Claude Code (claude mcp add … -- uvx <name>) and Claude Desktop (absolute uvx path) turns the published package into a one-line install for your users.

Next improvements

  • Automate releases with GitHub Actions: build and uv publish on 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 run instead of uvx.
  • Publish the server to an MCP registry/directory so users discover it without knowing the package name.