Skip to content
Zarif Automates

Build an MCP Server and Client for a Ticket-Label Tool

ZarifZarif
Published Updated

Hook an agent up to a tool, ask it to do something, and get a confusing answer back. Was it your prompt, the tool, or the connection between them? With a model in the middle, it's hard to tell.

So test the tool first with a plain client, a small program with no model in it. Make sure it can find the tool, call it, and recognize when a request is turned down.

This lesson builds a local MCP server with one tool. MCP, the Model Context Protocol, is a standard way for AI apps to find and call tools. The tool suggests a label for a made-up support ticket. A separate Python client starts the server, reads what inputs the tool accepts, and runs seven checks. The tool returns a suggestion without changing any record. You don't need an API key or a model subscription.

Download the server, client and pinned dependencies.

What runs where

There are three roles. The server has the tool. The client opens a connection to the server and sends it requests. An agent app can be the host, wrapped around a client, deciding which tools to offer the model and when to call them. The MCP architecture guide explains that split.

Here, the client starts server.py as a child process and talks to it over stdio, the program's standard input and output. No network port is opened. Because the server's standard output carries the protocol messages, any debugging prints have to go to standard error instead, or they'll garble the conversation.

This example uses the official MCP Python SDK 2.2.0, tested with Python 3.12.14 on September 17, 2026. The protocol version the two sides agreed on in that run was 2026-07-28. The SDK's current documentation treats v2 as a separate release line. Older tutorials built on FastMCP or ClientSession don't mix with this example, so check the migration guide before borrowing from them.

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 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 run .venv\Scripts\python.exe directly for the install and client commands.

The dependencies are pinned to the exact versions used for the recorded run. Keep the server and client in the same folder. The client finds the server next to its own file and starts it with the same Python.

2. Define the tool's input and output

The whole server is short enough to read before you run it:

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 separate checks here. First, Pydantic checks the shape of the input: the one allowed ticket, one of three labels, a whole-number version, and no extra fields inside request. Then the function checks the made-up ticket's current version. A request can be perfectly shaped and still be out of date.

StrictInt matters because Python treats True and False as a kind of number. Without it, an input of true could quietly become version 1. The output type also spells out applied: false, so nobody has to guess whether anything changed.

The fixed ticket ID keeps the exercise small. It isn't a login system. A server handling real tickets would need to work out who the caller is from a trusted connection and check their access to each record. A ticket ID the model sends proves nothing about who owns it.

3. Discover before invoking

The v2 client API handles opening and closing the connection for you with an async with block. This is the core of 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 adds the imports and asyncio.run(main()). Its discovery check expects exactly one tool, propose_ticket_label, and confirms that request is required. The printed input schema, the tool's description of what it accepts, includes the allowed labels and additionalProperties: false.

Notice the arguments are nested. Sending ticket_id at the top level isn't the same as sending it inside request. Use the schema the server actually reports.

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 meant for a host to show a model. Your own code can read structured_content, but it has to check is_error first. An error comes with a readable explanation, and that explanation isn't a successful result.

The client runs these checks over the real connection to the server process:

Input or operationRecorded outcome
Valid ticket, label and versionStructured proposal, is_error: false
Label deleteTool result with is_error: true
Extra nested field execute: trueTool result with is_error: true
Boolean versionTool result with is_error: true
Different ticket IDTool result with is_error: true
Version 2 instead of 3Tool result with is_error: true
Read nonexistent ticket://missing resourceMCPError with code -32602

The last row tests a different kind of error. It's a protocol error, raised by the MCP connection itself, not a tool turning down its arguments. The client catches it as MCPError. The other rejected calls come back as normal tool results that the client inspects.

A connection failure is a third kind. Rename server.py and run the client again to see it fail to start before it can discover anything. Put the filename back afterward.

The recorded run passed all seven checks. It tested a real MCP connection and SDK with made-up ticket data. It didn't test whether a model picks the right tool, a remote login service, or a production deployment.

5. Connect it to an agent only after the direct client works

Any app that supports MCP will have its own server settings and approval controls. Point it at the same Python and the same server path, then look at the tool it discovers before you let it make calls. The example server has one tool and no outside connections, so it's easy to see exactly what the host can ask for.

If you later let the tool make real changes, keep the code that writes them separate. A suggestion can go out of date while a person reviews it, so check the ticket version again right when you write. And if the client loses its connection mid-call, it needs to tell "I don't know if that worked" apart from "that failed" before it retries a real action.

You should end up with the two-file server and client, the schema it discovered, and the seven-check output. Change the allowed labels, update one client check to match, and confirm the schema and behavior agree. Then continue to retrieval-augmented generation to give the agent a small source of evidence you can inspect.

Zarif Choudhury

Zarif Choudhury

Zarif builds AI agents and automation workflows. He writes about what keeps working once real people use it: who is worth reading, the new jobs AI is creating, and agent workflows you can check step by step.