Skip to main content
Glama
realcaptainsolaris

pydantic-ai-mcp-lab

Pydantic AI MCP Lab

A small experimental project for understanding the Model Context Protocol (MCP) by building an MCP server, inspecting it with a plain MCP client, and connecting it to a Pydantic AI agent.

The project deliberately separates the MCP layer from the AI layer.

The goal is not only to make an MCP tool call work, but to make the architecture and protocol interactions visible.

Architecture

The project contains three main components:

                         OpenAI
                           ^
                           |
                           |
User -> agent.py -> Pydantic AI
                     |
                     | MCP over Streamable HTTP
                     v
              127.0.0.1:8080/mcp
                     |
                     v
                  server.py
                     |
        +------------+-------------+
        |            |             |
    get_user    get_device    find_user
                                  |
                         find_devices_for_user

There is also a plain MCP client:

client.py
   |
   | MCP over Streamable HTTP
   v
server.py

The plain client does not use an LLM or Pydantic AI. It exists to demonstrate MCP tool discovery independently from AI agent behavior.

Related MCP server: employees

What the Project Demonstrates

The lab demonstrates several MCP concepts:

  • exposing Python functions as MCP tools

  • generating tool schemas from function signatures and type annotations

  • discovering tools through MCP

  • calling an MCP server without using an AI model

  • connecting Pydantic AI to an MCP server

  • letting an LLM select and chain MCP tools

  • using Streamable HTTP as the MCP transport

  • distinguishing normal domain outcomes from actual tool failures

Example Domain

The MCP server represents a fictional internal IT asset service.

It contains users and devices and exposes four tools:

get_user
get_device
find_user
find_devices_for_user

For example, answering:

Which laptop is assigned to Anna Keller?

requires two pieces of information.

First, the user must be found:

find_user("Anna Keller")
        |
        v
       U1

Then the devices assigned to that user can be retrieved:

find_devices_for_user("U1")
        |
        v
ThinkPad T14

This sequence is not hardcoded in the agent.

The LLM discovers the available MCP tools and decides which tools to call.

Requirements

The project uses:

  • Python 3.14

  • uv

  • Pydantic AI

  • MCP Python SDK

  • FastMCP

  • OpenAI

Installation

Clone the repository and install the dependencies:

uv sync

Create the local environment file:

cp .env.example .env

Configure the required values:

OPENAI_API_KEY=your-openai-api-key
OPENAI_MODEL=gpt-5.4-mini
MCP_HOST=127.0.0.1
MCP_PORT=8080
MCP_PATH=/mcp

The OpenAI configuration is only required by the AI agent.

The MCP server and plain MCP client do not require an OpenAI API key.

Project Structure

.
├── .env.example
├── README.md
├── pyproject.toml
└── src
    └── pydantic_ai_mcp_lab
        ├── __init__.py
        ├── agent.py
        ├── client.py
        ├── config.py
        └── server.py

server.py

Implements the MCP server and owns the example IT asset data.

The server itself does not use an LLM or Pydantic AI.

Functions decorated with @mcp.tool() are exposed through MCP.

client.py

Implements a plain MCP client.

It connects to the running server, initializes an MCP session, and calls list_tools() to inspect the tools exposed by the server.

No AI model is involved.

agent.py

Implements an interactive Pydantic AI agent.

The agent connects to the same MCP server and makes the discovered MCP tools available to the LLM.

The model can then decide which tools to call based on the user's question.

config.py

Loads MCP and AI configuration from environment variables.

MCP configuration and AI configuration are intentionally separated so that the server and plain client can operate without OpenAI credentials.

Running the MCP Server

Start the server in the first terminal:

uv run python -m pydantic_ai_mcp_lab.server

By default, the MCP endpoint is available at:

http://127.0.0.1:8080/mcp

The server uses MCP Streamable HTTP transport.

Keep this process running while using the client or agent.

Inspecting MCP Without AI

Open a second terminal and run:

uv run python -m pydantic_ai_mcp_lab.client

The client connects directly to the MCP server and asks which tools are available.

The output includes information such as:

TOOL: find_user
Description: Find a user by name.
Input schema:
{
  "properties": {
    "name": {
      "title": "Name",
      "type": "string"
    }
  },
  "required": [
    "name"
  ],
  "type": "object"
}

This demonstrates an important property of MCP:

The client does not import the Python functions implemented by the server.

Instead, it discovers their standardized MCP descriptions and input schemas through the protocol.

Running the AI Agent

Make sure the MCP server is still running.

