MIKAEELS

Explore the blog

Building Your First MCP Server: A Practical Python Walkthrough

This is part 3 of the MCP series. In part 2, MCP core concepts, we separated resources, tools, and prompts. Here we implement those three capabilities and verify them through a local client.

To build an MCP server, you need a protocol implementation and something useful to expose. You do not need a language model to prove that the server works.

This guide builds a local Python server with a read-only inventory lookup, a resource describing its sample data, and a prompt template. A separate client verifies all three. The inventory is deliberately fictional and static: the example teaches the protocol boundary, not live inventory management.

Versions and scope

The example targets Python 3.11 and mcp==1.12.4 with pydantic==2.11.7, a specific release of the official MCP Python SDK. It uses that SDK's mcp.server.fastmcp.FastMCP class, not the separately distributed fastmcp package. The pin is a reproducibility baseline, not a claim that it is the newest release.

The flow follows the SDK's implementation of the 2025-06-18 protocol lifecycle. Newer SDK or protocol releases can change APIs and connection behavior. Review upgrades separately rather than silently substituting a different major version.

You need Python 3.11 and a terminal. No cloud resources, API credentials, or model service are required.

1. Create an isolated project

On macOS or Linux:

mkdir inventory-mcp cd inventory-mcp python3.11 -m venv .venv printf 'mcp==1.12.4\npydantic==2.11.7\n' > requirements.txt .venv/bin/pip install -r requirements.txt .venv/bin/pip freeze > requirements.lock.txt

The SDK pin fixes the API used here. The Pydantic pin is also required: this SDK release imports a helper removed by newer Pydantic releases. The generated lock file also records the transitive versions installed in your environment. Recreate that environment with .venv/bin/pip install -r requirements.lock.txt. On Windows, use the equivalent executables under .venv\Scripts.

Download the complete server, check client, and tested dependency lock. The project needs just two Python files:

inventory-mcp/ ├── requirements.txt ├── requirements.lock.txt ├── server.py └── check_client.py

2. Write the complete server

Save this as server.py. It is a complete file; copy the whole block.

import json from mcp.server.fastmcp import FastMCP mcp = FastMCP("Sample inventory") INVENTORY = {"keyboard": 8, "monitor": 3} @mcp.tool() def get_stock(sku: str) -> dict[str, str | int]: """Look up a product in fictional sample inventory; no live data.""" key = sku.strip().lower() if key not in INVENTORY: return {"status": "not_found", "sku": key} return {"status": "found", "sku": key, "quantity": INVENTORY[key]} @mcp.resource("inventory://catalog") def catalog() -> str: """Describe the available fictional inventory.""" return json.dumps({"sample_data": True, "skus": sorted(INVENTORY)}) @mcp.prompt() def stock_question(sku: str) -> str: """Prepare a question about the sample inventory.""" return f"Check sample stock for {sku}. Do not describe it as live data." if __name__ == "__main__": mcp.run(transport="stdio")

This uses the decorator API implemented by the pinned SDK's FastMCP server class. Type annotations describe the tool's inputs and output. The lookup explicitly distinguishes an unknown product from a known product with zero stock.

The tool performs a read. The resource has a URI and returns descriptive data. The prompt returns a template for an application to use; registering it does not force a model to follow it.

Do not use print() for server diagnostics on stdout. In stdio mode, stdout carries protocol messages. Use Python logging or stderr for diagnostics, as described in the transport specification.

3. Verify the server without a model

Save the following complete file as check_client.py in the same directory:

import asyncio import json import sys from pathlib import Path from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main() -> None: server = Path(__file__).with_name("server.py").resolve() params = StdioServerParameters(command=sys.executable, args=[str(server)]) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() assert "get_stock" in [tool.name for tool in tools.tools] result = await session.call_tool("get_stock", {"sku": "keyboard"}) assert not result.isError assert result.structuredContent == { "status": "found", "sku": "keyboard", "quantity": 8 } missing = await session.call_tool("get_stock", {"sku": "unknown"}) assert not missing.isError assert missing.structuredContent == {"status": "not_found", "sku": "unknown"} resource = await session.read_resource("inventory://catalog") assert json.loads(resource.contents[0].text)["sample_data"] is True prompt = await session.get_prompt("stock_question", {"sku": "keyboard"}) assert "keyboard" in prompt.messages[0].content.text print("PASS: discovery, lookup, missing SKU, resource, prompt") if __name__ == "__main__": asyncio.run(main())

