Guide to build and run a minimal FastMCP MCP server over STDIO, exposing example tools, structured errors, logging, and using the MCP Inspector for local testing.
In this lesson you’ll build a minimal MCP (Model Communication Protocol) server using the FastMCP Python library and run it locally with the STDIO transport. The guide shows how to:
Create a Python virtual environment
Expose simple tools: add, divide, and long_process
Return structured errors via a custom MCPError exception
Run and test the server locally with the MCP Inspector
Add logging to capture server activity
Prerequisite: Python 3.11+ (examples tested with Python 3.11).Table of contents
Create a project and a virtual environment. This example uses the uv helper shown in this guide, but you can substitute with python -m venv .venv if preferred.
# Initialize project and venv (using uv as shown in this guide)uv init .uv venvsource .venv/bin/activate
Replace main.py with a first MCP server that exposes a simple add tool:
# main.pyfrom mcp.server.fastmcp import FastMCPmcp = FastMCP("add_integers")@mcp.tool()def add(a: int, b: int) -> int: """ Add two integers and return the sum. Args: a: First integer b: Second integer Returns: The sum of a and b. """ return a + bif __name__ == "__main__": mcp.run(transport="stdio")
Run the development server (this opens the Inspector and prints a session token):
mcp dev main.py
The server prints a session token and provides a local MCP Inspector UI (by default at http://localhost:6274). Open the Inspector and authenticate by appending the query parameter shown in the console, for example:http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=<token>#resources(When copying, replace <token> with the value printed in your console.)Using the Inspector you can list tools, view tool docstrings (used as descriptions), and call tools. For example:
Introduce a small MCPError exception and add a divide tool that returns structured errors to callers:
# main.pyfrom mcp.server.fastmcp import FastMCPmcp = FastMCP("add_integers")class MCPError(Exception): def __init__(self, code: int, message: str): self.code = code self.message = message super().__init__(f"[{code}] {message}")@mcp.tool()def add(a: int, b: int) -> int: """Add two integers and return the sum.""" return a + b@mcp.tool()def divide(a: int, b: int) -> float: """ Divides two integers. Args: a: The numerator. b: The denominator. Returns: The result of the division. """ if b == 0: raise MCPError(code=400, message="Division by zero is not allowed.") return a / bif __name__ == "__main__": mcp.run(transport="stdio")
Run mcp dev main.py again. The Inspector now shows both add and divide. Example responses:
Successful call divide(24, 2):
{ "result": 12}
Error case divide(12, 0) — the Inspector and client will show the structured error message similar to:
Error executing tool divide: [400] Division by zero is not allowed.
Validation errors (e.g., passing floats where integers are required) are surfaced by pydantic-style validation messages that indicate the type mismatch and provide guidance.
Some tools need to perform long-running tasks. Add long_process to simulate a multi-step job and show how STDIO behaves with longer processing times.Warning: writing arbitrary text to stdout while using the STDIO transport can corrupt the MCP message stream. Use logging or the library’s streaming/event APIs for progress updates.
Avoid printing arbitrary text to stdout when using the STDIO transport — it can interfere with the MCP protocol and break communication. Use logging or FastMCP’s streaming features for progress reporting.
Example long_process that uses logging (preferred):
# main.py (append or modify as needed)import timeimport logginglogger = logging.getLogger(__name__)@mcp.tool()def long_process(steps: int) -> str: """ Simulates a long-running process by iterating steps. """ for i in range(steps): # Use logging instead of printing to stdout when using STDIO transport. logger.info("Processing step %s of %s", i + 1, steps) time.sleep(0.1) return "Process complete!"
Invoke long_process(10) from the Inspector to see how the server performs a short job. Increasing steps (e.g., long_process(100)) may trigger client or server timeouts depending on configured timeouts; timeouts are configurable on the server side.
STDIO is convenient for local development and tooling. FastMCP and the MCP Inspector also support other transports (for example, SSE or HTTP) when you need to expose your MCP server to other processes or to a network.
Enable Python logging to capture server activity in a file and to emit progress or input/output info from tools.Top-level logging setup:
# main.py (top-level additions)import logginglogging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", filename="mcp_server.log", filemode="a" # append on each run ('w' to overwrite))logger = logging.getLogger(__name__)
Log inputs and outputs within your tools, for example:
@mcp.tool()def add(a: int, b: int) -> int: """Add two integers and return the sum.""" logger.info("Adding %s and %s", a, b) result = a + b logger.info("Result is %s", result) return result
After invoking tools via the Inspector, mcp_server.log will include entries similar to:
2025-07-09 00:25:04,183 - mcp.server.lowlevel.server - INFO - Processing request of type ListToolsRequest2025-07-09 00:25:12,229 - mcp.server.lowlevel.server - INFO - Processing request of type CallToolRequest2025-07-09 00:25:12,229 - main - INFO - Adding 5 and 42025-07-09 00:25:12,229 - main - INFO - Result is 9
# main.pyfrom mcp.server.fastmcp import FastMCPimport timeimport loggingmcp = FastMCP("add_integers")class MCPError(Exception): def __init__(self, code: int, message: str): self.code = code self.message = message super().__init__(f"[{code}] {message}")logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", filename="mcp_server.log", filemode="a")logger = logging.getLogger(__name__)@mcp.tool()def add(a: int, b: int) -> int: """Add two integers and return the sum.""" logger.info("Adding %s and %s", a, b) result = a + b logger.info("Result is %s", result) return result@mcp.tool()def divide(a: int, b: int) -> float: """ Divides two integers. Args: a: The numerator. b: The denominator. Returns: The result of the division. """ if b == 0: raise MCPError(code=400, message="Division by zero is not allowed.") return a / b@mcp.tool()def long_process(steps: int) -> str: """Simulates a long-running process.""" for i in range(steps): logger.info("Processing step %s of %s", i + 1, steps) time.sleep(0.1) return "Process complete!"if __name__ == "__main__": mcp.run(transport="stdio")
Used docstrings for tool descriptions and argument schemas
Implemented structured errors with MCPError
Simulated long-running tasks and accounted for timeouts
Added logging to persist server events to a logfile
Ran and inspected the server locally via the MCP Inspector (STDIO transport)
You can now extend tools to access databases, call LLMs, perform async tasks, or expose your MCP server over other transports (HTTP/SSE) for remote clients.Links and references
FastMCP / MCP docs (check your installed package for local docs)