← Back to Agentic AI map
Lesson 4.3 · Model Context Protocol (MCP)

Building a Simple MCP Server

Build the "current time" MCP server step by step with the Python SDK 2.x: one function, two decorators. Then test it three ways, send clear errors the model can use, and learn the one rule that breaks stdio servers.

mcp

What you will be able to do

  • Install the MCP Python SDK and check its version
  • Turn a normal Python function into an MCP tool
  • Explain how the docstring and type hints become the tool’s description and input schema
  • Test a server three ways: a client script, in-process, and the MCP Inspector
  • Send clear error messages with ToolError - and know why plain exceptions are hidden
  • Log safely: stderr, never stdout
  • Run the same server over HTTP

The idea, in plain English

In Lessons 4.1 and 4.2 you used a time server. Now you will build it yourself, one step at a time. It is short - but each line matters, and there are a few traps that cost beginners hours.

An MCP server in Python is a normal Python program. You write normal functions. You put a decorator on the ones the model may use. The SDK does the rest: it describes your tools to clients, checks the arguments, runs your function, and sends the answer back in MCP format.

We use the official MCP Python SDK, version 2.3.0 (pip install mcp). Version 2 changed some names, so if you follow other tutorials, check which version they use. Everything below was run, and every output is real.

Worked example: A get_current_time(timezone) server, tested with a client script, in-process, and with the MCP Inspector.

workflowFrom a Python function to an MCP toolstep 1 / 5

1 - You write a normal function

get_current_time(timezone: str = "UTC") -> str, with a docstring. Nothing about MCP yet - you could call it from any Python code.

argument
timezone: str = "UTC"
returns
str
docstring
yes
MCP code
none yet

What the SDK does with your function - from the decorator to the answer a client receives. Real values from our server.

Words you will see in this lesson

A few words used in this lesson.

Small dictionary
DecoratorThe @something line above a function. It changes or registers the function.
DocstringThe text in triple quotes at the start of a function. It describes what it does.
Type hinttimezone: str or -> str. It says what type a value should have.
Input schemaA JSON description of the arguments a tool accepts. The SDK makes it from the type hints.
stdout / stderrThe two output streams of a program: normal output, and error/log output.
In-processClient and server in the same Python program - no child process. Handy for tests.

Step 1 - set up

Create a virtual environment, as in Lesson 2.2, and install the SDK. Pin the major version, so a future version 3 does not break your code without warning. Then check what you got.

Install and check
$ python -m venv .venv && source .venv/bin/activate $ pip install "mcp>=2,<3" $ python -c "import importlib.metadata as m; print(m.version('mcp'))" 2.3.0

Step 2 - write the server

Start with the function you want to share. It is a normal Python function: give it a clear name, type hints for every argument and for the result, and a docstring that says what it does in one sentence.

Then add three things: create an MCPServer with a name, put @server.tool() above the function, and call server.run() at the bottom. That is the whole server.

The docstring and the names are not only for people. The model reads the tool’s name and description to decide whether to use it, and reads the input schema to know what to send. A vague docstring means the model guesses. "Return the current date and time in an IANA timezone, such as Asia/Kolkata" tells it exactly what kind of text to put in timezone.

Example 1 - time_server.py
from datetime import datetime from zoneinfo import ZoneInfo # timezones by name, built into Python 3.9+ from mcp.server.mcpserver import MCPServer # SDK 2.x (version 1 called this FastMCP) server = MCPServer("time-server") # the name clients will see @server.tool() # register the function below as an MCP tool def get_current_time(timezone: str = "UTC") -> str: """Return the current date and time in an IANA timezone, such as Asia/Kolkata.""" now = datetime.now(ZoneInfo(timezone)) # e.g. ZoneInfo("Asia/Kolkata") return now.strftime("%Y-%m-%d %H:%M:%S %Z") # e.g. "2026-10-09 18:59:49 IST" if __name__ == "__main__": server.run() # stdio: read requests on stdin, write answers on stdout

Watch out: Do not run this file by hand and wait for something to happen. With stdio, the server sits silently and waits for JSON on its input. A client starts it for you - that is the next step.

Step 3 - test it with a client script

A client script starts the server as a child process (StdioServerParameters says which command to run), connects, and uses it. This is exactly what a host app does.

