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 CodeClaude Desktop
Configclaude mcp CLI → ~/.claude.json / .mcp.jsonclaude_desktop_config.json (edit by hand)
Transportsstdio and remote httpstdio only (use mcp-remote for HTTP)
Environmentinherits 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 its PATH, 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-greeter FastMCP 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-remote bridge.
  • A make wrapper that renders the exact config blocks with absolute paths filled in.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • 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 the mcp-remote bridge in Step 7; npx ships 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.json is 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

  • fastmcp provides both the server and the Client used by the smoke test. No other dependency is needed on the Python side; mcp-remote (Step 7) is a Node package run through npx, 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

  • greet returns a fixed, recognizable string so you can tell at a glance that a tool call went through this server and not somewhere else.
  • server_info reports 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 of uv — good hygiene even here, and exactly what Claude Desktop will need in Step 6.
  • run --directory "$(pwd)" tells uv which 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.

ScopeFlagWhere it is storedWho sees it
Local(default)~/.claude.json, under this project’s entryOnly you, only this project
User--scope user~/.claude.json, top-level mcpServersOnly you, every project
Project--scope project.mcp.json in the project rootAnyone 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" with command/args is 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.json should work on a teammate’s machine, so it relies on uv being on their PATH and 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-greeter at more than one scope while following along, claude mcp remove mac-greeter will report that it exists in multiple scopes; pass -s local, -s user, or -s project to 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

  • command is the absolute path to uv. Find yours with command -v uv (Homebrew installs it at /opt/homebrew/bin/uv on Apple silicon). A bare uv fails here because Claude Desktop has no PATH from your shell.
  • --directory names the project with an absolute path so uv finds the right pyproject.toml no matter what working directory the app uses.
  • Claude Desktop’s schema has no type field — a mcpServers entry is always a stdio command/args pair. HTTP servers go through the bridge in Step 7.
  • If claude_desktop_config.json already has other servers, add mac-greeter as another key inside the existing mcpServers object 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-remote is 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.
  • -y lets npx fetch mcp-remote on first run without prompting. It requires Node 18+.
  • command is the absolute path to npx for the same PATH reason as uv; find it with command -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-remote also handles the OAuth browser flow for servers that use it.
  • make desktop-config-remote prints this block with your npx path 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/DIR are resolved once at the top with command -v and pwd, the same absolute values the clients need, so every recipe and rendered config is consistent.
  • register-code reproduces the Step 5 command; register-code-http the Step 7 one. unregister-code prefixes both removals with - so make ignores the error when a server was not registered.
  • desktop-config / desktop-config-remote print 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 through python3 -m json.tool to confirm it parses.
  • Plain make prints 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 to claude 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: /mcp shows 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.json and <project>/.mcp.json are read — paths like ~/.claude/mcp.json are ignored.
  • Claude Desktop: server never appears. Almost always a PATH problem: use the absolute path to uv/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 first npx/uvx run can be slow while it downloads a package. Increase Claude Code’s limit with MCP_TIMEOUT=60000 claude.
  • HTTP server refuses the connection. Confirm it is running and reachable: curl -I http://127.0.0.1:8000/mcp/. A 404/405 still 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: stdio via a -- launch command, remote http via --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 shell PATH — needs absolute paths and an explicit project directory.
  • mcp-remote bridges 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 /mcp panel.
  • Commit .mcp.json to a real repository and confirm a teammate gets the approval prompt on first claude launch.
  • Import existing Claude Desktop servers into Claude Code with claude mcp add-from-claude-desktop instead of re-adding them by hand.