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) and call (invoke a tool with JSON arguments).
  • A macOS-native --copy flag that pipes the result through pbcopy.
  • Clean failure modes: a friendly message when the server is down, and fast rejection of malformed JSON.
  • A pytest suite that runs the real client functions against an in-memory server — no network, no running service required.
  • A make control panel whose targets talk to the live server.

Prerequisites

  • macOS (any recent version). pbcopy/pbpaste ship with the OS.
  • 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.
  • Comfort with async/await. FastMCP’s client API is asynchronous, so the client functions are coroutines driven by asyncio.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 via make deploy, or run in the foreground from that project with make 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 environment uv creates. 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_Store is 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

  • fastmcp provides the Client class used to connect to the server — the same package powers both servers and clients.
  • pytest-asyncio lets pytest run the async client functions directly. FastMCP’s client API is coroutine-based, so this is required to test it.
  • --no-workspace keeps 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 why DEFAULT_URL ends in /mcp/.
  • --url defaults to $MCP_URL then the built-in default, so you can point the client at a different host or port without editing code.
  • list_capabilities calls list_tools() and list_resources() — the two discovery calls every client makes to learn what a server offers.
  • call_tool returns result.data, the deserialized return value. FastMCP also exposes raw content blocks, but .data is what you want for typed returns.
  • format_result returns 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_clipboard shells out to pbcopy, feeding the text on stdin. This is the macOS-specific touch: slugify output 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

  • stub is a minimal FastMCP server that mirrors the two tools of the real macmcp server. Because Client(stub) uses the in-memory transport, the tests drive the actual call_tool function 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 --copy and pbcopy behavior is intentionally not tested here, so the suite never clobbers your real clipboard.
  • asyncio_mode = auto in pytest.ini lets the async def tests 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 := 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.
  • 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_URL support.
  • list, demo, copy-demo are the live targets; they require the server to be running. copy-demo is the full Mac experience — call a tool and land its output on the clipboard.
  • test is 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 serve or make deploy) and confirm the port with lsof -iTCP:8000 -sTCP:LISTEN.
  • Invalid JSON arguments (exit code 2). The call argument 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. --copy only runs on a successful call. If the call failed you will see the error instead; fix that first. pbcopy is part of macOS, so it needs no installation.
  • Wrong host or port. Override it: make list URL=http://127.0.0.1:9000/mcp/ or MCP_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:

  • uv manages the interpreter, dependencies, and every run command.
  • client.py connects over HTTP by URL, discovers tools and resources, and calls them — with distinct exit codes for bad JSON (2) and an unreachable server (3).
  • --copy uses pbcopy to put results on the macOS clipboard.
  • The tests exercise the real client functions in-memory, so they pass with no server running.
  • make ties it together: test offline, and list/demo/copy-demo against the live server.

Next improvements

  • Add a read subcommand that fetches a resource (e.g. server://info) and prints it.
  • Support bearer-token auth to match a secured HTTP server (Client accepts an auth argument).
  • Add a --json output mode so the CLI can be piped into jq.
  • Turn the client into an installable uv tool so macmcp call … works from anywhere.