Look at the output. We never wrote an input schema - the SDK built it from timezone: str = "UTC". The result has two forms: content, a list with one text item (what most hosts show the model), and structured_content, the same value as JSON. You get structured_content because the function says -> str.

Example 2 - client.py
import asyncio, sys from mcp import Client, StdioServerParameters async def main(): params = StdioServerParameters(command=sys.executable, args=["time_server.py"]) # how to start it async with Client(params) as client: # start + connect tools = await client.list_tools() for t in tools.tools: print("tool:", t.name, "|", t.description) print("input schema:", t.input_schema) result = await client.call_tool("get_current_time", {"timezone": "Asia/Kolkata"}) print("result:", result) asyncio.run(main())
Output
tool: get_current_time | Return the current date and time in an IANA timezone, such as Asia/Kolkata. input schema: {'type': 'object', 'properties': {'timezone': {'default': 'UTC', 'title': 'Timezone', 'type': 'string'}}, 'title': 'get_current_timeArguments'} result: meta={'io.modelcontextprotocol/serverInfo': {'name': 'time-server', 'version': ''}} content=[TextContent(type='text', text='2026-10-09 18:59:49 IST', annotations=None, meta=None)] structured_content={'result': '2026-10-09 18:59:49 IST'} is_error=False result_type='complete'

Step 4 - test it without a child process

For quick tests you can skip the child process: pass the server object itself to Client. The SDK connects them inside one Python program. It is fast, and it is perfect for automated tests with pytest.

Here we also see one more detail. This tool returns a plain dict with no exact type. The client received the dict as JSON text, but structured_content was None. With -> str (Step 3) it was filled. If you want reliable structured output, give the return value an exact type.

Example 3 - an in-process test
import asyncio from mcp import Client from mcp.server.mcpserver import MCPServer server = MCPServer("shape") @server.tool() def time_parts(timezone: str = "UTC") -> dict: """Return the time as separate parts.""" from datetime import datetime; from zoneinfo import ZoneInfo n = datetime.now(ZoneInfo(timezone)); return {"hour": n.hour, "minute": n.minute, "zone": timezone} async def main(): async with Client(server) as c: # in-process: handy for tests t = (await c.list_tools()).tools[0] print("outputSchema:", getattr(t, "output_schema", None)) r = await c.call_tool("time_parts", {"timezone": "Asia/Kolkata"}) print("text:", r.content[0].text.replace("\n", " "), "| structured:", r.structured_content) asyncio.run(main())
Output
outputSchema: None text: { "hour": 19, "minute": 2, "zone": "Asia/Kolkata" } | structured: None

Step 5 - test it with the MCP Inspector

The MCP Inspector is the official tool for testing servers. It needs Node.js and runs with npx - no install step. With --cli it works in the terminal: give it the command that starts your server, and a method.

Use it as the "is it my server or my agent?" test. If the Inspector can list and call your tool, the server is fine, and any problem is on the client side. Without --cli, the same command opens a web page where you can click through tools, resources and prompts.

The Inspector, from the command line
$ npx -y @modelcontextprotocol/inspector@latest --cli python time_server.py --method tools/list { "tools": [ { "name": "get_current_time", "description": "Return the current date and time in an IANA timezone, such as Asia/Kolkata.", "inputSchema": { ... "timezone": { "default": "UTC", "title": "Timezone", "type": "string" } ... }, "outputSchema": { ... "required": [ "result" ], "title": "get_current_timeOutput" } } ] } $ npx -y @modelcontextprotocol/inspector@latest --cli python time_server.py \ --method tools/call --tool-name get_current_time --tool-arg timezone=Asia/Kolkata { "content": [ { "type": "text", "text": "2026-10-09 19:02:32 IST" } ], "structuredContent": { "result": "2026-10-09 19:02:32 IST" }, "isError": false }

Step 6 - errors the model can understand

In Lesson 4.2, the timezone "Mars/Olympus" came back as "Error executing tool get_current_time". True, but useless: the model cannot tell what to fix. The full Python error was written to the server’s log - not sent to the client.

That is on purpose. A normal exception (ValueError, KeyError, ...) may contain private details - file paths, database names, secrets - so the SDK hides its message. When you WANT the model to see the reason, raise ToolError with a short, helpful message. That message is sent.

Wrong argument types are handled for you. add(a="two", b=3) and add(a=2) never reached the function: the SDK checked them against the type hints and sent back a validation message.

