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.
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.
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.
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.
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.
$ 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.0Step 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.
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 stdoutWatch 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.
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())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.
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())outputSchema: None
text: { "hour": 19, "minute": 2, "zone": "Asia/Kolkata" } | structured: NoneStep 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.
$ 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.
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 + bclear_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 xStep 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.
@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"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 serverStep 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.
if __name__ == "__main__":
server.run() # stdio: a client starts this file
# server.run("streamable-http", port=8931) # HTTP: http://127.0.0.1:8931/mcpA checklist for your own servers
Before you share a server, check these points. Each one comes from this lesson.
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
InstallPin the major version.
pip install "mcp>=2,<3"
CreateA server with a name.
server = MCPServer("time-server")ToolDocstring -> description, hints -> schema.
@server.tool() def get_current_time(timezone: str = "UTC") -> str:
Clear errorMessage is sent to the client.
raise ToolError("Unknown timezone ...")LogNever 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-processNo child process.
async with Client(server) as c: ...
Test with InspectorNeeds 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.
“Type Example 1 yourself, then run Example 2. Change the docstring and see the description change in tools/list.”
“Add convert_time(time: str, from_zone: str, to_zone: str) -> str to the same server. Test it with the Inspector.”
“Make get_current_time raise ToolError for an unknown timezone. Call it with "India" and read the message.”
“Write a pytest test that uses Client(server) in-process and checks the result for "UTC" ends with "UTC".”
“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
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 loggingTheir 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.")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."""It waits silently for JSON on stdin. Start it from a client, or test it with the Inspector.
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 MCPServerKey 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
Quiz
- 1.
You call add(a="two", b=3) on a tool add(a: int, b: int). Does your function run?
- 2.
A tool returns a plain dict. What was structured_content in our test?
- 3.
What one line changes to serve the same tools over HTTP?
- 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...