The client uses the pinned SDK's ClientSession API. sys.executable ensures the child server runs with the same virtual environment as the client. Resolving the server path avoids depending on the terminal's current directory.

Run it:

.venv/bin/python check_client.py

Expected success output, in addition to any SDK diagnostics:

PASS: discovery, lookup, missing SKU, resource, prompt

The local protocol check passed on Python 3.11.14 with the linked dependency lock. The installed settings library also emitted an IncompleteFieldDefinitionWarning; it did not prevent the stdio checks. This is a local compatibility baseline, not a remote deployment test.

A failed assertion exits unsuccessfully. This checks an actual protocol exchange, including the missing-product path. It does not establish performance, remote access security, or the quality of a model's answers.

How does MCP work at a fundamental level?

For the pinned SDK and protocol version used here, the exchange is:

Client starts server subprocess → initialize: exchange version and capabilities → notifications/initialized: client is ready → tools/list: discover get_stock and its schema → tools/call: request get_stock with a SKU → server executes Python and returns the result → resources/read or prompts/get when the client requests them

Initialization is a protocol operation, not a call to an LLM. The versioned lifecycle specification documents the initialization sequence. The SDK handles message framing and dispatch for this example.

In an AI application, a host also manages the conversation and model calls. It can present discovered tool schemas to the model, route a requested operation to the server, and include the result in a subsequent model request. Our check client replaces those decisions with explicit calls so failures are easier to isolate.

This also explains why an MCP server does not need its own model API key for ordinary tools. Python performs the lookup. The host's model, if one is involved, explains the result.

Tools, resources, and prompts have different roles

Capability in this exampleHow the client uses itImportant limit
get_stock toolRequest a lookup with a SKUSample values are not live stock
inventory://catalog resourceRead data by URIA host must choose to read and include it
stock_question promptRetrieve a template with argumentsIt does not install a mandatory system policy

Resources can represent data that changes over time. Readable through MCP does not mean immutable in the underlying system. Tools can be read-only, as our lookup is. The resource and prompt specifications describe these application and user interaction roles.

Connect a host after the check passes

For a host supporting stdio, configure it to launch the absolute path to this project's virtual-environment Python with the absolute path to server.py as its argument. The JSON field names and configuration location depend on the host; use its documentation.

The host might support tools while handling resources or prompts differently. Verify those features individually instead of assuming every MCP feature appears in the chat interface.

For a network deployment you need a supported HTTP transport, a reachable service, and an access-control design. A local stdio process does not become remote because you put a hostname in the client. See how to find a remote MCP server URL.

Troubleshooting

SymptomLikely investigation
Import or decorator errorsConfirm the installed distribution and mcp==1.12.4 pin; avoid mixing examples from different libraries
Server waits silently when launched aloneExpected for stdio; run the check client
JSON parsing or protocol framing errorsCheck for debug prints on server stdout
Host cannot launch PythonUse an absolute executable path inside the virtual environment
Tool list is emptyConfirm which server file and environment the host actually launched
Tool returns not_foundThe sample only contains keyboard and monitor; this is a domain result, not a connection failure

What must change before using real inventory?

Replace the dictionary with an authenticated, bounded lookup against the system of record. Derive the user's identity from a trusted application context and enforce access at the service boundary. Keep timeouts and unavailable results distinct from zero stock. Include observation timestamps if the assistant needs to claim that information is current.

A read does not reserve inventory. If you later add ordering, validate availability again in the transaction and handle duplicate requests. MCP does not make a read-and-write sequence atomic.

Continue to part 4, MCP clients explained. We will use this tutorial's check_client.py as the starting point for distinguishing protocol calls from the host application's decisions about context and tool use. Part 5 then places that client/server boundary alongside retrieval and function calling.

For an optional application design guide, see giving an LLM real-time information.

Quick Recap

How do you build an MCP server?

Choose an SDK and transport, register the capabilities your application needs, and start the server. Verify discovery and calls with a client before connecting a language model. This guide uses the official Python SDK and local stdio transport.

Do I need an LLM API key to test an MCP server?

No. A protocol client can initialize the connection, list tools, call a tool, read a resource, and retrieve a prompt without using a model.

Can MCP tools be read-only?

Yes. Tools can calculate values, search documents, or query APIs without changing data. A tool does not have to perform a write.

Why does my stdio MCP server appear to hang?

A stdio server waits for protocol messages on standard input. Run it through a client or Inspector. Send application logs to stderr because stdout carries protocol messages.

Add Mikaeels Blog as a preferred source on Google

Comments

Share your thoughts and questions below