The friendliest way to ship a Python command-line tool today is to make it
runnable with uvx, uv’s
equivalent of pipx run. A user types uvx macmcp and uv fetches the
package, builds an ephemeral environment, and runs it, with no clone, no
virtualenv, and nothing left installed. That is a perfect distribution model
for a FastMCP server: you publish once, and anyone can
run your MCP server (or wire it into an MCP client config) with a single
command.
In this tutorial you will turn a FastMCP server into a proper Python package
with a console-script entry point, build a wheel, run it through uvx and
uv tool install, and prepare it for PyPI — all on macOS, driven by uv and
make.
What you will build
- A packaged FastMCP server (
src/macmcp/) with amacmcpconsole-script entry point defined inpyproject.toml. - A
--checkflag that verifies an install without starting a blocking server — ideal for smoke-testing a freshly installed tool. - A
pytestsuite that runs the server in-memory. - A
makecontrol panel to test, build, run viauvx, install as a globaluvtool, and (opt-in) publish to PyPI.
Prerequisites
- macOS with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version.uvxanduv buildship withuv. - Xcode Command Line Tools (
xcode-select --install) formake. - Basic familiarity with Python packaging concepts (a wheel, an entry point) is helpful but not required.
Everything runs through uv and make.
Step 1: Scaffold a packaged project
The key difference from a script project is uv init --package, which produces a
src/ layout, a build backend, and, crucially, a [project.scripts] entry
point. Create the .gitignore first so build artifacts never leak into git.
Create the files
mkdir -p uvx-fastmcp-server-macos
cd uvx-fastmcp-server-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
# uv
.uv/
# Build artifacts
dist/
build/
*.egg-info/
# Tooling caches
.pytest_cache/
.ruff_cache/
.mypy_cache/
# OS / editor noise
.DS_Store
*.log
Detailed breakdown
dist/,build/,*.egg-info/are the outputs ofuv build. They are regenerated on every build and must never be committed —uvxand PyPI consume freshly built artifacts, not checked-in ones..venv/isuv’s dev environment; the published package does not depend on it..DS_Storeis Finder’s per-directory metadata on macOS.
Step 2: Initialize the package with uv
uv init --package scaffolds a distributable package; then add FastMCP and the
test tools.
Create the files
uv init --package --name macmcp
uv add fastmcp
uv add --dev pytest pytest-asyncio
This creates src/macmcp/__init__.py, a uv.lock, and a pyproject.toml that
already contains a [project.scripts] entry and a build backend.
Add the code: pyproject.toml
Edit the placeholder description; the rest is generated. Your file should look
like this (versions may be newer):
[project]
name = "macmcp"
version = "0.1.0"
description = "A FastMCP server distributed as a uvx-runnable tool"
readme = "README.md"
authors = [
{ name = "Your Name", email = "[email protected]" }
]
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.3",
]
[project.scripts]
macmcp = "macmcp: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
[project.scripts] macmcp = "macmcp:main"is the heart of this article. It declares a console script namedmacmcpthat calls themainfunction of themacmcppackage. After install, that becomes an executable onPATH— and it is exactly whatuvx macmcpruns.[build-system]withuv_buildtells any installer how to build the wheel.uv build,uvx, andpipall read this.dependencieslistsfastmcp, so anyone who installs the package automatically gets FastMCP.[project.name]must be unique on PyPI if you intend to publish — pick something likeyourname-macmcp.
Step 3: Write the server and the entry point
Put the server in src/macmcp/server.py and expose main(). The entry point
supports a --check flag that lists the tools and exits — a fast way to verify
an install without launching a stdio loop that would block waiting for input.
Create the file
touch src/macmcp/server.py
Add the code: src/macmcp/server.py
"""macmcp — a FastMCP server packaged as a runnable tool.
The console-script entry point (`macmcp`) is defined in pyproject.toml as
`macmcp:main`, so once installed the server runs with a bare `macmcp` command —
including via `uvx macmcp` with no clone or virtualenv. Transport is chosen at
runtime; `macmcp --check` prints the tool list and exits (handy for verifying an
install without starting a blocking stdio loop).
"""
import asyncio
import os
import sys
from fastmcp import FastMCP
mcp = FastMCP("macmcp")
@mcp.tool
def word_count(text: str) -> int:
"""Count the whitespace-separated words in a block of text."""
return len(text.split())
@mcp.tool
def slugify(text: str) -> str:
"""Turn a title into a URL-safe, lowercase slug."""
cleaned = [c.lower() if c.isalnum() else "-" for c in text.strip()]
slug = "".join(cleaned)
while "--" in slug:
slug = slug.replace("--", "-")
return slug.strip("-")
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"macmcp OK — tools: {names}")
def main() -> None:
if "--check" in sys.argv[1:]:
_check()
return
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
if __name__ == "__main__":
main()
Detailed breakdown
main()is the entry point named inpyproject.toml. Everything a user triggers withmacmcp/uvx macmcpflows through here.--checkcallsmcp.list_tools()(FastMCP’s public async API) and prints the tool names. This gives you a non-blocking way to confirm a packaged install actually works — the stdio server would otherwise sit waiting on stdin and look like it hung.- The transport switch is the same pattern as the companion deploy article:
stdio by default (what MCP clients launch) and HTTP when
MCP_TRANSPORT=http, so the same installed tool can serve either way.
Step 4: Point the package entry at the server
uv init --package created a stub main() in src/macmcp/__init__.py. Re-export
the real one so the macmcp:main entry point resolves to your server.
Create/replace the file
: > src/macmcp/__init__.py # truncate the generated stub
Add the code: src/macmcp/__init__.py
"""macmcp package — a FastMCP server distributed as a uvx-runnable tool."""
from macmcp.server import main, mcp
__all__ = ["main", "mcp"]
Detailed breakdown
- The entry point is
macmcp:main— i.e.mainas found on the packagemacmcp. Re-exportingfrom macmcp.server import mainhere makes that name resolve to your server’smain(). - Exporting
mcptoo lets tests dofrom macmcp.server import mcp(orfrom macmcp import mcp) to drive the server in-memory.
Step 5: Test the server in-memory
Verify behavior with FastMCP’s in-memory client, and assert the entry point
imports — a cheap guard against a broken [project.scripts] wiring.
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
Add the code: tests/test_server.py
"""Exercise the packaged server in-memory, with no network or subprocess."""
import pytest
from fastmcp import Client
from macmcp.server import mcp
@pytest.mark.asyncio
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
@pytest.mark.asyncio
async def test_slugify():
async with Client(mcp) as client:
result = await client.call_tool("slugify", {"text": "Ship it with uvx!"})
assert result.data == "ship-it-with-uvx"
def test_entry_point_is_importable():
# The console script points at macmcp:main; make sure it resolves.
from macmcp import main
assert callable(main)
Detailed breakdown
from macmcp.server import mcpworks because the package is installed in editable mode insideuv’s environment —uv run pytestseessrc/macmcpas the importablemacmcppackage.test_entry_point_is_importablefails loudly if you ever break the__init__.pyre-export or renamemain, which is what would silently makeuvx macmcpstop working.
Run them:
uv run pytest -v
You should see three passing tests.
Step 6: The Makefile — build, run via uvx, install, publish
The Makefile wraps the whole distribution lifecycle. Plain make prints help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
WHEEL = $(wildcard dist/*.whl)
.PHONY: help install run serve check test build uvx-run tool-install tool-uninstall publish clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
run: ## Run the server over stdio (local dev)
uv run macmcp
serve: ## Run the HTTP server in the foreground (Ctrl-C to stop)
MCP_TRANSPORT=http uv run macmcp
check: ## Print the server's tools and exit (verify it works)
uv run macmcp --check
test: ## Run the test suite
uv run pytest -v
build: ## Build the wheel and sdist into dist/
rm -rf dist
uv build
uvx-run: build ## Run the built wheel through uvx in an ephemeral env
uvx --from $(WHEEL) macmcp --check
tool-install: build ## Install macmcp as a global uv tool (bare `macmcp`)
uv tool install --force $(WHEEL)
@echo "Installed. Try: macmcp --check"
tool-uninstall: ## Remove the global uv tool
uv tool uninstall macmcp
publish: build ## Publish to PyPI (requires UV_PUBLISH_TOKEN; not run by default)
@test -n "$$UV_PUBLISH_TOKEN" || (echo "Set UV_PUBLISH_TOKEN to publish. Aborting." && exit 1)
uv publish
clean: ## Remove build artifacts and caches
rm -rf dist build .pytest_cache __pycache__ src/macmcp/__pycache__ tests/__pycache__ *.egg-info
Detailed breakdown
buildrunsuv build, producingdist/macmcp-0.1.0-py3-none-any.whland a matching.tar.gzsdist.rm -rf distfirst keeps stale versions from lingering.uvx-runis the money target: it builds, then runs the wheel throughuvxin a throwaway environment — exactly what an end user experiences, proving the package is self-contained.tool-installinstalls themacmcpexecutable globally viauv tool, somacmcpworks from any directory.--forcemakes re-installs idempotent.publishis guarded. It refuses to run unlessUV_PUBLISH_TOKENis set, so you never publish by accident. Publishing is a deliberate, credentialed act.
Step 7: Build and run it like a user
Confirm the help screen and tests, then exercise the distribution paths.
make # help screen
make test # 3 passing tests
make check # macmcp OK — tools: ['slugify', 'word_count']
Run the built wheel through uvx — no install, ephemeral env:
make uvx-run
# ...
# macmcp OK — tools: ['slugify', 'word_count']
This is what your users get once the package is on PyPI: uvx macmcp fetches,
builds an isolated environment, and runs — leaving nothing behind.
Install it as a global command:
make tool-install
macmcp --check # works from any directory
make tool-uninstall
Serve over HTTP straight from the packaged tool (the deployment angle — a single command runs the network server):
MCP_TRANSPORT=http uvx --from ./dist/macmcp-0.1.0-py3-none-any.whl macmcp
# Uvicorn running on http://127.0.0.1:8000
Step 8: Publish to PyPI (optional)
To let people run uvx macmcp without your --from path, publish to PyPI.
- Pick a unique
nameinpyproject.toml(PyPI names are global). - Create an API token at pypi.org.
- Publish:
export UV_PUBLISH_TOKEN=pypi-... # your token
make publish
After it lands on PyPI, anyone can run:
uvx macmcp # run the server (stdio)
uvx macmcp --check # verify it
…and MCP clients can reference it as a stdio server with
command: "uvx", args: ["macmcp"].
Do not commit your token.
make publishreads it fromUV_PUBLISH_TOKEN; keep it in your shell or a secrets manager, never in the repo. Test uploads against TestPyPI first withuv publish --publish-url https://test.pypi.org/legacy/.
Troubleshooting
uvx macmcpsays “command not found: macmcp” inside the package. The[project.scripts]name or themacmcp:maintarget is wrong. Confirmpyproject.tomlhasmacmcp = "macmcp:main"and thatsrc/macmcp/__init__.pyre-exportsmain.make testcatches this viatest_entry_point_is_importable.uvxseems to hang. Without--check,macmcpstarts the stdio server and waits on stdin — that is normal, not a hang. Usemacmcp --check, or run the HTTP transport withMCP_TRANSPORT=http.uv buildfails with “Multiple top-level packages”. Keep a single package undersrc/(here,src/macmcp/). Theuv_buildbackend expects thesrclayout thatuv init --packagecreated.- PyPI rejects the upload: name already taken. PyPI names are global. Rename
the project (e.g.
yourname-macmcp) and rebuild. uvxruns an old version after publishing.uvxcaches; force a refresh withuvx --refresh macmcp.
Recap
You packaged a FastMCP server so it ships like a first-class CLI tool:
uv init --packagegave you asrc/layout and a[project.scripts]console-script entry point (macmcp = "macmcp:main").- A
--checkflag verifies an install without blocking on stdio. uv buildproduces a wheel;uvxanduv tool installrun it with no clone and no manual virtualenv.make publish(token-guarded) puts it on PyPI souvx macmcpjust works.
Next improvements
- Add a GitHub Actions release workflow that runs
uv buildanduv publishon a tag, using PyPI Trusted Publishing (no long-lived token). - Add a
--versionflag and read it from the installed package metadata. - Register the published tool with Claude Desktop / Claude Code as a stdio MCP
server (
command: "uvx",args: ["macmcp"]). - Ship multiple entry points (e.g. a separate admin CLI) from the same package.