The Model Context Protocol (MCP) lets AI clients (Claude Desktop, Claude Code, or your own agent) call tools, read resources, and load prompts from an external server. FastMCP is a Python framework that turns ordinary typed functions into a compliant MCP server with a couple of decorators.

Most FastMCP tutorials stop at “run it over stdio.” This one goes further: you build a small server, then deploy it as a persistent background service on your Mac using launchd, the same supervisor macOS uses for its own daemons. The result is an HTTP MCP server that starts at login, restarts if it crashes, and writes logs you can tail — managed entirely through make.

Everything here is written for macOS (Apple Silicon or Intel) and uses uv for Python and make as the task runner.

What you will build

  • A FastMCP server exposing two tools (word_count, slugify) and one resource (server://info).
  • A single server.py that runs over stdio for local development and over HTTP for deployment, selected by environment variables.
  • A pytest suite that exercises the server in-memory (no network, no subprocess).
  • A launchd user agent that supervises the HTTP server, plus make targets to deploy, check status, tail logs, and tear it down.

Prerequisites

  • macOS 13 (Ventura) or newer. The launchctl bootstrap/bootout subcommands used here are the modern replacements for launchctl load.
  • Homebrew — the standard macOS package manager. Install from brew.sh if you do not have it.
  • uv 0.5 or newer. Install with brew install uv, then verify with uv --version. uv manages the Python interpreter, so you do not need to install Python separately.
  • Xcode Command Line Tools (xcode-select --install) for make. Verify with make --version.
  • Basic familiarity with Python type hints. FastMCP uses them to build the JSON schema each tool advertises to clients.

We use uv (not pip) for every dependency and run step, and make for every task. All commands assume you are in the project directory unless noted.

Step 1: Scaffold the project and lock down hygiene

Create the project workspace and a .gitignore before any other files, so that virtual environments, caches, logs, and macOS’s .DS_Store files are never committed.

Create the files

mkdir -p deploy-fastmcp-server-macos
cd deploy-fastmcp-server-macos
touch .gitignore

Add the code: .gitignore

# Python
__pycache__/
*.py[cod]
.venv/

# uv
.uv/

# Tooling caches
.pytest_cache/
.ruff_cache/
.mypy_cache/

# Runtime / deploy artifacts
logs/
*.log
*.pid

# OS / editor noise
.DS_Store

Detailed breakdown

  • .venv/ is the virtual environment uv creates. It is large and fully reproducible from the lockfile, so it should never be committed.
  • logs/, *.log, *.pid cover the runtime artifacts the deployed service writes. When launchd supervises the server, its stdout and stderr land in logs/; ignoring them now prevents accidental commits later.
  • .DS_Store is the metadata file Finder drops into every directory you open on macOS. It has no business in a repo, and it will otherwise appear the first time you browse the project in Finder.
  • Creating this file first means the very next commands cannot leak build artifacts into a future commit.

Step 2: Initialize the project with uv

Turn the folder into a managed uv project, then add FastMCP as a runtime dependency and the test tools as dev dependencies.

Create the files

uv init --name macmcp --no-workspace
rm -f main.py hello.py          # remove the sample file uv scaffolds
uv add fastmcp
uv add --dev pytest pytest-asyncio

This generates pyproject.toml, a pinned uv.lock, a .python-version, and a .venv/. uv downloads and pins a compatible Python interpreter automatically — that is why you did not install Python by hand.

Add the code: pyproject.toml

After the commands above, pyproject.toml looks like this (versions may be newer):

[project]
name = "macmcp"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "fastmcp>=3.4.3",
]

[dependency-groups]
dev = [
    "pytest>=9.1.1",
    "pytest-asyncio>=1.4.0",
]

Detailed breakdown

  • --no-workspace keeps this a standalone project rather than joining a parent uv workspace, which matters if you scaffold inside a larger repo.
  • dependencies holds fastmcp — the only thing consumers of the server need at runtime.
  • dependency-groups.dev holds pytest and pytest-asyncio. FastMCP’s client API is async, so the async plugin lets pytest await coroutines directly. Dev dependencies are installed for development but excluded from a production install.

Step 3: Write the server

Write one server that can run two ways. During development you want stdio (what Claude Desktop launches directly). In production you want HTTP (a long-lived process you can supervise and connect to over a URL). Rather than maintain two entry points, read the transport from the environment.

Create the file

touch server.py

Add the code: server.py

"""A small FastMCP server, ready to run locally over stdio or deploy over HTTP.

Transport is chosen at runtime from environment variables so the exact same
file works for local development (stdio) and for a deployed HTTP service.
"""

