An installable MCP server needs the same release discipline as any Python package: lint and test every change, build the artifact, and publish on a tagged release. This tutorial wires that pipeline with GitHub Actions and uvruff for linting and formatting, pytest for tests, uv build for the wheel/sdist, and PyPI trusted publishing (OIDC, no stored token) on a version tag. It is the CI/CD companion to Publish a FastMCP Server to PyPI and Run It Anywhere with uvx.

The whole pipeline runs locally with one make target before it ever reaches GitHub, so you develop against the exact steps CI runs.

What you will build

  • An installable FastMCP package (mcp-ci-demo) with a --check smoke command.
  • ruff, pytest, and uv build wired through a Makefile whose ci target mirrors the pipeline.
  • A GitHub Actions workflow with three jobs: a test matrix (Python 3.12 and 3.13), a build job that smoke-tests the wheel, and a tag-gated publish job using trusted publishing.
  • Dependency caching, least-privilege permissions, and run cancellation.

Prerequisites

  • uv 0.11+ (brew install uv). Validated on uv 0.11.26, ruff 0.15.22, Python 3.12.9, FastMCP 3.4.4.
  • A GitHub repository to hold the project (the workflow runs there).
  • For publishing: a PyPI account and a configured trusted publisher (Step 8). You do not need either to build and validate the pipeline locally.
  • macOS commands are shown; the pipeline runs on ubuntu-latest.

Step 1: Scaffold the package

Use uv’s packaged layout so the project has a src/ module and a console script, the same shape you publish to PyPI.

Create the files

mkdir -p ci-cd-mcp-server-macos
cd ci-cd-mcp-server-macos
touch .gitignore
uv init --package --name mcp-ci-demo .
uv add "fastmcp>=3.4.4"
uv add --dev "pytest>=9" "pytest-asyncio>=1.4" "ruff>=0.15"

Add the code: .gitignore

.venv/
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
dist/
.DS_Store
*.log

Add the code: pyproject.toml

[project]
name = "mcp-ci-demo"
version = "0.1.0"
description = "A FastMCP server with a lint/test/build/publish GitHub Actions pipeline"
readme = "README.md"
authors = [
    { name = "Your Name", email = "you@example.com" }
]
requires-python = ">=3.12"
dependencies = [
    "fastmcp>=3.4.4",
]

[project.scripts]
mcp-ci-demo = "mcp_ci_demo:main"

[build-system]
requires = ["uv_build>=0.11.26,<0.12.0"]
build-backend = "uv_build"

[dependency-groups]
dev = [
    "pytest>=9",
    "pytest-asyncio>=1.4",
    "ruff>=0.15",
]

[tool.ruff]
target-version = "py312"

[tool.ruff.lint]
# A small, opinionated rule set: pyflakes (F), pycodestyle (E/W), isort (I),
# flake8-bugbear (B), and pyupgrade (UP). Enough to catch real problems in CI
# without bikeshedding.
select = ["E", "W", "F", "I", "B", "UP"]

Detailed breakdown

  • uv init --package creates the src/mcp_ci_demo/ layout, the [project.scripts] console script (mcp-ci-demo), and the uv_build backend — everything needed for uv build to produce a wheel.
  • dist/ is in .gitignore. Build artifacts are regenerated by CI and by uv build; they never belong in git.
  • ruff is a dev dependency, pinned in the lockfile, so CI runs the same linter version you do — no drift between your machine and the runner. The [tool.ruff.lint] select list is committed config, so ruff check behaves identically everywhere.

Step 2: Write the server

Create the file

touch src/mcp_ci_demo/server.py

Add the code: src/mcp_ci_demo/server.py

"""A small, installable FastMCP server.

Nothing here is unusual for a FastMCP server — this project is about the
*pipeline* that lints, tests, builds, and publishes it. Transport is chosen at
runtime so the same entry point works as a stdio subprocess or over HTTP.
"""

import os

from fastmcp import FastMCP

mcp = FastMCP("mcp-ci-demo")


@mcp.tool
def greet(name: str) -> str:
    """Return a greeting for name."""
    return f"Hello, {name}! Served by mcp-ci-demo."


@mcp.tool
def add(a: int, b: int) -> int:
    """Add two integers and return the sum."""
    return a + b


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 tools (greet, add) are enough to have something to test. The focus is the pipeline, not the server surface.
  • run picks the transport from MCP_TRANSPORT. The default stdio path is what a client launches; the HTTP branch is there so the same published command serves remotely if needed. CI never starts the server — it uses the --check path from Step 3.

Step 3: The package entry point

The console script needs a main. Add a --check flag that imports the package and lists its tools without starting the server — a fast way for CI to prove the built artifact is importable and wired correctly.

Create the file

uv init --package created src/mcp_ci_demo/__init__.py. Replace its contents.

Add the code: src/mcp_ci_demo/__init__.py