Then start the interactive agent:

uv run python -m pydantic_ai_mcp_lab.agent

The agent accepts questions such as:

Which laptop is assigned to Anna Keller?

A typical trace looks like:

TOOL CALL: find_user
{"name":"Anna Keller"}

TOOL RESULT: find_user
{'found': True, 'user_id': 'U1', 'name': 'Anna Keller', 'department': 'IT'}

TOOL CALL: find_devices_for_user
{"user_id":"U1"}

TOOL RESULT: find_devices_for_user
[{'device_id': 'NB-1042', ...}]

The important part is that agent.py does not explicitly tell the model to call these tools in this order.

The LLM selects the tools based on their MCP metadata and the results of previous tool calls.

A more direct question:

What can you tell me about device NB-1042?

can instead result in only:

TOOL CALL: get_device
{"device_id":"NB-1042"}

The model therefore selects different MCP tools depending on the information required to answer the question.

Tool Design

MCP tools are interfaces for both software clients and AI models.

For that reason, function names, parameter names, docstrings, and type annotations matter.

FastMCP uses this information to describe the tools through MCP.

A tool such as:

@mcp.tool()
def find_user(name: str) -> dict[str, str | bool]:
    """Find a user by name."""

provides substantially more useful information to a client and an LLM than an ambiguously named function with untyped parameters.

Good tool design is therefore part of good MCP server design.

Expected Results vs. Tool Errors

An important behavior discovered during the experiment concerns missing data.

An early implementation raised a ValueError when find_user() could not find a user.

For a query such as:

Which laptop is assigned to Laura Miller?

the tool error caused the agent framework to retry the tool call and eventually fail after reaching its retry limit.

The tool was changed to return a normal structured result instead:

{
    "found": False,
    "name": "Laura Miller",
}

The agent could then interpret the result and answer appropriately.

The resulting design rule is:

Expected domain outcomes are tool results. Unexpected technical failures are tool errors.

A user not existing is an expected search outcome.

A database connection failing would be an actual technical failure.

MCP and Transport

MCP should not be confused with the transport used to carry MCP messages.

During development, the project was successfully tested first using stdio and later using Streamable HTTP.

Conceptually:

MCP
 |
 +-- stdio
 |
 +-- Streamable HTTP

With stdio, a client can start the MCP server as a subprocess and communicate through its standard input and output streams.

With Streamable HTTP, the MCP server runs independently and clients connect to its HTTP endpoint.

The current project configuration uses Streamable HTTP:

client.py --------+
                  |
                  | MCP over HTTP
                  v
             server.py
                  ^
                  |
                  | MCP over HTTP
                  |
agent.py ---------+

The tools themselves do not depend on the transport.

MCP vs. AI Tool Calling

MCP and LLM tool calling are related but different concepts.

The MCP server exposes capabilities:

MCP Server
    |
    +-- get_user
    +-- get_device
    +-- find_user
    +-- find_devices_for_user

The LLM decides how to use those capabilities:

User question
     |
     v
    LLM
     |
     | selects tool
     v
MCP Tool
     |
     v
Tool Result
     |
     v
    LLM

MCP provides the standardized interface.

The LLM provides the reasoning about which tools to call.

Known Warning

With the dependency versions used during development, starting FastMCP may produce a Pydantic warning related to the lifespan field:

IncompleteFieldDefinitionWarning

The warning originates from the MCP Python SDK/FastMCP integration and does not prevent the example server from operating.

The lab deliberately does not patch the installed dependency to suppress the warning.

Development Checks

Run Ruff:

uv run ruff check .

Run the formatter check:

uv run ruff format --check .

Key Takeaway

MCP is not the intelligence of the system.

It provides a standardized boundary through which clients can discover and invoke capabilities.

In this project:

server.py

defines and exposes the capabilities.

client.py

demonstrates the MCP protocol without AI.

agent.py

adds an LLM that can reason about those capabilities and decide how to combine them. Keeping those responsibilities separate makes it much easier to understand what MCP actually does.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    An MCP server exposing employee info retrieval and web search tools, designed to be consumed by a LangChain agent for decoupled tool execution.
    -
  • F
    license
    Not graded
    quality
    D
    maintenance
    A sample employee database exposed as an MCP server, enabling AI agents to search employees and query organizational structure.
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    MCP server for Snipe-IT asset management, enabling users to read and manage IT assets, models, manufacturers, and related data through natural language. Designed to feed inventory into lifecycle audits.
    Apache 2.0