Build an MCP Server in Python
Build a local MCP server with Python, expose one typed tool, verify its schema and errors, and call it through an actual stdio subprocess.
Version and test detailsRetested October 8, 2026 with Python 3.11.9, MCP Python SDK 2.3.0 and Inspector CLI 2.10.1. The 11 original tests pass; consumer-host setup remains untested.
On this page
Version verifiedChecked October 8, 2026 against MCP 2026-07-28. Review method.
This guide builds a small server you can check without an API key, a production account, or a model deciding whether to call the tool. The server runs as a local process and has one tool: lookup_service_owner. It accepts a service identifier and returns the owner of one of two fictional services.
For an explanation of how the server connects to an AI application, see what an MCP server is.
The example was tested with Python 3.11.9 and mcp==2.3.0. Its automated suite made an in-memory connection and launched the same server as a stdio subprocess. It did not test Claude Desktop, VS Code, Cursor, or another product host.
What you need
- Python 3.10 or newer; the tested environment used Python 3.11.9.
- A terminal and a new project directory.
- The official MCP Python SDK v2 line.
The SDK’s stable v2 release introduced MCPServer, its first-class Client, and support for the 2026-07-28 protocol revision S324. The current protocol is not the same as the older examples built around a connection-wide initialize handshake S054.
Create a virtual environment and install the SDK version used in this example:
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install "mcp[cli]==2.3.0"
On macOS or Linux, the interpreter inside the environment is .venv/bin/python.
Pinning the version makes the code and observed schema reproducible. Before using this later, check the official Python SDK releases and retest rather than changing the version number in the article alone.
Create the server
Save this as server.py:
from typing import Annotated
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import BaseModel, ConfigDict, Field
ServiceId = Annotated[str, Field(pattern=r"^[a-z][a-z0-9-]{0,39}$")]
class ServiceOwner(BaseModel):
model_config = ConfigDict(extra="forbid")
service_id: str
team: str
runbook_path: str
CATALOG = {
"checkout": ServiceOwner(
service_id="checkout",
team="commerce-platform",
runbook_path="/runbooks/checkout",
),
"search": ServiceOwner(
service_id="search",
team="discovery-platform",
runbook_path="/runbooks/search",
),
}
mcp = MCPServer("Service owner example")
@mcp.tool()
def lookup_service_owner(service_id: ServiceId) -> ServiceOwner:
"""Return the team and inert runbook path for a fictional service."""
try:
return CATALOG[service_id]
except KeyError as error:
raise ToolError(
f"No service named {service_id!r} is in this example catalog."
) from error
if __name__ == "__main__":
mcp.run(transport="stdio")
The identifier can contain lowercase letters, digits, and hyphens, is limited to 40 characters, and cannot begin with a digit. The handler does not accept a path, URL, query, command, or credential. The runbook paths are inert example strings.
ToolError is deliberate. An unknown but well-formed identifier is a tool execution failure that a model or person can correct; returning an error-shaped success string would incorrectly mark the call successful.
Inspect the tool before trusting it
The MCP tools contract lets a server advertise an inputSchema and optional outputSchema S123. The SDK generates both from the function signature and return model.
For this example, check that:
- the catalog contains exactly
lookup_service_owner; service_idis required and carries the intended pattern;- the output requires
service_id,team, andrunbook_path; - a known identifier returns the expected structured object; and
- malformed values and unknown services are visibly different failures.
The same input limitation was observed in SDK 2.2.0 and again in the October 8 retest on 2.3.0: the high-level decorator’s generated top-level input schema did not include additionalProperties: false, and an undeclared top-level argument was ignored. The handler uses only the validated service_id, so the extra argument has no effect. If your application needs to reject unknown arguments, this generated input schema does not do that. The output model rejects extra fields.
Verify it without a model
An in-memory client is the fastest way to test the protocol-facing behavior. A second test should launch server.py as a subprocess so stdio startup, negotiation, listing, calling, and shutdown are exercised too.
Our automated tests checked:
- both catalog records;
- missing, wrong-type, and traversal-shaped identifiers;
- an unknown valid identifier;
- generated input and output schemas;
- an intentionally invalid output object;
- the observed undeclared-argument behavior;
- negotiation of
2026-07-28; and - a complete stdio subprocess list-and-call round trip.
All 11 tests passed with SDK 2.3.0. Host configuration is a separate step; these tests did not connect Claude Desktop, VS Code or another AI application.
Verify the same server with Inspector
Inspector lets you list and call the tool from a terminal. The commands below were tested on October 8 with Inspector CLI 2.10.1 and SDK 2.3.0.
With Node 22.19 or newer, run from the directory containing server.py:
npx --yes @modelcontextprotocol/inspector@2.10.1 --cli .\.venv\Scripts\python.exe server.py --method tools/list --format json
npx --yes @modelcontextprotocol/inspector@2.10.1 --cli .\.venv\Scripts\python.exe server.py --method tools/call --tool-name lookup_service_owner --tool-arg service_id=checkout --format json
The list must contain lookup_service_owner with its required patterned service_id and structured output schema. The valid call exits 0 and returns the commerce-platform record shown below. These results are from the CLI; the web UI was not tested.
Repeat the call with service_id=../secrets, then service_id=missing-service. Both tested cases exited 5 with a tool error: the first fails input validation, while the second reaches the handler but cannot find a catalog record. Inspect the error as well as the exit code to distinguish those cases. See the dated test summary.
Inspector checks the protocol-facing surface independently of the Python test client. It does not prove compatibility with a particular consumer host or model behavior. The official Inspector CLI documentation describes its output and failure codes. Version 2.10.1 also fixes the preceding release’s default executable selection; if an older unpinned invocation fails before connecting, check the installed version against the release notes.
Recognize the successful result
Calling lookup_service_owner with service_id set to checkout returns:
{
"service_id": "checkout",
"team": "commerce-platform",
"runbook_path": "/runbooks/checkout"
}
Calling it with missing-service should return a tool error and no structured success object. A traceback or a made-up owner would be a failure.
For the exact distinction between the advertised input, structured output, and error path, continue with the MCP tool schema guide.
Where to go next
Keep the first server local until the tool contract and negative cases are stable. Stdio gives the process the permissions of the account that launches it, so local does not mean harmless. Review MCP security before exposing files, credentials, browsers, databases, or write actions.
A shared server changes the task. Streamable HTTP adds caller authentication, audience validation, per-tool authorization, endpoint protection, timeouts, resource limits, and deployment operations. The local versus remote comparison covers that deployment work. Add authentication and authorization before exposing this example on a public endpoint.