Example 4 - ToolError vs a plain exception, and free validation
from datetime import datetime from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver.exceptions import ToolError server = MCPServer("variants") @server.tool() def clear_error(timezone: str = "UTC") -> str: """Time with a clear error message.""" try: zone = ZoneInfo(timezone) except (ZoneInfoNotFoundError, ValueError): raise ToolError(f"Unknown timezone '{timezone}'. Use a name like Asia/Kolkata or Europe/Paris.") return datetime.now(zone).strftime("%Y-%m-%d %H:%M:%S %Z") @server.tool() def value_error(timezone: str) -> str: """Raises a plain ValueError.""" raise ValueError(f"bad timezone {timezone}") @server.tool() def add(a: int, b: int) -> int: """Add two whole numbers.""" return a + b
Output - what the client received
clear_error{'timezone': 'Mars/Olympus'} -> isError=True text="Error executing tool clear_error: Unknown timezone 'Mars/Olympus'. Use a name like Asia/Kolkata or Europe/Pari" structured=None value_error{'timezone': 'x'} -> isError=True text='Error executing tool value_error' structured=None add{'a': 2, 'b': 3} -> isError=False text='5' structured={'result': 5} add{'a': 'two', 'b': 3} -> isError=True text='Error executing tool add: 1 validation error for addArguments\na\n Input should be a valid integer, unable to p' structured=None add{'a': 2} -> isError=True text="Error executing tool add: 1 validation error for addArguments\nb\n Field required [type=missing, input_value={'" structured=None server log (stderr, shortened): ERROR Tool 'value_error' raised an ValueError: bad timezone x

Step 7 - the one rule: never print to stdout

With stdio, the server’s standard output IS the MCP channel. Every line there must be one JSON message. A print("debug: ...") puts a line that is not JSON into that channel.

We tested it with a tool that calls print(), and read the server’s stdout line by line. Three calls produced three "debug: noisy was called" lines in the message stream. The SDK client skipped them and logged "Failed to parse JSONRPC message from server" - a simpler client that expects JSON on every line would crash. Worse, Python buffers print() output when stdout is not a terminal: the three answers (ids 2, 3, 4) came first, and the three debug lines arrived later, all together, long after the calls that printed them.

Write debug output to stderr instead: print(..., file=sys.stderr), or use the logging module (it writes to stderr by default). stderr is not part of the MCP channel; hosts usually show it in their logs.

Wrong and right
@server.tool() def noisy() -> str: print("debug: noisy was called") # WRONG: goes into the MCP channel return "ok" @server.tool() def logs_ok() -> str: print("debug: logs_ok was called", file=sys.stderr) # RIGHT: stderr is safe return "ok"
Output - reading the server’s stdout after three calls to noisy()
JSON id 1 JSON id 2 JSON id 3 JSON id 4 NOT JSON debug: noisy was called NOT JSON debug: noisy was called NOT JSON debug: noisy was called SDK client log: Failed to parse JSONRPC message from server

Step 8 - the same server over HTTP

To share the server with other machines, run it with the Streamable HTTP transport. Only the last line changes: server.run("streamable-http", port=8931). The tool code stays exactly the same.

Clients then connect with a URL instead of a command: Client("http://127.0.0.1:8931/mcp"). Lesson 4.2 ran the same client against both transports with identical results. Remember the warning from 4.2: our demo has no login, so keep it on 127.0.0.1.

stdio or HTTP - one line
if __name__ == "__main__": server.run() # stdio: a client starts this file # server.run("streamable-http", port=8931) # HTTP: http://127.0.0.1:8931/mcp

A checklist for your own servers

Before you share a server, check these points. Each one comes from this lesson.

MCP server checklist
Name and docstringA clear tool name and one sentence that says what it does, with an example value.
Type hintsOn every argument and on the result - they become the schema and the checks.
ErrorsToolError with a short message the model can act on. Plain exceptions stay private.
Loggingstderr or logging - never print() to stdout in a stdio server.
TestsAn in-process Client(server) test, plus a check with the Inspector.
VersionPin the SDK: mcp>=2,<3.
Side effectsTools that change things should be clearly named, and approved by a human (Lesson 3.7).

Building an MCP server at a glance

Install

Pin the major version.

pip install "mcp>=2,<3"
Create

A server with a name.

server = MCPServer("time-server")
Tool

Docstring -> description, hints -> schema.

