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.pythat runs over stdio for local development and over HTTP for deployment, selected by environment variables. - A
pytestsuite that exercises the server in-memory (no network, no subprocess). - A
launchduser agent that supervises the HTTP server, plusmaketargets to deploy, check status, tail logs, and tear it down.
Prerequisites
- macOS 13 (Ventura) or newer. The
launchctl bootstrap/bootoutsubcommands used here are the modern replacements forlaunchctl 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 withuv --version.uvmanages the Python interpreter, so you do not need to install Python separately. - Xcode Command Line Tools (
xcode-select --install) formake. Verify withmake --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 environmentuvcreates. It is large and fully reproducible from the lockfile, so it should never be committed.logs/,*.log,*.pidcover the runtime artifacts the deployed service writes. Whenlaunchdsupervises the server, its stdout and stderr land inlogs/; ignoring them now prevents accidental commits later..DS_Storeis 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-workspacekeeps this a standalone project rather than joining a parentuvworkspace, which matters if you scaffold inside a larger repo.dependenciesholdsfastmcp— the only thing consumers of the server need at runtime.dependency-groups.devholdspytestandpytest-asyncio. FastMCP’s client API is async, so the async plugin letspytestawait 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.toolregisters 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 theMCP_TRANSPORTlookup so the tool, the resource, andmain()all agree. It defaults tostdio, so running the file with no environment set behaves like a normal local MCP server.main()branches on the transport. For HTTP it also readsMCP_HOSTandMCP_PORT, binding to127.0.0.1by 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 setsMCP_TRANSPORT=httpand 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 = autoinpytest.iniletspytest-asynciorunasync deftests 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.datais the deserialized return value of the tool. FastMCP also exposes the raw content blocks, but.datais what you assert against for typed returns.read_resourcereturns a list of content items;server://inforeturns JSON text, so the test parsescontents[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 := helpplus thehelptarget’sgrep/awkone-liner means a baremakeprints 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
launchdneeds at run time:UVis the absolute path touv(launchd runs with a bare environment and will not find it onPATH),DIRis the absolute project directory, andDOMAINisgui/<your-uid>— the per-user launchd domain your login session lives in. devruns the MCP Inspector over stdio for interactive poking;serveruns the HTTP server in the foreground so you can watch it during development.deployis the heart of it: it renders the plist template (Step 6) by substituting the absolute paths, writes it into~/Library/LaunchAgents/, thenbootouts any previous copy andbootstraps the new one.seduses#as its delimiter because the replacement values are filesystem paths full of/.status,logs,undeployare the operational verbs you will reach for after deploying.undeployis idempotent — the|| trueswallows 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
Labelis the service’s unique identity. It must match the plist filename (minus.plist) and is how you address the service inlaunchctl.ProgramArgumentsis the command launchd runs.@UV@is replaced by the absoluteuvpath souv run python server.pylaunches inside the project’s virtual environment.WorkingDirectory(@DIR@) ensuresserver.pyandpyproject.tomlare found.EnvironmentVariablesis where the single-file design pays off: settingMCP_TRANSPORT=http(plus host and port) makes the sameserver.pyboot as an HTTP service. The injectedPATH(@PATH@) gives launchd enough to finduvand system tools.RunAtLoadstarts the server the moment it is bootstrapped (and at every login).KeepAliverestarts it if it exits — a crash, or a manualkill— making it a genuine supervised service.StandardOutPath/StandardErrorPathredirect the process’s output intologs/, which is exactly whatmake logstails. The@…@placeholders are never valid on their own; the template is only ever used throughmake 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 ishttp://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_URLoverride lets you point the same script at a different host or port without editing it.make smokeruns 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:
Local stdio (simplest). Point Claude Desktop directly at
server.pywith noMCP_TRANSPORTset, 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_countandslugifyappear in the tools list. With this option you do not need the deployed HTTP service at all — it is the classic local-dev path.Deployed HTTP + bridge. Keep the supervised HTTP service from Step 8 and put a stdio-to-HTTP bridge like
mcp-remotein front of it, pointing athttp://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 statussays “not loaded” right after deploy. Look atlogs/server.err.log. A Python traceback there means the server crashed on startup; becauseKeepAliveretries, launchd may report it between restarts. Fix the error, thenmake deployagain (it re-bootstraps cleanly).make smokefails with a connection error. The service is not listening. Checkmake statusfor a running pid and confirm nothing else holds port 8000 withlsof -iTCP:8000 -sTCP:LISTEN.launchctl bootstraperrors with “Bootstrap failed: 5: Input/output error”. A stale copy is still loaded. Runmake undeployfirst, thenmake deploy. Thedeploytarget already does a best-effortbootout, but a manual load can leave a copy behind.uv: command not foundin the logs. launchd could not resolveuv. Ensurecommand -v uvreturns a Homebrew path (/opt/homebrew/bin/uvon Apple Silicon,/usr/local/bin/uvon Intel) at the time you runmake 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 theMCP_PORTvalue 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:
uvmanages the interpreter, dependencies, and every run command — no global Python required.- One
server.pyruns over stdio for local development and over HTTP for deployment, switched byMCP_TRANSPORT. launchdsupervises the HTTP server: it starts at login, restarts on crash, and logs tologs/.makeis the single control panel —deploy,status,smoke,logs, andundeploycover 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
uvscript or a container so the deploy target does not depend on the checkout path. - Add a
/health-style resource and wiremake statusto assert on it.