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

> Run a versioned Python MCP server and client, discover a ticket-label tool, and test valid calls, schema failures and protocol errors.

- Source: https://www.zarifautomates.com/blog/how-to-build-an-ai-agent-using-mcp-model-context-protocol
- Published: 2026-09-17
- Updated: 2026-09-19
- Pillar: Agents & AI Engineering
- Tags: agent-course, ai-engineering, practical-guide
- Author: Zarif

---

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](/downloads/agent-course/mcp-ticket-example-v1.zip).

## 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](https://modelcontextprotocol.io/docs/learn/architecture) 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](https://github.com/modelcontextprotocol/python-sdk) 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:

```bash
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:

```python
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](https://py.sdk.modelcontextprotocol.io/client/) manages connection setup and cleanup through an asynchronous context manager. This is the central sequence in the downloaded client:

```python
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:

```json
{
  "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](/blog/how-to-build-ai-agent-with-rag) to give the agent a small, inspectable evidence source.

## Related Guides

- [Plan and Execute: Build a Bounded Ticket Workflow](/blog/how-to-build-ai-agent-that-plans-and-executes-tasks)
- [Use Cursor to Build a Ticket Approval Interface You Can Test](/blog/how-to-use-cursor-ai-to-build-web-applications)
- [Build a Ticket Workflow: Signed Delivery, Evidence and Human Approval](/blog/ticket-agent-workflow-capstone)

## Continue the course

Lesson 5 of 17.

Previous lesson: [Run a Bounded Tool-Calling Loop in Python](https://www.zarifautomates.com/blog/how-to-build-an-ai-agent-with-tool-use-and-function-calling.md).

Next lesson: [Build a Small RAG Pipeline and Measure What It Misses](https://www.zarifautomates.com/blog/how-to-build-ai-agent-with-rag.md).

[Browse available lessons](https://www.zarifautomates.com/blog/pillar/agents-and-ai-engineering#agent-course-heading).