"""Package entry point.

`main` is the target of the `mcp-ci-demo` console script declared in
pyproject.toml, so the installed command (and `uvx mcp-ci-demo` once published)
calls it. A `--check` flag verifies the package resolves, imports, and registers
its tools without starting the server — the CI pipeline runs it as a fast smoke
test of the built artifact.
"""

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"mcp-ci-demo OK — tools: {names}")


def main() -> None:
    if "--check" in sys.argv[1:]:
        _check()
        return
    run()

Detailed breakdown

  • main is the [project.scripts] target. After a build, mcp-ci-demo (installed or via uvx) calls it. With no flag it starts the server; with --check it runs _check and exits 0.
  • _check is the artifact smoke test. It asserts nothing, but it imports the package, builds the server, and lists tools — so a broken import or a bad entry point fails immediately. The build job runs it against the freshly built wheel, proving the published form works, not just the source tree.

Step 4: Tests

Create the files

touch pytest.ini tests/test_server.py

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 built."""

from fastmcp import Client

from mcp_ci_demo.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 == ["add", "greet"]


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 mcp-ci-demo."


async def test_add():
    async with Client(mcp) as client:
        result = await client.call_tool("add", {"a": 40, "b": 2})
    assert result.data == 42

Detailed breakdown

  • The tests import the installed package (mcp_ci_demo.server), not a loose file. Because the package is installed into the environment by uv sync, the src/ layout resolves without a pythonpath hack.
  • Client(mcp) drives the server in-memory, so the suite is fast and deterministic — no subprocess, no network. test_add pins 40 + 2 = 42.

Step 5: The Makefile

Wrap every pipeline step, and add a ci target that runs them in the same order GitHub will.

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

.PHONY: help sync lint format test build check ci clean

help: ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*## "}; {printf "  %-8s %s\n", $$1, $$2}'

sync: ## Install dependencies from the lockfile
	uv sync --locked

lint: ## Lint with ruff
	uv run ruff check .

format: ## Check formatting with ruff
	uv run ruff format --check .

test: ## Run the test suite
	uv run pytest -q

build: ## Build the sdist and wheel into dist/
	uv build

check: build ## Smoke-test the built wheel with uvx
	uvx --from dist/*.whl mcp-ci-demo --check

ci: lint format test build check ## Run the whole pipeline locally (mirrors CI)
	@echo "local CI passed"

clean: ## Remove build artifacts and caches
	rm -rf dist .pytest_cache .ruff_cache __pycache__ src/*/__pycache__ tests/__pycache__

Detailed breakdown

  • ci chains lint format test build check, the exact steps the workflow runs. Running make ci locally reproduces a CI run before you push — the fastest way to avoid a red build.
  • check depends on build and installs the freshly built wheel with uvx --from dist/*.whl, so it exercises the published artifact, not the source tree.
  • .DEFAULT_GOAL := help prints the target list on bare make. Recipes are tab-indented.

Step 6: Run the pipeline locally

Before touching GitHub, run the whole thing:

make ci
uv run ruff check .
All checks passed!
uv run ruff format --check .
3 files already formatted
uv run pytest -q
...                                                                      [100%]
3 passed in 0.28s
uv build
Building source distribution (uv build backend)...
Building wheel from source distribution (uv build backend)...
Successfully built dist/mcp_ci_demo-0.1.0.tar.gz
Successfully built dist/mcp_ci_demo-0.1.0-py3-none-any.whl
uvx --from dist/*.whl mcp-ci-demo --check
Installed 67 packages in 35ms
mcp-ci-demo OK — tools: ['add', 'greet']
local CI passed

Green locally means the same steps will pass on the runner. That is the point of mirroring the pipeline in a make target: CI holds no surprises.

Step 7: The GitHub Actions workflow

This is the deliverable. Three jobs: test (a version matrix), build (which depends on test and smoke-tests the wheel), and publish (which depends on build and runs only on a version tag).

Create the file

mkdir -p .github/workflows
touch .github/workflows/ci.yml

Add the code: .github/workflows/ci.yml

name: CI

# Run on every push to main and every PR, plus on version tags (which also
# publish). Tag pushes match `v*`, e.g. `v0.1.0`.
on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:
    branches: [main]

# Least privilege by default; the publish job widens this for OIDC below.
permissions:
  contents: read

# Cancel superseded runs on the same ref to save minutes.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    name: Lint & test (py${{ matrix.python-version }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v6
        with:
          enable-cache: true # cache the uv download cache across runs

      - name: Install dependencies
        run: uv sync --locked --python ${{ matrix.python-version }}

      - name: Lint
        run: uv run ruff check .

      - name: Format check
        run: uv run ruff format --check .

      - name: Test
        run: uv run pytest -q

  build:
    name: Build & smoke-test the wheel
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v6
        with:
          enable-cache: true

      - name: Build sdist and wheel
        run: uv build

      - name: Smoke-test the built wheel
        run: uvx --from dist/*.whl mcp-ci-demo --check

      - name: Upload the distribution
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  publish:
    name: Publish to PyPI
    runs-on: ubuntu-latest
    needs: build
    # Only on a version tag, never on a branch push or PR.
    if: startsWith(github.ref, 'refs/tags/v')
    environment: pypi
    permissions:
      id-token: write # mint a short-lived PyPI token via OIDC (trusted publishing)
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v6

      - name: Build sdist and wheel
        run: uv build

      - name: Publish
        run: uv publish --trusted-publishing always

Detailed breakdown

  • Triggers: the workflow runs on pushes to main, on pull requests, and on tags matching v*. A tag push runs test and build and then publish, because a tag both validates and releases.
  • permissions: contents: read at the top is least privilege: the whole workflow can only read the repo. The publish job re-declares id-token: write, the only extra scope it needs, for OIDC.
  • concurrency cancels superseded runs on the same ref, so pushing twice in a row does not burn a full run on the stale commit.
  • astral-sh/setup-uv with enable-cache: true installs uv and caches its download cache keyed on the lockfile, so dependency installs are fast on repeat runs. uv sync --locked installs the exact pinned versions and fails if the lockfile is out of date — reproducible builds, not “latest that resolves.”
  • The test matrix runs Python 3.12 and 3.13 with fail-fast: false, so one version failing still reports the other. uv sync --python ${{ ... }} selects the interpreter per matrix leg.
  • build needs test — it never runs unless every matrix leg is green — then builds, smoke-tests the wheel with uvx --from dist/*.whl mcp-ci-demo --check, and uploads dist/ as an artifact you can download from the run.
  • publish needs build, is gated by if: startsWith(github.ref, 'refs/tags/v'), and targets the pypi environment. It uses uv publish --trusted-publishing always, which exchanges the job’s OIDC token for a short-lived PyPI token — there is no PYPI_TOKEN secret to store or leak.

Step 8: Publish on a tag with trusted publishing

The publish job needs a one-time setup on PyPI, then every release is just a tag.

  1. Register the project on PyPI (or use a pending publisher for a new name): on the project’s Publishing settings, add a GitHub trusted publisher with your repository, the workflow filename (ci.yml), and the environment name (pypi).

  2. Create the pypi environment in the GitHub repo settings (Settings → Environments). Optionally require a reviewer, so a human approves each release.

  3. Tag and push:

    git tag v0.1.0
    git push origin v0.1.0
    

The tag push triggers the workflow; test and build run, and on success publish exchanges its OIDC token for a PyPI token and uploads the wheel and sdist. Once published, anyone can run the server with uvx mcp-ci-demo.

Bump version in pyproject.toml for each release — PyPI refuses to overwrite an existing version. Because publishing is irreversible, treat it as a deliberate, authorized action: this tutorial validates everything up to the tag and leaves the actual upload to you.

Validate the workflow before pushing

A YAML typo or an invalid expression should not cost a round-trip to GitHub. Lint the workflow locally with actionlint:

brew install actionlint   # or download the single binary
actionlint .github/workflows/ci.yml

No output and exit code 0 means the workflow is well-formed — job dependencies resolve, expressions parse, and step keys are valid. This catches the mistakes that otherwise fail only after a push.

Troubleshooting

  • uv sync --locked fails in CI with “lockfile out of date.” You changed pyproject.toml without updating uv.lock. Run uv lock (or uv sync) locally and commit the lockfile.
  • The publish job is skipped. It runs only on a v* tag. A push to main runs test and build but not publish — that is by design.
  • uv publish fails with an OIDC/permity error. Confirm the job has id-token: write, the pypi environment exists, and the PyPI trusted publisher matches the repo, workflow filename, and environment exactly.
  • ruff format --check fails CI but ruff check passes. They are different: check is lint rules, format --check is formatting. Run uv run ruff format . to fix formatting, then commit.
  • A matrix leg fails only on 3.13. Some dependency lacks a 3.13 wheel or a behavior changed. Reproduce locally with uv run --python 3.13 pytest.

Recap

  • The pipeline is lint → format-check → test → build → smoke-test → publish, each step a uv/ruff/pytest command that also runs locally via make ci.
  • The workflow uses astral-sh/setup-uv with caching, a Python matrix, least-privilege permissions, and run cancellation — the pieces that keep CI fast, reproducible, and safe.
  • Publishing is tag-gated and uses trusted publishing (OIDC), so there is no long-lived PyPI token in the repo.
  • Validate the workflow with actionlint before pushing, and reproduce a CI run with make ci.

Next improvements

  • Add a lint/type job with mypy or ty alongside ruff for static typing.
  • Publish to TestPyPI first from a pre-release tag to rehearse the release end to end (see Publish a FastMCP Server to PyPI and Run It Anywhere with uvx).
  • Attach the artifacts to a GitHub Release with softprops/action-gh-release so each tag ships downloadable wheels.
  • Add a Docker image job that builds and pushes a container next to the wheel, for clients that run the server via docker run.