Build an MCP Server and Client for a Ticket-Label Tool
Before connecting an agent to a tool, make sure an ordinary client can discover it, call it, and recognize a rejected request. Otherwise, a model's response can hide whether the problem is in your prompt, your tool, or the connection between them.
This lesson builds a local MCP server with one tool: propose a label for a synthetic support ticket. A separate Python client launches the server, discovers its input schema and runs seven checks. The tool returns a proposal without changing a record. You do not need an API key or a model subscription.
Download the server, client and pinned dependencies.
What runs where
The server owns the capability. The client opens a connection and sends protocol requests. An agent application can act as the host around a client, deciding which tools to make available and when to call them. The MCP architecture guide explains that separation.
Here, the client launches server.py as a child process and communicates over stdio. No HTTP port is opened. The server's standard output carries protocol messages, so ordinary debugging output belongs on standard error.
This example uses the official MCP Python SDK 2.2.0, tested with Python 3.12.14 on September 17, 2026. Its observed negotiated protocol version was 2026-07-28. The SDK's current documentation identifies v2 as a separate release line. Older FastMCP/ClientSession tutorials should not be mixed into this example without checking the migration guide.
1. Create the environment
Install Python 3.10 or newer. Extract the ZIP into a new folder. It contains server.py, client.py, requirements.txt and the recorded fixture result.
On macOS or Linux:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python client.py
On Windows, activate with .venv\Scripts\activate in Command Prompt, or invoke .venv\Scripts\python.exe directly for the installation and client commands.
The dependencies are pinned to the versions used for the recorded run. Keep the server and client in the same directory: the client resolves the server path relative to its own file and launches it with the same Python executable.
2. Define the tool's input and output
The complete server is short enough to inspect before running:
from typing import Literal
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import BaseModel, ConfigDict, StrictInt
server = MCPServer("Ticket label lesson", version="1.0.0")
class Proposal(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
ticket_id: Literal["DEMO-42"]
label: Literal["billing", "technical", "other"]
expected_version: StrictInt
class ProposalResult(Proposal):
applied: Literal[False] = False
@server.tool()
def propose_ticket_label(request: Proposal) -> ProposalResult:
"""Propose a label for synthetic DEMO-42 at version 3. Never apply a change."""
if request.expected_version != 3:
raise ToolError("stale_version: reload the ticket before proposing a change")
return ProposalResult(**request.model_dump())
if __name__ == "__main__":
server.run(transport="stdio")
There are two different checks. Pydantic validates the input shape: the permitted ticket, three labels, an integer version and no extra fields inside request. The function then checks the current synthetic ticket version. A well-formed request can still be stale.
StrictInt matters because Python treats booleans as a kind of integer. An input of true should not become version 1. The output type also makes applied: false explicit.
The fixed ticket ID keeps this exercise small. It is not an authentication system. A server handling real tickets would need to derive the caller's identity from its trusted connection and check access to each record. An ID supplied by a model does not establish ownership.
3. Discover before invoking
The v2 client API manages connection setup and cleanup through an asynchronous context manager. This is the central sequence in the downloaded client:
params = StdioServerParameters(
command=sys.executable,
args=[str(Path(__file__).with_name("server.py"))],
env={},
)
async with Client(params, read_timeout_seconds=10) as client:
discovery = await client.list_tools()
print(discovery.tools[0].name)
response = await client.call_tool(
"propose_ticket_label",
{"request": {
"ticket_id": "DEMO-42",
"label": "billing",
"expected_version": 3,
}},
)
The full client supplies the imports and asyncio.run(main()). Its discovery check expects exactly propose_ticket_label and verifies that request is required. The printed input schema includes the nested proposal's allowed labels and additionalProperties: false.
Notice the nested argument shape. Sending ticket_id directly at the top level is different from sending it inside request. Use the schema the server actually advertises.
4. Read the result and distinguish failures
The successful call returns this structured content:
{
"ticket_id": "DEMO-42",
"label": "billing",
"expected_version": 3,
"applied": false
}
The SDK also returns content blocks for a host to present to a model. Application code can inspect structured_content, but it must check is_error first. Do not treat an error's human-readable explanation as a successful tool result.
The client runs these checks over the actual subprocess connection:
| Input or operation | Recorded outcome |
|---|---|
| Valid ticket, label and version | Structured proposal; is_error: false |
Label delete | Tool result with is_error: true |
Extra nested field execute: true | Tool result with is_error: true |
| Boolean version | Tool result with is_error: true |
| Different ticket ID | Tool result with is_error: true |
| Version 2 instead of 3 | Tool result with is_error: true |
Read nonexistent ticket://missing resource | MCPError with code -32602 |
That last row exercises a protocol-level error separately from the tool's rejected arguments. The client catches MCPError. The other rejected calls return tool results that it inspects. A connection failure is another category again. Rename server.py and rerun the client to see a launch failure before discovery. Restore the filename afterward.
The recorded run passed all seven checks. It tested a real MCP transport and SDK, with synthetic ticket data. It did not test a model's tool selection, a remote identity provider or a production deployment.
5. Connect it to an agent only after the direct client works
An MCP-capable host will have its own server configuration and approval controls. Configure the same Python executable and server path, then inspect the discovered tool before allowing calls. The example server has one capability and no external integrations, making it easier to see what the host can actually request.
Keep the executor separate if you later allow changes. A proposal can become stale while a person reviews it, so recheck the ticket version at the point of writing. A disconnected client also needs to distinguish an unknown outcome from a confirmed failure before retrying a real action.
Your deliverable is the two-file server/client, its discovered schema and the seven-case output. Change the allowed labels, update one client fixture, and verify that the schema and behavior agree. Then continue to retrieval-augmented generation to give the agent a small, inspectable evidence source.
