In the companion article Create and Deploy a FastMCP Server on macOS you built
a FastMCP server and deployed it as a launchd service
listening on http://127.0.0.1:8000/mcp/. This tutorial builds the other half:
a command-line MCP client that connects to that running server over HTTP,
lists what it offers, and calls its tools.
Because you are on a Mac, the client does one extra thing that a generic client
does not: with --copy it drops a tool’s result straight onto the macOS
clipboard via pbcopy, so the output of slugify is ready to paste into your
editor. Everything is managed with uv and make.
This is a different client from the stdio, in-memory demo in Build an MCP Client with FastMCP and Python. Here the whole point is talking to a real, running, deployed server over the network.
What you will build
- A CLI (
client.py) that connects to an MCP server by URL and offers two subcommands:list(show tools and resources) andcall(invoke a tool with JSON arguments). - A macOS-native
--copyflag that pipes the result throughpbcopy. - Clean failure modes: a friendly message when the server is down, and fast rejection of malformed JSON.
- A
pytestsuite that runs the real client functions against an in-memory server — no network, no running service required. - A
makecontrol panel whose targets talk to the live server.
Prerequisites
- macOS (any recent version).
pbcopy/pbpasteship with the OS. - 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. - Comfort with
async/await. FastMCP’s client API is asynchronous, so the client functions are coroutines driven byasyncio.run. - The macmcp server from the companion article. For the live steps you need
it reachable at
http://127.0.0.1:8000/mcp/— either deployed viamake deploy, or run in the foreground from that project withmake serve. The tests in this article do not need it.
We use uv (not pip) for every dependency and run step, and make for every
task.
Step 1: Scaffold the project and lock down hygiene
Create the project workspace and a .gitignore before any other files, so
virtual environments, caches, and macOS’s .DS_Store files are never committed.
Create the files
mkdir -p connect-fastmcp-server-macos
cd connect-fastmcp-server-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
# uv
.uv/
# Tooling caches
.pytest_cache/
.ruff_cache/
.mypy_cache/
# OS / editor noise
.DS_Store
*.log
Detailed breakdown
.venv/is the virtual environmentuvcreates. It is large and fully reproducible from the lockfile, so it should never be committed..pytest_cache/and the other tooling caches are regenerated on each run..DS_Storeis the metadata file Finder drops into directories on macOS; ignoring it now keeps it out of every future commit.- 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, add FastMCP for the client, and add
the test tools as dev dependencies.
Create the files
uv init --name macmcp-client --no-workspace
rm -f main.py hello.py # remove the sample file uv scaffolds
uv add fastmcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
After the commands above, pyproject.toml looks like this (versions may be
newer):
[project]
name = "macmcp-client"
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
fastmcpprovides theClientclass used to connect to the server — the same package powers both servers and clients.pytest-asyncioletspytestrun the async client functions directly. FastMCP’s client API is coroutine-based, so this is required to test it.--no-workspacekeeps this a standalone project even if you scaffold it inside a larger repo.
Step 3: Write the client
The client is a small argparse CLI. The interesting design choice is that the
functions that do real work (list_capabilities, call_tool, format_result)
take a FastMCP Client or plain data as arguments. That keeps them testable
in isolation (Step 4) while run() wires them to a live HTTP connection.
Create the file
touch client.py
Add the code: client.py
"""A command-line MCP client for the macmcp HTTP server, tuned for macOS.
Connects to a running FastMCP server over HTTP, lists its capabilities, and
calls a tool. With --copy the result is placed on the macOS clipboard via
`pbcopy`, so a tool's output flows straight into whatever you paste next.
"""
import argparse
import asyncio
import json
import os
import subprocess
import sys
from fastmcp import Client
DEFAULT_URL = "http://127.0.0.1:8000/mcp/"
def format_result(data: object) -> str:
"""Render a tool result as text suitable for printing or the clipboard."""
if isinstance(data, str):
return data
return json.dumps(data, indent=2, sort_keys=True)
def copy_to_clipboard(text: str) -> None:
"""Send text to the macOS clipboard using pbcopy."""
subprocess.run(["pbcopy"], input=text, text=True, check=True)
async def list_capabilities(client: Client) -> None:
"""Print the tools and resources the server advertises."""
tools = await client.list_tools()
resources = await client.list_resources()
print("Tools:")
for tool in tools:
print(f" {tool.name} - {tool.description or ''}".rstrip())
print("Resources:")
for resource in resources:
print(f" {resource.uri}")
async def call_tool(client: Client, name: str, arguments: dict) -> object:
"""Call one tool and return its deserialized result."""
result = await client.call_tool(name, arguments)
return result.data
async def run(args: argparse.Namespace) -> int:
# Parse JSON arguments before connecting so a typo fails fast.
if args.command == "call":
try:
arguments = json.loads(args.json)
except json.JSONDecodeError as exc:
print(f"Invalid JSON arguments: {exc}", file=sys.stderr)
return 2
try:
async with Client(args.url) as client:
if args.command == "list":
await list_capabilities(client)
return 0
if args.command == "call":
data = await call_tool(client, args.tool, arguments)
text = format_result(data)
print(text)
if args.copy:
copy_to_clipboard(text)
print("(copied to clipboard)", file=sys.stderr)
return 0
except (ConnectionError, RuntimeError) as exc:
print(f"Could not reach the server at {args.url}: {exc}", file=sys.stderr)
print("Is it running? Try: make serve (in the server project)", file=sys.stderr)
return 3
return 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="MCP client for the macmcp server")
parser.add_argument(
"--url",
default=os.environ.get("MCP_URL", DEFAULT_URL),
help=f"server URL (default: {DEFAULT_URL} or $MCP_URL)",
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list", help="list the server's tools and resources")
call = sub.add_parser("call", help="call a tool with JSON arguments")
call.add_argument("tool", help="tool name, e.g. slugify")
call.add_argument("json", help='JSON object of arguments, e.g. \'{"text": "Hi"}\'')
call.add_argument(
"--copy",
action="store_true",
help="copy the result to the macOS clipboard (pbcopy)",
)
return parser
def main() -> None:
args = build_parser().parse_args()
raise SystemExit(asyncio.run(run(args)))
if __name__ == "__main__":
main()
Detailed breakdown
Client(args.url)— passing a URL (not a server object) makes FastMCP use its HTTP client. The default endpoint for the HTTP transport is/mcp/(with the trailing slash), which is whyDEFAULT_URLends in/mcp/.--urldefaults to$MCP_URLthen the built-in default, so you can point the client at a different host or port without editing code.list_capabilitiescallslist_tools()andlist_resources()— the two discovery calls every client makes to learn what a server offers.call_toolreturnsresult.data, the deserialized return value. FastMCP also exposes raw content blocks, but.datais what you want for typed returns.format_resultreturns strings as-is and pretty-prints everything else as sorted JSON. It is a pure function, which makes it trivial to unit test.copy_to_clipboardshells out topbcopy, feeding the text on stdin. This is the macOS-specific touch:slugifyoutput lands on the clipboard, ready to paste.- The failure handling in
run()is deliberate. JSON is parsed before connecting, so a typo returns exit code 2 without touching the network. A connection failure is caught and turned into a friendly message plus exit code 3, instead of dumping a traceback. Distinct exit codes let scripts tell the difference.
Step 4: Test the client in-memory
FastMCP’s Client can connect directly to a server object, so you can test
the real client functions against a tiny in-process stand-in for the deployed
server — no network, no running service.
Create the files
mkdir -p tests
touch tests/__init__.py tests/test_client.py pytest.ini
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
Add the code: tests/test_client.py
"""Test the client against an in-memory stand-in for the macmcp server.
FastMCP's Client can connect directly to a server object, so these tests
exercise the real client functions with no network and no running service.
"""
import pytest
from fastmcp import Client, FastMCP
from client import call_tool, format_result
# A tiny mirror of the deployed macmcp server, used only for tests.
stub = FastMCP("macmcp-stub")
@stub.tool
def word_count(text: str) -> int:
return len(text.split())
@stub.tool
def slugify(text: str) -> str:
return "-".join(text.lower().split())
@pytest.mark.asyncio
async def test_call_tool_returns_data():
async with Client(stub) as client:
assert await call_tool(client, "word_count", {"text": "a b c"}) == 3
@pytest.mark.asyncio
async def test_call_tool_slugify():
async with Client(stub) as client:
assert await call_tool(client, "slugify", {"text": "Hello Mac"}) == "hello-mac"
def test_format_result_passthrough_for_strings():
assert format_result("hello-mac") == "hello-mac"
def test_format_result_pretty_prints_objects():
assert format_result({"b": 1, "a": 2}) == '{\n "a": 2,\n "b": 1\n}'
Detailed breakdown
stubis a minimal FastMCP server that mirrors the two tools of the realmacmcpserver. BecauseClient(stub)uses the in-memory transport, the tests drive the actualcall_toolfunction without a socket in sight.test_call_tool_*prove the client correctly sends arguments and unwraps the typed result — the same code path the CLI uses against the live server.test_format_result_*cover the pure formatter directly: strings pass through untouched, objects become sorted, indented JSON. The--copyandpbcopybehavior is intentionally not tested here, so the suite never clobbers your real clipboard.asyncio_mode = autoinpytest.inilets theasync deftests run without per-test event-loop boilerplate.
Run them:
uv run pytest -v
You should see four passing tests, with no server running.
Step 5: The Makefile — targets that talk to the live server
The Makefile drives both the offline test suite and the live client. Running
plain make prints a help screen listing every target.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
# Override on the command line, e.g. make list URL=http://127.0.0.1:9000/mcp/
URL ?= http://127.0.0.1:8000/mcp/
.PHONY: help install list demo copy-demo test 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
list: ## List the server's tools and resources
uv run python client.py --url $(URL) list
demo: ## Call slugify on a sample title
uv run python client.py --url $(URL) call slugify '{"text": "Deploy FastMCP on macOS!"}'
copy-demo: ## Call slugify and copy the result to the macOS clipboard
uv run python client.py --url $(URL) call slugify '{"text": "Deploy FastMCP on macOS!"}' --copy
test: ## Run the test suite
uv run pytest -v
clean: ## Remove caches
rm -rf .pytest_cache __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.URL ?= …makes the server URL overridable per invocation (make list URL=http://127.0.0.1:9000/mcp/) without editing the file, mirroring the client’s--url/$MCP_URLsupport.list,demo,copy-demoare the live targets; they require the server to be running.copy-demois the full Mac experience — call a tool and land its output on the clipboard.testis the only target that works with no server up.
Confirm the help screen:
make
Step 6: Run the client against the live server
Start the server (from the companion article), then drive it from this one.
In the server project, run it in the foreground:
# in projects/.../deploy-fastmcp-server-macos
make serve
(Or make deploy there to run it as a background launchd service — either way
it listens on http://127.0.0.1:8000/mcp/.)
Back in the client project, discover what the server offers:
make list
Expected output:
Tools:
word_count - Count the whitespace-separated words in a block of text.
slugify - Turn a title into a URL-safe, lowercase slug.
Resources:
server://info
Call a tool:
make demo
# deploy-fastmcp-on-macos
Now the macOS payoff — call it and put the result on the clipboard:
make copy-demo
You will see the slug printed and (copied to clipboard) on stderr. Confirm it
landed:
pbpaste
# deploy-fastmcp-on-macos
You can also call any tool directly with your own arguments:
uv run python client.py call word_count '{"text": "one two three four"}'
# 4
Troubleshooting
Could not reach the server at …(exit code 3). The server is not running or not on that URL. Start it in the server project (make serveormake deploy) and confirm the port withlsof -iTCP:8000 -sTCP:LISTEN.Invalid JSON arguments(exit code 2). Thecallargument must be a JSON object with double-quoted keys:'{"text": "hi"}', not'{text: hi}'. Wrap it in single quotes so your shell passes it through intact.- Nothing on the clipboard after
--copy.--copyonly runs on a successful call. If the call failed you will see the error instead; fix that first.pbcopyis part of macOS, so it needs no installation. - Wrong host or port. Override it:
make list URL=http://127.0.0.1:9000/mcp/orMCP_URL=http://host:port/mcp/ uv run python client.py list. Remember the trailing/mcp/.
Recap
You built a macOS command-line MCP client that connects to a live, deployed FastMCP server:
uvmanages the interpreter, dependencies, and every run command.client.pyconnects over HTTP by URL, discovers tools and resources, and calls them — with distinct exit codes for bad JSON (2) and an unreachable server (3).--copyusespbcopyto put results on the macOS clipboard.- The tests exercise the real client functions in-memory, so they pass with no server running.
maketies it together:testoffline, andlist/demo/copy-demoagainst the live server.
Next improvements
- Add a
readsubcommand that fetches a resource (e.g.server://info) and prints it. - Support bearer-token auth to match a secured HTTP server (
Clientaccepts anauthargument). - Add a
--jsonoutput mode so the CLI can be piped intojq. - Turn the client into an installable
uv toolsomacmcp call …works from anywhere.