import os

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("-")


@mcp.resource("server://info")
def server_info() -> dict:
    """Expose basic server metadata as a readable resource."""
    return {"name": "macmcp", "version": "0.1.0", "transport": _transport()}


def _transport() -> str:
    """Read the transport from the environment, defaulting to stdio."""
    return os.environ.get("MCP_TRANSPORT", "stdio").lower()


def main() -> None:
    transport = _transport()
    if transport == "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

  • mcp = FastMCP("macmcp") creates the server instance. The name is what clients display.
  • @mcp.tool registers a function as an MCP tool. FastMCP reads the type hints (text: str -> int) to generate the input schema and the docstring to describe the tool to the model. The bodies are deliberately pure and deterministic, which makes them trivial to test in Step 4.
  • @mcp.resource("server://info") exposes read-only data at a URI. Clients list and read resources separately from calling tools; this one reports which transport the process is actually running under, which is handy after deploy.
  • _transport() centralizes the MCP_TRANSPORT lookup so the tool, the resource, and main() all agree. It defaults to stdio, so running the file with no environment set behaves like a normal local MCP server.
  • main() branches on the transport. For HTTP it also reads MCP_HOST and MCP_PORT, binding to 127.0.0.1 by default so the server is reachable only from your Mac — not the local network. This single-file design is the key to the whole tutorial: the deployment in Step 6 just sets MCP_TRANSPORT=http and runs the same script.

Step 4: Test the server in-memory

