You have a working MCP server (see Build an MCP Server with FastMCP and Create
and Deploy a FastMCP Server on macOS). A server on its own does nothing until a
client connects to it. This tutorial wires one server into the two Claude clients
you are most likely to use on a Mac: Claude Code (the CLI) and Claude
Desktop (the chat app). They use different configuration systems, so each gets
its own walkthrough, and the tricky remote case gets a mcp-remote bridge.
The two clients differ in three ways that shape everything below:
| Claude Code | Claude Desktop | |
|---|---|---|
| Config | claude mcp CLI → ~/.claude.json / .mcp.json | claude_desktop_config.json (edit by hand) |
| Transports | stdio and remote http | stdio only (use mcp-remote for HTTP) |
| Environment | inherits your shell (PATH, cwd) | launched by the GUI: no shell PATH, arbitrary cwd |
The one gotcha behind most failures. Claude Desktop is a GUI app. It does not read your shell profile, so
uv,npx, and friends are not on itsPATH, and it starts servers from an arbitrary working directory. Every command it launches must use an absolute path and name the project directory explicitly. Claude Code, launched from your terminal, inherits your shell and is far more forgiving.
What you will build
- A tiny
mac-greeterFastMCP server that runs over stdio or HTTP. - A stdio smoke test that launches the server exactly as a client does, so you verify the launch command before touching any client config.
- Registration in Claude Code three ways: local, user, and project scope
(the committed
.mcp.json). - Registration in Claude Desktop over stdio, and over HTTP through the
mcp-remotebridge. - A
makewrapper that renders the exact config blocks with absolute paths filled in.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - Claude Code installed and authenticated — verify
claude --version. - Claude Desktop (claude.ai/download) for the desktop walkthrough.
- Node.js 18+ (
brew install node) — only for themcp-remotebridge in Step 7;npxships with it.
Step 1: Scaffold and add project hygiene
Create the workspace and a .gitignore first.
Create the files
mkdir -p register-fastmcp-server-with-claude-macos
cd register-fastmcp-server-with-claude-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
logs/
*.log
*.pid
# Local client config renders (may contain absolute paths)
*.local.json
# OS / editor noise
.DS_Store
Detailed breakdown
- The stack is Python, so the ignores mirror the other FastMCP tutorials.
*.local.jsonis a convention for any config block you render and save locally with machine-specific absolute paths — keep those out of version control. The one config file you do commit,.mcp.json, is portable and is added deliberately in Step 5.
Step 2: Initialize the project with uv
Create the files
uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "A FastMCP server registered with Claude Desktop and Claude Code"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.4",
]
Detailed breakdown
fastmcpprovides both the server and theClientused by the smoke test. No other dependency is needed on the Python side;mcp-remote(Step 7) is a Node package run throughnpx, not a Python dependency.
Step 3: Write the server
A minimal server keeps the focus on registration. It runs over stdio by default
(what a client launches as a subprocess) and switches to HTTP when
MCP_TRANSPORT=http, so the same file serves both the local and remote paths.
Create the file
touch server.py
Add the code: server.py
"""A small FastMCP server used to demonstrate client registration.
The server is deliberately tiny — the point of this tutorial is wiring it into
Claude Desktop and Claude Code, not the tools themselves. It runs over stdio by
default (what both clients launch as a subprocess) and switches to HTTP when
MCP_TRANSPORT=http, so the same file works for the local and remote paths.
"""
import os
from fastmcp import FastMCP
mcp = FastMCP("mac-greeter")
@mcp.tool
def greet(name: str) -> str:
"""Return a friendly greeting for name."""
return f"Hello, {name}! This came from the mac-greeter MCP server."
@mcp.tool
def server_info() -> dict:
"""Report how this server process was launched."""
return {
"name": "mac-greeter",
"transport": os.environ.get("MCP_TRANSPORT", "stdio").lower(),
"pid": os.getpid(),
}
def main() -> None:
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: one client, launched as a subprocess
if __name__ == "__main__":
main()
Detailed breakdown
greetreturns a fixed, recognizable string so you can tell at a glance that a tool call went through this server and not somewhere else.server_inforeports the transport and process id, which is handy for confirming how a client launched the server (stdio subprocess vs HTTP).- The transport switch is the whole reason one file covers both clients:
stdio for the subprocess model (Steps 5–6) and HTTP for the remote model
(Step 7). The defaults (
127.0.0.1:8000) keep the HTTP server local.
Step 4: Smoke-test the launch command over stdio
Before editing any client config, confirm the exact command a client will run
actually starts the server and answers tool calls. This script launches
server.py as a subprocess and speaks MCP to it — the same thing Claude Code and
Claude Desktop do.
Create the file
mkdir -p scripts
touch scripts/smoke.py
Add the code: scripts/smoke.py
"""Smoke-test the server over stdio, the same way a client launches it.
This spawns `server.py` as a subprocess and speaks MCP to it, so a green run
proves the exact command you will hand to Claude Desktop / Claude Code works
before you touch any client config. No network and no GUI involved.
"""
import asyncio
from fastmcp import Client
# Launch the server the same way a client will: `python server.py` over stdio.
client = Client("server.py")
async def main() -> None:
async with client:
tools = [t.name for t in await client.list_tools()]
print("tools:", tools)
greeting = (await client.call_tool("greet", {"name": "Ada"})).data
print("greet ->", greeting)
info = (await client.call_tool("server_info", {})).data
print("server_info ->", info)
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
Client("server.py")tells the FastMCP client to launch that script over stdio and manage the subprocess lifecycle — a faithful stand-in for what a Claude client does.- A green run means the server and its launch both work. If a client later reports “failed to connect”, you know the problem is in the client config (path, cwd, environment), not the server.
Run it:
uv run python scripts/smoke.py
tools: ['greet', 'server_info']
greet -> Hello, Ada! This came from the mac-greeter MCP server.
server_info -> {'name': 'mac-greeter', 'transport': 'stdio', 'pid': 12345}
FastMCP prints a startup banner to stderr; the three lines above are the script’s
stdout. The pid is whatever the OS assigned, so yours will differ.
Step 5: Register with Claude Code
Claude Code registers servers with the claude mcp command. Because it is
launched from your terminal, it inherits your shell, so the launch command is
close to what you just smoke-tested. The one addition is --directory, which
pins the working directory so the server resolves regardless of where you start
claude.
Run this from the project directory:
claude mcp add mac-greeter -- "$(command -v uv)" run --directory "$(pwd)" python server.py
Added stdio MCP server mac-greeter with command: /opt/homebrew/bin/uv run
--directory /Users/you/register-fastmcp-server-with-claude-macos python server.py
to local config
File modified: /Users/you/.claude.json [project: /Users/you/register-fastmcp-server-with-claude-macos]
Everything after -- is the command Claude Code runs to start the server. The
key parts:
"$(command -v uv)"expands to the absolute path ofuv— good hygiene even here, and exactly what Claude Desktop will need in Step 6.run --directory "$(pwd)"tellsuvwhich project to run in, so the server does not depend on Claude Code’s working directory.
Confirm it connected:
claude mcp get mac-greeter
mac-greeter:
Scope: Local config (private to you in this project)
Status: ✔ Connected
Type: stdio
Command: /opt/homebrew/bin/uv
Args: run --directory /Users/you/register-fastmcp-server-with-claude-macos python server.py
Environment:
To remove this server, run: claude mcp remove mac-greeter -s local
✔ Connected means Claude Code launched the server and completed the MCP
handshake. Start a session with claude, then ask it to use mac-greeter to
greet Ada, and approve the tool the first time it is called.
Choose the right scope
claude mcp add writes to one of three scopes. The scope is fixed at add time,
so to change it you remove and re-add.
| Scope | Flag | Where it is stored | Who sees it |
|---|---|---|---|
| Local | (default) | ~/.claude.json, under this project’s entry | Only you, only this project |
| User | --scope user | ~/.claude.json, top-level mcpServers | Only you, every project |
| Project | --scope project | .mcp.json in the project root | Anyone who clones the repo |
Use user scope for a server you want everywhere:
claude mcp add --scope user mac-greeter -- "$(command -v uv)" run --directory "$(pwd)" python server.py
Use project scope to share the server with your team through the repository.
That writes a .mcp.json you commit — so make it portable rather than pinning it
to one machine’s absolute paths.
Create the file
touch .mcp.json
Add the code: .mcp.json
{
"mcpServers": {
"mac-greeter": {
"type": "stdio",
"command": "uv",
"args": ["run", "server.py"]
}
}
}
Detailed breakdown
type: "stdio"withcommand/argsis the same schema every Claude Code config file uses; an HTTP entry would use"type": "http"and a"url"instead (Step 7).- No absolute paths and no
--directory. A committed.mcp.jsonshould work on a teammate’s machine, so it relies onuvbeing on theirPATHand on Claude Code being started from the project root (its default working directory). This is the opposite trade-off from Claude Desktop. - The first time Claude Code sees a project server it asks for approval, so a cloned repo cannot launch processes silently:
claude mcp get mac-greeter
mac-greeter:
Scope: Project config (shared via .mcp.json)
Status: ⏸ Pending approval (run `claude` to approve)
Type: stdio
Command: uv
Args: run server.py
To remove this server, run: claude mcp remove mac-greeter -s project
Run claude, approve the prompt (or open /mcp to approve later), and the
status changes to connected.
If you registered
mac-greeterat more than one scope while following along,claude mcp remove mac-greeterwill report that it exists in multiple scopes; pass-s local,-s user, or-s projectto pick one.
Step 6: Register with Claude Desktop
Claude Desktop has no CLI; you edit its config file directly. It speaks stdio only, and because it is a GUI app it does not inherit your shell — so this is where absolute paths become mandatory.
Create the file
mkdir -p ~/Library/Application\ Support/Claude
touch ~/Library/Application\ Support/Claude/claude_desktop_config.json
Add the code: ~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"mac-greeter": {
"command": "/opt/homebrew/bin/uv",
"args": ["run", "--directory", "/Users/you/register-fastmcp-server-with-claude-macos", "python", "server.py"]
}
}
}
Detailed breakdown
commandis the absolute path touv. Find yours withcommand -v uv(Homebrew installs it at/opt/homebrew/bin/uvon Apple silicon). A bareuvfails here because Claude Desktop has noPATHfrom your shell.--directorynames the project with an absolute path souvfinds the rightpyproject.tomlno matter what working directory the app uses.- Claude Desktop’s schema has no
typefield — amcpServersentry is always a stdiocommand/argspair. HTTP servers go through the bridge in Step 7. - If
claude_desktop_config.jsonalready has other servers, addmac-greeteras another key inside the existingmcpServersobject rather than replacing the file.
Getting those two absolute paths right by hand is error-prone, so let the project
render the block for you (the make desktop-config target from Step 8):
make desktop-config
It prints the JSON above with your real uv path and project directory filled
in — copy the mac-greeter entry into the config file.
Restart Claude Desktop completely (Quit with ⌘Q, not just close the window) for it to reload the config. Then open a new chat: the tools appear under the plug/tools icon, and asking Claude to greet Ada with mac-greeter exercises the server.
Step 7: Connect over HTTP (remote servers)
A deployed server (behind HTTPS, or the OAuth/Clerk servers from the auth tutorials) speaks HTTP, not stdio. The two clients handle that differently. Start the server in HTTP mode in one terminal:
MCP_TRANSPORT=http uv run python server.py
Claude Code speaks HTTP natively. Register the URL with --transport http:
claude mcp add --transport http mac-greeter-http http://127.0.0.1:8000/mcp/
claude mcp get mac-greeter-http
mac-greeter-http:
Scope: Local config (private to you in this project)
Status: ✔ Connected
Type: http
URL: http://127.0.0.1:8000/mcp/
For a server that needs a token, add --header "Authorization: Bearer <token>";
for one behind a browser sign-in (OAuth), add it the same way and complete the
login from the /mcp panel inside a session.
Claude Desktop cannot speak HTTP, so it needs the mcp-remote bridge — a
small Node program that presents a remote HTTP server to the app as a local stdio
process. Add this to claude_desktop_config.json:
{
"mcpServers": {
"mac-greeter-remote": {
"command": "/opt/homebrew/bin/npx",
"args": ["-y", "mcp-remote", "http://127.0.0.1:8000/mcp/"]
}
}
}
Detailed breakdown
mcp-remoteis the stdio-to-HTTP adapter. Claude Desktop launches it over stdio (the only transport it knows); it forwards the traffic to the HTTP URL and relays the responses back.-yletsnpxfetchmcp-remoteon first run without prompting. It requires Node 18+.commandis the absolute path tonpxfor the same PATH reason asuv; find it withcommand -v npx.- If the remote server requires auth, pass it through the bridge, for example
"args": ["-y", "mcp-remote", "https://your-server/mcp/", "--header", "Authorization: Bearer <token>"].mcp-remotealso handles the OAuth browser flow for servers that use it. make desktop-config-remoteprints this block with yournpxpath filled in.
Step 8: The Makefile
Wrap the commands so plain make prints help, and let two targets render the
Claude Desktop JSON with absolute paths already substituted.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
# Absolute paths matter: Claude Desktop launches servers without your shell's
# PATH or working directory, so recipes resolve `uv`/`npx` to full paths and
# pass the project directory explicitly.
UV := $(shell command -v uv)
NPX := $(shell command -v npx)
DIR := $(shell pwd)
NAME := mac-greeter
HTTP_URL ?= http://127.0.0.1:8000/mcp/
.PHONY: help install smoke serve-http register-code register-code-http \
get list unregister-code desktop-config desktop-config-remote clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}'
install: ## Sync dependencies
uv sync
smoke: ## Launch the server over stdio and call its tools (no client, no GUI)
uv run python scripts/smoke.py
serve-http: ## Run the server over HTTP for the remote/mcp-remote path
MCP_TRANSPORT=http uv run python server.py
register-code: ## Register the stdio server with Claude Code (local scope)
claude mcp add $(NAME) -- $(UV) run --directory $(DIR) python server.py
register-code-http: ## Register the running HTTP server with Claude Code
claude mcp add --transport http $(NAME)-http $(HTTP_URL)
get: ## Show the server's scope, status, and launch command
claude mcp get $(NAME)
list: ## List all servers Claude Code sees, with connection status
claude mcp list
unregister-code: ## Remove both servers this project registered
-claude mcp remove $(NAME)
-claude mcp remove $(NAME)-http
desktop-config: ## Print a claude_desktop_config.json block (stdio, absolute paths)
@printf '%s\n' \
'{' \
' "mcpServers": {' \
' "$(NAME)": {' \
' "command": "$(UV)",' \
' "args": ["run", "--directory", "$(DIR)", "python", "server.py"]' \
' }' \
' }' \
'}'
desktop-config-remote: ## Print a claude_desktop_config.json block (mcp-remote bridge)
@printf '%s\n' \
'{' \
' "mcpServers": {' \
' "$(NAME)-remote": {' \
' "command": "$(NPX)",' \
' "args": ["-y", "mcp-remote", "$(HTTP_URL)"]' \
' }' \
' }' \
'}'
clean: ## Remove caches
rm -rf .pytest_cache __pycache__ scripts/__pycache__
Detailed breakdown
UV/NPX/DIRare resolved once at the top withcommand -vandpwd, the same absolute values the clients need, so every recipe and rendered config is consistent.register-codereproduces the Step 5 command;register-code-httpthe Step 7 one.unregister-codeprefixes both removals with-somakeignores the error when a server was not registered.desktop-config/desktop-config-remoteprint ready-to-paste JSON with your machine’s paths filled in — the safe way to produce the Step 6 and Step 7 blocks. Pipe either throughpython3 -m json.toolto confirm it parses.- Plain
makeprints the help screen listing every target.
Verify the default target:
make
help Show this help screen
install Sync dependencies
smoke Launch the server over stdio and call its tools (no client, no GUI)
serve-http Run the server over HTTP for the remote/mcp-remote path
register-code Register the stdio server with Claude Code (local scope)
register-code-http Register the running HTTP server with Claude Code
get Show the server's scope, status, and launch command
list List all servers Claude Code sees, with connection status
unregister-code Remove both servers this project registered
desktop-config Print a claude_desktop_config.json block (stdio, absolute paths)
desktop-config-remote Print a claude_desktop_config.json block (mcp-remote bridge)
clean Remove caches
Troubleshooting
- Claude Code:
✘ Failed to connect. Run the server’s command directly to see the real error, then compare it toclaude mcp get <name>. A stdio server that connects on the command line but not through Claude Code usually means a missing--separator (everything after--is the launch command) or a wrong--directory. - Claude Code:
/mcpshows no servers. Local-scoped servers are tied to the directory you added them from. Re-add from the current project, or use--scope user. Only~/.claude.jsonand<project>/.mcp.jsonare read — paths like~/.claude/mcp.jsonare ignored. - Claude Desktop: server never appears. Almost always a
PATHproblem: use the absolute path touv/npx, not a bare command. Quit the app fully (⌘Q) and reopen — closing the window does not reload the config. - Claude Desktop: config changes ignored. The JSON is likely malformed
(a trailing comma, a missing brace). Validate with
python3 -m json.tool < ~/Library/Application\ Support/Claude/claude_desktop_config.json. Connection timed out at startup. A firstnpx/uvxrun can be slow while it downloads a package. Increase Claude Code’s limit withMCP_TIMEOUT=60000 claude.- HTTP server refuses the connection. Confirm it is running and reachable:
curl -I http://127.0.0.1:8000/mcp/. A404/405still means the server is up (MCP endpoints answer POST); no response means it is not listening.
Recap
- Claude Code registers servers with
claude mcp add:stdiovia a--launch command, remotehttpvia--transport http, across local/user/project scopes. Project scope is a committed, portable.mcp.json. - Claude Desktop is edited by hand in
claude_desktop_config.json, speaks stdio only, and — being a GUI app with no shellPATH— needs absolute paths and an explicit project directory. mcp-remotebridges a remote HTTP server into Claude Desktop by presenting it as a local stdio process.- Smoke-test the launch command first, and remember the split: Claude Code inherits your shell and is forgiving; Claude Desktop inherits nothing and needs everything spelled out.
Next improvements
- Register the deployed HTTPS server from Put a FastMCP Server Behind HTTPS with
Caddy and the Clerk/OAuth servers, completing the sign-in from the
/mcppanel. - Commit
.mcp.jsonto a real repository and confirm a teammate gets the approval prompt on firstclaudelaunch. - Import existing Claude Desktop servers into Claude Code with
claude mcp add-from-claude-desktopinstead of re-adding them by hand.