A Model Context Protocol (MCP) server exposes tools, resources, and prompts; an MCP client is what connects to that server, discovers those capabilities, and calls them. Clients are usually embedded in an AI application (Claude Desktop, Claude Code, or your own agent), but writing a standalone client is the clearest way to understand the protocol and to script, test, or debug a server.
In this tutorial you will build a command-line MCP client with
FastMCP. It connects to any MCP server over stdio,
prints the server’s advertised tools, resources, and prompts, and calls a tool
with JSON arguments. You will run it against a small demo server, then cover it
with pytest using FastMCP’s in-memory transport — no subprocess required.
This article is a companion to Build an MCP Server with FastMCP and Python; the client here can talk to the server built there, but it is fully self-contained.
Prerequisites
- Python 3.10 or newer (FastMCP requires 3.10+). Check with
python3 --version. - uv 0.5 or newer — the package and project manager used throughout this
tutorial. Install it from docs.astral.sh/uv,
then verify with
uv --version. - Comfort with
async/await. FastMCP’s client API is asynchronous, so the client functions are coroutines driven byasyncio.run. - No running server is required — this project ships a small demo server to connect to.
We use uv (not pip) for every dependency and run step. If you have not used
uv before, the only commands you need appear inline in each step.
Step 1: Scaffold the project and lock down hygiene
Create the project workspace and a .gitignore before any other files, so
that generated virtual environments and caches are never committed.
Create the files
mkdir -p fastmcp-notes-client
cd fastmcp-notes-client
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
__pycache__/and*.py[cod]keep compiled bytecode out of version control — it is regenerated on every run and is machine-specific..venv/is the virtual environmentuvcreates. It is large and fully reproducible from the lockfile, so it should never be committed..pytest_cache/is written bypytestbetween runs;.ruff_cache/and.mypy_cache/only appear if you add those tools later, but ignoring them now prevents accidental commits..DS_Store/*.logare common local artifacts. 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. This generates a pyproject.toml
and a pinned uv.lock.
Create the file
uv init --name fastmcp-notes-client --python 3.10
uv init creates pyproject.toml, a sample main.py (which you will replace),
and a README.md. Delete the sample file you will not use:
rm -f main.py
Add the code: pyproject.toml (generated, shown for reference)
[project]
name = "fastmcp-notes-client"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10"
dependencies = []
uv init creates an application-style project, so there is no [build-system]
block — you do not need one to run the client.
Detailed breakdown
requires-python = ">=3.10"matches FastMCP’s minimum and is enforced byuvwhen it resolves dependencies, so contributors on older Pythons fail fast.dependencies = []is empty for now; the next step fills it viauv addrather than hand-editing, which keepspyproject.tomlanduv.lockin sync.readme = "README.md"points at the fileuv initgenerated; the rest of the layout (tests/,client.py,demo_server.py) you add by hand.
Step 3: Add the FastMCP dependency
FastMCP provides both the server framework and the client used here.
Install the dependency
uv add fastmcp
Detailed breakdown
uv add fastmcppulls in FastMCP and its transitive dependencies (including the officialmcpSDK andpydantic). After it runs,fastmcpappears under[project].dependenciesinpyproject.toml.- The same package supplies
fastmcp.Client, so a client project needs no extra dependency beyondfastmcpitself. - Because
uvrecords exact versions inuv.lock, anyone who runsuv syncgets the identical dependency set.
Step 4: Add a demo server to connect to
A client needs a server to talk to. This minimal server gives the client real tools and a resource to discover, and keeps the tutorial self-contained.
Create the file
touch demo_server.py
Add the code: demo_server.py
"""A minimal MCP server used as a target for the client in this tutorial."""
from fastmcp import FastMCP
mcp = FastMCP("Demo Server")
@mcp.tool
def add(a: int, b: int) -> int:
"""Add two integers and return the sum."""
return a + b
@mcp.tool
def greet(name: str) -> str:
"""Return a friendly greeting for the given name."""
return f"Hello, {name}!"
@mcp.resource("config://version")
def version() -> str:
"""Expose the demo server's version as a readable resource."""
return "1.0.0"
if __name__ == "__main__":
# Run over stdio so a client can launch this file as a subprocess.
mcp.run()
Detailed breakdown
mcp = FastMCP("Demo Server")creates the server. The name is what the client will see during discovery.addandgreetare two tools with typed signatures; FastMCP turns the type hints into the JSON schema the client receives, so the client knows each tool’s argument names and types.@mcp.resource("config://version")registers a static resource. The client lists it by itsconfig://versionURI in Step 6.mcp.run()starts the server over stdio. This matters for the client: when the client is given the pathdemo_server.py, FastMCP launchespython demo_server.pyas a subprocess and speaks MCP over its stdin/stdout.
Step 5: Write the MCP client
This is the core of the tutorial. The client exposes two reusable async functions (one to inspect a server, one to call a tool) and a small CLI on top of them.
Create the file
touch client.py
Add the code: client.py
"""A command-line MCP client built with FastMCP."""
import argparse
import asyncio
import json
from typing import Any
from fastmcp import Client
async def inspect_server(target: Any) -> dict[str, list[str]]:
"""Connect to an MCP server and return its advertised capabilities.
`target` is anything FastMCP's Client understands: a path to a server
`.py` file (launched over stdio), an http(s) URL, or an in-process
FastMCP server object (used by the tests).
"""
async with Client(target) as client:
await client.ping()
tools = await client.list_tools()
resources = await client.list_resources()
prompts = await client.list_prompts()
return {
"tools": [tool.name for tool in tools],
"resources": [str(resource.uri) for resource in resources],
"prompts": [prompt.name for prompt in prompts],
}
async def call_tool(target: Any, name: str, arguments: dict[str, Any]) -> Any:
"""Connect to a server, invoke a tool by name, and return its result."""
async with Client(target) as client:
result = await client.call_tool(name, arguments)
return result.data
def main() -> None:
parser = argparse.ArgumentParser(description="A minimal MCP client.")
parser.add_argument("target", help="Server .py file path or server URL")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("inspect", help="List the server's tools, resources, prompts")
call_parser = sub.add_parser("call", help="Call a tool by name")
call_parser.add_argument("tool", help="Name of the tool to call")
call_parser.add_argument(
"--args", default="{}", help="Tool arguments as a JSON object"
)
args = parser.parse_args()
if args.command == "inspect":
capabilities = asyncio.run(inspect_server(args.target))
print(json.dumps(capabilities, indent=2))
elif args.command == "call":
arguments = json.loads(args.args)
output = asyncio.run(call_tool(args.target, args.tool, arguments))
print(json.dumps(output, indent=2, default=str))
if __name__ == "__main__":
main()
Detailed breakdown
Client(target)is FastMCP’s high-level client. It infers the transport fromtarget: a.pypath launches the file over stdio, anhttp://URL uses the streamable-HTTP transport, and aFastMCPobject connects in-process. The same code therefore works against a real subprocess in production and an in-memory server in tests.async with Client(target) as client:opens the connection (and, for stdio, spawns the server subprocess) on entry and tears it down on exit. All calls must happen inside this block.client.ping()performs a protocol round-trip to confirm the server is alive before issuing real requests — a cheap, clear failure point if the server cannot start.list_tools/list_resources/list_promptsare the discovery calls. Each returns typed objects; the function extracts the human-readable identifier (.name, or.urifor resources) into plain lists for printing.call_tool(name, arguments)invokes a tool. The returned object carries the result in.dataas the deserialized Python value (anintfromadd, astrfromgreet).- The
mainCLI wraps the two coroutines withargparse.inspecttakes only a target;calladds a tool name and a--argsJSON string, parsed withjson.loads.asyncio.run(...)drives each coroutine to completion. Note that every CLItargetis a string path or URL — the in-process object form is used only from Python (the tests).
Step 6: Run the client against the demo server
With both files in place, point the client at demo_server.py.
Note: the launched server prints a FastMCP startup banner and an
INFO ... transport 'stdio'line to stderr before the client’s output appears. That is the server announcing itself, not an error — the JSON below is the client’s stdout. Append2>/dev/nullto the commands to hide it.
Inspect the server’s capabilities
uv run python client.py demo_server.py inspect
Expected output (on stdout):
{
"tools": [
"add",
"greet"
],
"resources": [
"config://version"
],
"prompts": []
}
Call a tool
uv run python client.py demo_server.py call add --args '{"a": 2, "b": 3}'
Expected output:
5
Detailed breakdown
uv run python client.py demo_server.py inspectruns the client inside the project environment. The client receives"demo_server.py", infers stdio, launchespython demo_server.pyas a subprocess, and prints the discovered capabilities. The emptypromptslist is expected — the demo server defines none.call add --args '{"a": 2, "b": 3}'parses the JSON object into a dict and passes it tocall_tool, which invokesadd(a=2, b=3)on the server and prints the.dataresult,5.- Because the client infers transport from the target, the very same commands work
against an HTTP server by passing a URL instead of a file path — for example
uv run python client.py http://localhost:8000/mcp inspect.
Step 7: Add the test dependencies
Cover the client with automated tests. FastMCP’s in-memory transport lets the client connect directly to a server object (no subprocess, no network), so tests are fast and deterministic.
Install the dev dependencies
uv add --dev pytest pytest-asyncio
Detailed breakdown
pytestis the test runner. Tests live in atests/directory and use atest_prefix sopytestdiscovers them automatically.pytest-asynciois required because the client functions are coroutines; the test functions areasyncand need an async-aware runner.--devrecords both under[dependency-groups].dev, keeping them out of the runtime dependency set.
Step 8: Configure pytest for async tests
Tell pytest where the source lives and enable automatic async handling.
Create the file
touch pytest.ini
Add the code: pytest.ini
[pytest]
pythonpath = .
asyncio_mode = auto
testpaths = tests
Detailed breakdown
pythonpath = .adds the project root tosys.path, soimport clientandimport demo_serverresolve when tests run fromtests/.asyncio_mode = autoletspytest-asynciotreat anyasync def test_...function as a coroutine test automatically, removing per-test decorators.testpaths = testsscopes discovery totests/, sopytestdoes not try to importclient.pyordemo_server.pyas test modules.
Step 9: Write the tests
Pass the in-memory demo_server.mcp object straight to the client functions.
Because Client(target) accepts a server object, the same inspect_server and
call_tool functions run without launching a subprocess.
Create the files
mkdir -p tests
touch tests/__init__.py
touch tests/test_client.py
Add the code: tests/test_client.py
"""Tests for the MCP client using FastMCP's in-memory transport."""
import demo_server
from client import call_tool, inspect_server
async def test_inspect_lists_capabilities():
capabilities = await inspect_server(demo_server.mcp)
assert set(capabilities["tools"]) == {"add", "greet"}
assert capabilities["resources"] == ["config://version"]
assert capabilities["prompts"] == []
async def test_call_add_tool():
result = await call_tool(demo_server.mcp, "add", {"a": 2, "b": 3})
assert result == 5
async def test_call_greet_tool():
result = await call_tool(demo_server.mcp, "greet", {"name": "Ada"})
assert result == "Hello, Ada!"
Detailed breakdown
inspect_server(demo_server.mcp)passes the FastMCP server object as the target. FastMCP’sClientdetects this and uses its in-memory transport, so the test exercises the full discovery path with no subprocess and no I/O.set(capabilities["tools"]) == {"add", "greet"}compares as a set because tool ordering is not guaranteed; the resource and prompt assertions check the exact lists the demo server advertises.call_tool(demo_server.mcp, "add", {...})verifies the tool-invocation path end to end and asserts on the deserialized.datavalue (5), confirming the client returns native Python types rather than raw protocol payloads.test_call_greet_toolcovers a string-returning tool, proving the samecall_toolhelper works regardless of the tool’s return type.
Run the tests
uv run pytest -v
You should see three passing tests. If pytest reports “no tests ran”, confirm
the files live in tests/ and start with test_.
Step 10: Add a Makefile with a help-first default
A Makefile gives contributors a discoverable set of commands. Running plain
make must print a help screen that lists every target.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help install inspect call test
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
inspect: ## Inspect the demo server's capabilities
uv run python client.py demo_server.py inspect
call: ## Call the demo server's add tool (2 + 3)
uv run python client.py demo_server.py call add --args '{"a": 2, "b": 3}'
test: ## Run the test suite
uv run pytest -v
Detailed breakdown
.DEFAULT_GOAL := helpmakes baremakerun thehelptarget, satisfying the requirement thatmakewith no arguments prints a help screen.- The
helptarget scans theMakefilefor targets annotated with a## descriptioncomment and prints them in aligned, colored columns. Adding a new annotated target makes it appear automatically. .PHONYdeclares these are commands, not files, somakealways runs them.inspect/call/testwrap the client and test commands from earlier steps so contributors can run them without remembering the exact invocation. Every command goes throughuv runto stay inside the managed environment.
Validate the default target
make
Confirm the output lists help, install, inspect, call, and test, each
with its description.
Troubleshooting
ModuleNotFoundError: No module named 'fastmcp'— you ranpythonoutside the managed environment. Always prefix commands withuv run, or runuv syncfirst.- The client hangs on
inspect— the target server failed to start. Run the server directly (uv run python demo_server.py) to confirm it imports, and make sure the path you passed to the client is correct. json.decoder.JSONDecodeErroroncall— the--argsvalue is not valid JSON. Quote the whole object and use double quotes inside it, e.g.--args '{"a": 1}'.pytestcollects zero tests — async tests are being skipped. Confirmpytest.inihasasyncio_mode = autoand thatpytest-asynciois installed.ImportErrorforclientordemo_serverin tests —pythonpath = .is missing frompytest.ini, so the project root is not onsys.path.
Recap
You built a working MCP client with FastMCP and Python:
- Scaffolded a
uv-managed project with hygiene (.gitignore) in place first. - Added a small demo server to connect to, keeping the project self-contained.
- Wrote reusable
inspect_serverandcall_toolcoroutines on top offastmcp.Client, plus anargparseCLI. - Ran the client against the demo server over stdio to discover capabilities and invoke a tool.
- Covered the client with
pytestusing FastMCP’s in-memory transport. - Added a help-first
Makefileso the workflow is self-documenting.
Next improvements
- Read resources and render prompts by adding
client.read_resource(uri)andclient.get_prompt(name, args)helpers and CLI subcommands. - Target remote servers by passing an
http(s)://URL; add--headerflags to send bearer tokens for authenticated servers. - Handle multiple servers by loading an MCP config (a JSON map of named servers) and letting the user select one by name.
- Add structured error handling so a failed tool call prints a clean message and a non-zero exit code instead of a traceback.