FastMCP’s Client can connect directly to a server object without any network or subprocess. That makes for fast, hermetic tests.

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 server in-memory, with no network or subprocess involved."""

import json

import pytest
from fastmcp import Client

from 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": "Deploy FastMCP on macOS!"})
        assert result.data == "deploy-fastmcp-on-macos"


@pytest.mark.asyncio
async def test_server_info_resource():
    async with Client(mcp) as client:
        contents = await client.read_resource("server://info")
        payload = json.loads(contents[0].text)
        assert payload["name"] == "macmcp"

Detailed breakdown

  • asyncio_mode = auto in pytest.ini lets pytest-asyncio run async def tests without wrapping each one in an event-loop fixture.
  • Client(mcp) passed the server object (not a URL) uses FastMCP’s in-memory transport. This is the fastest way to test tools and resources and needs no running server.
  • result.data is the deserialized return value of the tool. FastMCP also exposes the raw content blocks, but .data is what you assert against for typed returns.
  • read_resource returns a list of content items; server://info returns JSON text, so the test parses contents[0].text. These three tests cover both tools and the resource — the full public surface of the server.

Run them:

uv run pytest -v

You should see three passing tests.

Step 5: The Makefile — one command per task

The Makefile is the control panel for the whole project: development, testing, and the full deploy lifecycle. Running plain make prints a help screen listing every target.

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

# --- Deploy configuration (launchd user agent) --------------------------------
LABEL   := com.macmcp.server
PLIST   := $(HOME)/Library/LaunchAgents/$(LABEL).plist
UV      := $(shell command -v uv)
DIR     := $(shell pwd)
DOMAIN  := gui/$(shell id -u)

.PHONY: help install dev serve test smoke deploy undeploy status logs clean

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

install:  ## Sync runtime and dev dependencies
	uv sync

dev:  ## Launch the MCP Inspector against the server (stdio)
	uv run fastmcp dev server.py

serve:  ## Run the HTTP server in the foreground (Ctrl-C to stop)
	MCP_TRANSPORT=http uv run python server.py

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

smoke:  ## Call a tool against the running HTTP server
	uv run python scripts/smoke.py

deploy:  ## Render the launchd plist and start the service
	@mkdir -p logs
	@sed -e 's#@UV@#$(UV)#g' \
	     -e 's#@DIR@#$(DIR)#g' \
	     -e 's#@PATH@#$(dir $(UV)):/usr/bin:/bin#g' \
	     deploy/$(LABEL).plist.template > $(PLIST)
	launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
	launchctl bootstrap $(DOMAIN) $(PLIST)
	@echo "Deployed $(LABEL). Check: make status"

undeploy:  ## Stop the service and remove the launchd plist
	launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
	rm -f $(PLIST)
	@echo "Removed $(LABEL)."

status:  ## Show whether the service is registered and running
	@launchctl print $(DOMAIN)/$(LABEL) 2>/dev/null \
		| grep -E 'state =|pid =' || echo "$(LABEL) is not loaded."

logs:  ## Tail the service log files
	@tail -n 40 -f logs/server.out.log logs/server.err.log

clean:  ## Remove caches and local logs
	rm -rf .pytest_cache logs __pycache__ tests/__pycache__

Detailed breakdown

  • .DEFAULT_GOAL := help plus the help target’s grep/awk one-liner means a bare make prints a self-documenting list built from the ## comment after each target. Add a target, document it inline, and it appears automatically.
  • The configuration block computes everything launchd needs at run time: UV is the absolute path to uv (launchd runs with a bare environment and will not find it on PATH), DIR is the absolute project directory, and DOMAIN is gui/<your-uid> — the per-user launchd domain your login session lives in.
  • dev runs the MCP Inspector over stdio for interactive poking; serve runs the HTTP server in the foreground so you can watch it during development.
  • deploy is the heart of it: it renders the plist template (Step 6) by substituting the absolute paths, writes it into ~/Library/LaunchAgents/, then bootouts any previous copy and bootstraps the new one. sed uses # as its delimiter because the replacement values are filesystem paths full of /.
  • status, logs, undeploy are the operational verbs you will reach for after deploying. undeploy is idempotent — the || true swallows the error when nothing is loaded.

Confirm the help screen works:

make

It should print the target list with descriptions.

Step 6: Describe the service to launchd

launchd is the macOS service manager. A user agent is a service defined by a .plist file in ~/Library/LaunchAgents/; it runs as you, starts at login, and can be kept alive automatically. You will not write absolute paths by hand — the deploy target fills them in from a template.

Create the file

mkdir -p deploy
touch deploy/com.macmcp.server.plist.template

Add the code: deploy/com.macmcp.server.plist.template

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.macmcp.server</string>

    <key>ProgramArguments</key>
    <array>
        <string>@UV@</string>
        <string>run</string>
        <string>python</string>
        <string>server.py</string>
    </array>

    <key>WorkingDirectory</key>
    <string>@DIR@</string>

    <key>EnvironmentVariables</key>
    <dict>
        <key>MCP_TRANSPORT</key>
        <string>http</string>
        <key>MCP_HOST</key>
        <string>127.0.0.1</string>
        <key>MCP_PORT</key>
        <string>8000</string>
        <key>PATH</key>
        <string>@PATH@</string>
    </dict>

    <key>RunAtLoad</key>
    <true/>

    <key>KeepAlive</key>
    <true/>

    <key>StandardOutPath</key>
    <string>@DIR@/logs/server.out.log</string>

    <key>StandardErrorPath</key>
    <string>@DIR@/logs/server.err.log</string>
</dict>
</plist>

Detailed breakdown

  • Label is the service’s unique identity. It must match the plist filename (minus .plist) and is how you address the service in launchctl.
  • ProgramArguments is the command launchd runs. @UV@ is replaced by the absolute uv path so uv run python server.py launches inside the project’s virtual environment. WorkingDirectory (@DIR@) ensures server.py and pyproject.toml are found.
  • EnvironmentVariables is where the single-file design pays off: setting MCP_TRANSPORT=http (plus host and port) makes the same server.py boot as an HTTP service. The injected PATH (@PATH@) gives launchd enough to find uv and system tools.
  • RunAtLoad starts the server the moment it is bootstrapped (and at every login). KeepAlive restarts it if it exits — a crash, or a manual kill — making it a genuine supervised service.
  • StandardOutPath/StandardErrorPath redirect the process’s output into logs/, which is exactly what make logs tails. The @…@ placeholders are never valid on their own; the template is only ever used through make deploy, which substitutes them.

Step 7: A smoke test for the deployed server

Tests in Step 4 ran in-memory. Once the server is deployed over HTTP, you want a quick way to prove it answers over the wire. This tiny script connects to the URL and calls a tool.

Create the file

mkdir -p scripts
touch scripts/smoke.py

Add the code: scripts/smoke.py

"""Connect to the running HTTP server and call a tool as a smoke test."""

import asyncio
import os

from fastmcp import Client

URL = os.environ.get("MCP_URL", "http://127.0.0.1:8000/mcp/")


async def main() -> None:
    async with Client(URL) as client:
        tools = await client.list_tools()
        print("tools:", [t.name for t in tools])
        result = await client.call_tool("word_count", {"text": "deploy me on a mac"})
        print("word_count ->", result.data)


if __name__ == "__main__":
    asyncio.run(main())

Detailed breakdown

  • Client(URL) — passing a URL instead of the server object makes FastMCP use its HTTP client. The default endpoint for the HTTP transport is /mcp/ (note the trailing slash), which is why the URL is http://127.0.0.1:8000/mcp/.
  • list_tools() confirms the server is reachable and advertises the expected tools; call_tool(...) proves an end-to-end request/response actually works. If the server is down you get a connection error here — a clear, fast failure.
  • The MCP_URL override lets you point the same script at a different host or port without editing it. make smoke runs exactly this script.

Step 8: Deploy, verify, and manage the service

Now put it all together. Deploy the service, confirm it is running, and smoke test it over HTTP.

make deploy
make status

make status should report state = running and a pid. Behind the scenes, deploy rendered the plist into ~/Library/LaunchAgents/com.macmcp.server.plist and bootstrapped it into your login domain. Because RunAtLoad is set, the server is already listening on port 8000.

Prove it answers over the wire:

make smoke

Expected output:

tools: ['word_count', 'slugify']
word_count -> 5

Inspect the logs the service is writing:

make logs      # Ctrl-C to stop tailing

You will see uvicorn’s startup lines, ending with Uvicorn running on http://127.0.0.1:8000.

Because KeepAlive is set, the service is self-healing. Try killing it and watch launchd bring it straight back. launchd throttles respawns, so give it about ten seconds:

pkill -f "server.py"        # stop the running process
sleep 10                    # launchd throttles restarts (~10s)
make status                 # reports a new pid — the service is back

The server also comes back automatically after a reboot or re-login — no action needed. When you are done, tear it down cleanly:

make undeploy
make status                 # "com.macmcp.server is not loaded."

Step 9: Connect a real MCP client

The deployed server speaks HTTP; the make smoke client already proved that. To use it from Claude Desktop, which launches MCP servers over stdio, you have two options:

  1. Local stdio (simplest). Point Claude Desktop directly at server.py with no MCP_TRANSPORT set, so it launches over stdio on demand. Edit ~/Library/Application Support/Claude/claude_desktop_config.json:

    {
      "mcpServers": {
        "macmcp": {
          "command": "uv",
          "args": ["run", "python", "server.py"],
          "cwd": "/absolute/path/to/deploy-fastmcp-server-macos"
        }
      }
    }
    

    Restart Claude Desktop; word_count and slugify appear in the tools list. With this option you do not need the deployed HTTP service at all — it is the classic local-dev path.

  2. Deployed HTTP + bridge. Keep the supervised HTTP service from Step 8 and put a stdio-to-HTTP bridge like mcp-remote in front of it, pointing at http://127.0.0.1:8000/mcp/. This is the right shape when several clients (or several apps) should share one always-on server rather than each spawning its own.

Use stdio while developing a single server; reach for the deployed HTTP service when you want one persistent, supervised process that outlives any single client.

Troubleshooting

  • make status says “not loaded” right after deploy. Look at logs/server.err.log. A Python traceback there means the server crashed on startup; because KeepAlive retries, launchd may report it between restarts. Fix the error, then make deploy again (it re-bootstraps cleanly).
  • make smoke fails with a connection error. The service is not listening. Check make status for a running pid and confirm nothing else holds port 8000 with lsof -iTCP:8000 -sTCP:LISTEN.
  • launchctl bootstrap errors with “Bootstrap failed: 5: Input/output error”. A stale copy is still loaded. Run make undeploy first, then make deploy. The deploy target already does a best-effort bootout, but a manual load can leave a copy behind.
  • uv: command not found in the logs. launchd could not resolve uv. Ensure command -v uv returns a Homebrew path (/opt/homebrew/bin/uv on Apple Silicon, /usr/local/bin/uv on Intel) at the time you run make deploy — that absolute path is what gets baked into the plist.
  • Port already in use. Set a different port for the deploy by exporting it before make deploy, or edit the MCP_PORT value in the template.

Recap

You built a FastMCP server whose transport is chosen at runtime, tested its full surface in-memory, and deployed it as a supervised macOS service:

  • uv manages the interpreter, dependencies, and every run command — no global Python required.
  • One server.py runs over stdio for local development and over HTTP for deployment, switched by MCP_TRANSPORT.
  • launchd supervises the HTTP server: it starts at login, restarts on crash, and logs to logs/.
  • make is the single control panel — deploy, status, smoke, logs, and undeploy cover the whole lifecycle.

Next improvements

  • Front the server with a reverse proxy (Caddy or nginx) and TLS if you need to reach it from other machines instead of 127.0.0.1.
  • Add authentication — FastMCP supports bearer-token auth for HTTP transports.
  • Package the server as a uv script or a container so the deploy target does not depend on the checkout path.
  • Add a /health-style resource and wire make status to assert on it.