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 a macmcp console-script entry point defined in pyproject.toml.
  • A --check flag that verifies an install without starting a blocking server — ideal for smoke-testing a freshly installed tool.
  • A pytest suite that runs the server in-memory.
  • A make control panel to test, build, run via uvx, install as a global uv tool, and (opt-in) publish to PyPI.

Prerequisites

  • macOS with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version. uvx and uv build ship with uv.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • 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 of uv build. They are regenerated on every build and must never be committed — uvx and PyPI consume freshly built artifacts, not checked-in ones.
  • .venv/ is uv’s dev environment; the published package does not depend on it.
  • .DS_Store is 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 named macmcp that calls the main function of the macmcp package. After install, that becomes an executable on PATH — and it is exactly what uvx macmcp runs.
  • [build-system] with uv_build tells any installer how to build the wheel. uv build, uvx, and pip all read this.
  • dependencies lists fastmcp, so anyone who installs the package automatically gets FastMCP. [project.name] must be unique on PyPI if you intend to publish — pick something like yourname-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 in pyproject.toml. Everything a user triggers with macmcp/uvx macmcp flows through here.
  • --check calls mcp.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. main as found on the package macmcp. Re-exporting from macmcp.server import main here makes that name resolve to your server’s main().
  • Exporting mcp too lets tests do from macmcp.server import mcp (or from 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 mcp works because the package is installed in editable mode inside uv’s environment — uv run pytest sees src/macmcp as the importable macmcp package.
  • test_entry_point_is_importable fails loudly if you ever break the __init__.py re-export or rename main, which is what would silently make uvx macmcp stop 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

  • build runs uv build, producing dist/macmcp-0.1.0-py3-none-any.whl and a matching .tar.gz sdist. rm -rf dist first keeps stale versions from lingering.
  • uvx-run is the money target: it builds, then runs the wheel through uvx in a throwaway environment — exactly what an end user experiences, proving the package is self-contained.
  • tool-install installs the macmcp executable globally via uv tool, so macmcp works from any directory. --force makes re-installs idempotent.
  • publish is guarded. It refuses to run unless UV_PUBLISH_TOKEN is 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.

  1. Pick a unique name in pyproject.toml (PyPI names are global).
  2. Create an API token at pypi.org.
  3. 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 publish reads it from UV_PUBLISH_TOKEN; keep it in your shell or a secrets manager, never in the repo. Test uploads against TestPyPI first with uv publish --publish-url https://test.pypi.org/legacy/.

Troubleshooting

  • uvx macmcp says “command not found: macmcp” inside the package. The [project.scripts] name or the macmcp:main target is wrong. Confirm pyproject.toml has macmcp = "macmcp:main" and that src/macmcp/__init__.py re-exports main. make test catches this via test_entry_point_is_importable.
  • uvx seems to hang. Without --check, macmcp starts the stdio server and waits on stdin — that is normal, not a hang. Use macmcp --check, or run the HTTP transport with MCP_TRANSPORT=http.
  • uv build fails with “Multiple top-level packages”. Keep a single package under src/ (here, src/macmcp/). The uv_build backend expects the src layout that uv init --package created.
  • PyPI rejects the upload: name already taken. PyPI names are global. Rename the project (e.g. yourname-macmcp) and rebuild.
  • uvx runs an old version after publishing. uvx caches; force a refresh with uvx --refresh macmcp.

Recap

You packaged a FastMCP server so it ships like a first-class CLI tool:

  • uv init --package gave you a src/ layout and a [project.scripts] console-script entry point (macmcp = "macmcp:main").
  • A --check flag verifies an install without blocking on stdio.
  • uv build produces a wheel; uvx and uv tool install run it with no clone and no manual virtualenv.
  • make publish (token-guarded) puts it on PyPI so uvx macmcp just works.

Next improvements

  • Add a GitHub Actions release workflow that runs uv build and uv publish on a tag, using PyPI Trusted Publishing (no long-lived token).
  • Add a --version flag 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.