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 by asyncio.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 environment uv creates. It is large and fully reproducible from the lockfile, so it should never be committed.
  • .pytest_cache/ is written by pytest between runs; .ruff_cache/ and .mypy_cache/ only appear if you add those tools later, but ignoring them now prevents accidental commits.
  • .DS_Store / *.log are 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 by uv when it resolves dependencies, so contributors on older Pythons fail fast.
  • dependencies = [] is empty for now; the next step fills it via uv add rather than hand-editing, which keeps pyproject.toml and uv.lock in sync.
  • readme = "README.md" points at the file uv init generated; 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 fastmcp pulls in FastMCP and its transitive dependencies (including the official mcp SDK and pydantic). After it runs, fastmcp appears under [project].dependencies in pyproject.toml.
  • The same package supplies fastmcp.Client, so a client project needs no extra dependency beyond fastmcp itself.
  • Because uv records exact versions in uv.lock, anyone who runs uv sync gets 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.
  • add and greet are 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 its config://version URI in Step 6.
  • mcp.run() starts the server over stdio. This matters for the client: when the client is given the path demo_server.py, FastMCP launches python demo_server.py as 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 from target: a .py path launches the file over stdio, an http:// URL uses the streamable-HTTP transport, and a FastMCP object 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_prompts are the discovery calls. Each returns typed objects; the function extracts the human-readable identifier (.name, or .uri for resources) into plain lists for printing.
  • call_tool(name, arguments) invokes a tool. The returned object carries the result in .data as the deserialized Python value (an int from add, a str from greet).
  • The main CLI wraps the two coroutines with argparse. inspect takes only a target; call adds a tool name and a --args JSON string, parsed with json.loads. asyncio.run(...) drives each coroutine to completion. Note that every CLI target is 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. Append 2>/dev/null to 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 inspect runs the client inside the project environment. The client receives "demo_server.py", infers stdio, launches python demo_server.py as a subprocess, and prints the discovered capabilities. The empty prompts list is expected — the demo server defines none.
  • call add --args '{"a": 2, "b": 3}' parses the JSON object into a dict and passes it to call_tool, which invokes add(a=2, b=3) on the server and prints the .data result, 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

  • pytest is the test runner. Tests live in a tests/ directory and use a test_ prefix so pytest discovers them automatically.
  • pytest-asyncio is required because the client functions are coroutines; the test functions are async and need an async-aware runner.
  • --dev records 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 to sys.path, so import client and import demo_server resolve when tests run from tests/.
  • asyncio_mode = auto lets pytest-asyncio treat any async def test_... function as a coroutine test automatically, removing per-test decorators.
  • testpaths = tests scopes discovery to tests/, so pytest does not try to import client.py or demo_server.py as 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’s Client detects 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 .data value (5), confirming the client returns native Python types rather than raw protocol payloads.
  • test_call_greet_tool covers a string-returning tool, proving the same call_tool helper 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 := help makes bare make run the help target, satisfying the requirement that make with no arguments prints a help screen.
  • The help target scans the Makefile for targets annotated with a ## description comment and prints them in aligned, colored columns. Adding a new annotated target makes it appear automatically.
  • .PHONY declares these are commands, not files, so make always runs them.
  • inspect / call / test wrap the client and test commands from earlier steps so contributors can run them without remembering the exact invocation. Every command goes through uv run to 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 ran python outside the managed environment. Always prefix commands with uv run, or run uv sync first.
  • 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.JSONDecodeError on call — the --args value is not valid JSON. Quote the whole object and use double quotes inside it, e.g. --args '{"a": 1}'.
  • pytest collects zero tests — async tests are being skipped. Confirm pytest.ini has asyncio_mode = auto and that pytest-asyncio is installed.
  • ImportError for client or demo_server in testspythonpath = . is missing from pytest.ini, so the project root is not on sys.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_server and call_tool coroutines on top of fastmcp.Client, plus an argparse CLI.
  • Ran the client against the demo server over stdio to discover capabilities and invoke a tool.
  • Covered the client with pytest using FastMCP’s in-memory transport.
  • Added a help-first Makefile so the workflow is self-documenting.

Next improvements

  • Read resources and render prompts by adding client.read_resource(uri) and client.get_prompt(name, args) helpers and CLI subcommands.
  • Target remote servers by passing an http(s):// URL; add --header flags 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.