@server.tool()
def get_current_time(timezone: str = "UTC") -> str:
Clear error

Message is sent to the client.

raise ToolError("Unknown timezone ...")
Log

Never to stdout.

print("...", file=sys.stderr)
Run (stdio)

Started by a client.

server.run()
Run (HTTP)

A URL ending in /mcp.

server.run("streamable-http", port=8931)
Test in-process

No child process.

async with Client(server) as c: ...
Test with Inspector

Needs Node.js.

npx @modelcontextprotocol/inspector --cli python server.py --method tools/list

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Build it

“Type Example 1 yourself, then run Example 2. Change the docstring and see the description change in tools/list.”

A second tool

“Add convert_time(time: str, from_zone: str, to_zone: str) -> str to the same server. Test it with the Inspector.”

Clear errors

“Make get_current_time raise ToolError for an unknown timezone. Call it with "India" and read the message.”

Write a test

“Write a pytest test that uses Client(server) in-process and checks the result for "UTC" ends with "UTC".”

Break stdout

“Add a print() to the tool, call it from Example 2, and find the warning. Then move the print to stderr.”

What usually goes wrong

print() in a stdio server

stdout is the MCP channel. Our three prints became three non-JSON lines in the stream, arriving at random moments.

✗ print("debug:", timezone)
✓ print("debug:", timezone, file=sys.stderr)   # or logging
Raising plain exceptions for the model

Their message is hidden; the model only sees "Error executing tool ...". Use ToolError for messages the model should read.

✗ raise ValueError("unknown timezone")
✓ raise ToolError("Unknown timezone 'India'. Use a name like Asia/Kolkata.")
No type hints or docstring

The schema and description come from them. Without them the model has to guess what to send.

✗ def get_time(tz):
✓ def get_current_time(timezone: str = "UTC") -> str:
    """Return the current date and time in an IANA timezone, such as Asia/Kolkata."""
Running a stdio server by hand and waiting

It waits silently for JSON on stdin. Start it from a client, or test it with the Inspector.

Version 1 tutorials

FastMCP is MCPServer in SDK 2. Pin your version and check tutorials against it.

✗ from mcp.server.fastmcp import FastMCP
✓ from mcp.server.mcpserver import MCPServer

Key points

  • An MCP server is a normal Python program: MCPServer, @server.tool(), server.run().
  • The docstring becomes the description and the type hints become the input schema - write them well.
  • Arguments are checked against the type hints before your function runs.
  • Return an exact type (-> str, -> int) to get structuredContent as well as text.
  • Raise ToolError for messages the model should read; plain exceptions are hidden.
  • In a stdio server, stdout belongs to MCP - log to stderr.
  • Test three ways: a client script, Client(server) in-process, and the MCP Inspector.
  • The same tools run over HTTP by changing only server.run(...).

Quick check before you move on

Which two things does the SDK read from your function to describe the tool?
The docstring (the description) and the type hints (the input schema).
Why does a ValueError’s message not reach the client?
Plain exceptions may contain private details, so the SDK hides them. Raise ToolError to send a message on purpose.
What breaks if a stdio server calls print()?
The printed line goes into the MCP channel, which must contain only JSON messages.
How do you test a server without starting a child process?
Pass the server object to Client: async with Client(server) as c.

Quiz

  1. 1.

    You call add(a="two", b=3) on a tool add(a: int, b: int). Does your function run?

  2. 2.

    A tool returns a plain dict. What was structured_content in our test?

  3. 3.

    What one line changes to serve the same tools over HTTP?

  4. 4.

    Your agent cannot use your tool. How do you find out whose fault it is?

Interview questions

Walk through building a minimal MCP server in Python.

Install the SDK, create MCPServer with a name, decorate typed, documented functions with @server.tool() - the SDK derives the input schema from type hints and the description from the docstring - and call server.run() for stdio or with "streamable-http" for remote use. Test in-process with Client(server) and externally with the MCP Inspector.

How should an MCP tool report errors?

As tool errors the model can act on: a short, specific message (ToolError in the Python SDK), returned with isError true. Unexpected exceptions should be logged server-side and returned generically, so internals do not leak to the model or user.

What is the most common bug in stdio MCP servers?

Writing to stdout - prints, library banners, progress bars. stdout is the JSON-RPC channel, so stray output corrupts it. Send logs to stderr.

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...