AI Agents with MCP

Model Context Protocol for Building Clients, Services, and End-to-End Agents

With Early Release ebooks, you get books in their earliest form—the author’s raw and unedited content as they write—so you can take advantage of these technologies long before the official release of these titles.

Kyle Stratis

AI Agents with MCP

by Kyle Stratis

Copyright © 2026 O’Reilly Media. All rights reserved.

Printed in the United States of America.

Published by O’Reilly Media, Inc., 1005 Gravenstein Highway North, Sebastopol, CA 95472.

O’Reilly books may be purchased for educational, business, or sales promotional use. Online editions are also available for most titles (http://oreilly.com). For more information, contact our corporate/institutional sales department: 800-998-9938 or corporate@oreilly.com.

Editors: Michele Cronin and Nicole Butterfield

Interior Designer: David Futato

Cover Designer: Karen Montgomery

Illustrator: Kate Dullea

May 2026: First Edition

Revision History for the Early Release

2025-07-07: First Release

2025-08-13: Second Release

2025-10-03: Third Release

See http://oreilly.com/catalog/errata.csp?isbn=9798341639553 for release details.

The O’Reilly logo is a registered trademark of O’Reilly Media, Inc. AI Agents with MCP, the cover image, and related trade dress are trademarks of O’Reilly Media, Inc.

The views expressed in this work are those of the author(s) and do not represent the publisher’s views. While the publisher and the author(s) have used good faith efforts to ensure that the information and instructions contained in this work are accurate, the publisher and the author(s) disclaim all responsibility for errors or omissions, including without limitation responsibility for damages resulting from the use of or reliance on this work. Use of the information and instructions contained in this work is at your own risk. If any code samples or other technology this work contains or describes is subject to open source licenses or the intellectual property rights of others, it is your responsibility to ensure that your use thereof complies with such licenses and/or rights.

979-8-341-63950-8

Brief Table of Contents (Not Yet Final)

Chapter 1: Agentic AI and MCP (available)

Chapter 2: An Introduction to the Model Context Protocol (available)

Chapter 3: Making an Intelligent Application Agentic with MCP Clients (available)

Chapter 4: MCP Clients: Advanced Use and Best Practices(available)

Chapter 5: Building MCP Servers: Providing Tools, Prompts and Resources to Applications (available)

Chapter 6: Building MCP Servers: Advanced Use with Utilities and Integrating Client Capabilities (unavailable)

Chapter 7: Testing, Securing, and Sharing Your MCP Server (unavailable)

Chapter 8: Connecting Clients andServers: MCP Transports (unavailable)

Chapter 9: Wrapping Up: Full Examples and Next Steps (unavailable)

Chapter 1. Agentic AI and MCP

A Note for Early Release Readers

With Early Release ebooks, you get books in their earliest form—the author’s raw and unedited content as they write—so you can take advantage of these technologies long before the official release of these titles.

This will be the 1st chapter of the final book. There is a GitHub repository in progress with code examples.

If you’d like to be actively involved in reviewing and commenting on this draft, please reach out to the editor at jleonard@oreilly.com.

Towards the end of 2024, Anthropic released the Model Context Protocol (MCP) to great fanfare. Not only did Anthropic standardize a way to transform chatbots from simple talkers into agents that could act, but they released MCP concurrently with support from popular AI-powered IDEs as well as Anthropic’s Claude Desktop, driving rapid adoption. One of the major pieces of the MCP architecture, the MCP servers, was made to be incredibly simple to spin up, but powerfully complex when advanced workflows were needed. This lead to a rapid flood of user-created MCP servers, augmenting generative AI workflows and cementing the adoption of MCP as the standard for building AI agents.

This book aims to go deep into MCP, taking you beyond the available documentation and giving you a deep understanding of the MCP architecture, the features the protocol and its SDKs provide, and most importantly, how to use that understanding to build your own MCP servers and clients and augment your own generative AI workflows with them. By doing this, you can transform standard generative AI chatbots into fully fledged agents, which can act, plan, and use tools on their own, and even work together to accomplish complex tasks. In this chapter, you will learn more about AI agents, what they are, how they are used, and how MCP fits into the agentic AI landscape. The next chapter will cover MCP itself: its history, the problems it solves, its architecture, and real-world examples of MCP in action. The next three chapters will cover the major components of MCP: clients and their host applications, servers, and transports. With this knowledge in hand, the final three chapters will use a project in order to give you practical experience building not just your own servers, cliens, and transports, but also how to leverage them in an actual agent.

What is an Agent, Anyway?

But what exactly is an agent? If you Google search this, you will find more definitions than you will find links. If you ask Claude or ChatGPT a few times in a row, you might even get different answers as well. Everyone seems to have an opinion on what is or isn’t an agent, but it is difficult to talk about agents without settling on a common definition. For the purposes of this book, we will use the definition used by Anthropic in their article Building Effective Agents: an agent is a “system…​where large language models (LLMs) dynamically direct their own processes and tool usage, maintaining control over how they accomplish tasks.” They’re usually implemented as LLMs that receive a task and then use tools (here, a tool is any code “outside” of the agent that is called by the agent to accomplish some task) to execute an action and gather feedback before proceeding. The action and feedback gathering occurs in a loop, where the LLM does some action, feedback is gathered and sent back to the LLM, and then the LLM decides whether to present the result as a final result to the user, use the feedback it gathered to do another action, or gather more feedback from the user.

Let’s take a look at a common use for agents, and what this loop would look like for each outcome. For this example, let’s imagine a coding task: you are in an AI-powered code editor like Cursor or Windsurf and you ask the chatbot to write unit tests for your code. The action/feedback loops may look something like this:

Figure 1-1. A hypothetical action-feedback loop in action for a coding agent.

Figure 1-1 shows an example action-feeadback loop (AF loop) for a coding agent. The user initiates a task by asking the agent (nicely) to write unit tests for their code housed in feature.py. The first AF loop is initiated: the LLM reads feature.py, the environment it gets feedback from is feature.py, and the feedback here could be the code itself, its structure only, or some other representation of the file. Armed with this information, the next AF loop that the agent performs could be to write all the tests that are implied by the contents of feature.py. Writing the tests, then, is the action, the feedback environment is the written test file, and the feedback is the written code or some other representation of it. The agent could then enter the third AF loop, where it runs the tests as its action. The environment for this loop is the test environment and results, and the feedback to the LLM could be the test results, test coverage, or anything else that would be helpful to the model to determine the next step. After here, if the results are satisfactory, the loops can end. If not, the agent would probably choose to re-run AF loop 2 or perhapse take a different, more precise action, like fixing the failing tests, then run AF loop 3 again and continue until the results are satisfactory.

This is distinct from an agentic workflow, a system in which code, rather than the LLM, determines the path the logic will take, but still calls an LLM to do some part of the work. This is a common pattern in agentic systems, and can be very useful for complex tasks that require some level of deterministic logic as enforced by the code. For example, imagine an application that takes code as an input and turns it into its equivalent in another language. A workflow approach to this could be to take the input code, call an LLM to detect the language the code is in, and if it doesn’t detect a language, return a failure message to the user. If it does detect a language and it is one that you have a deterministic parser for, then the deterministic parser is called on the code, and the translated output is returned to the user. If a language is detected that you don’t have a parser for, then you would call an LLM again and prompt it to do the translation for you, returning the output to the user. This is prompt chaining, a common agentic workflow pattern for tasks that require one or more calls to an LLM and a code path for deciding which next steps to take to complete that task. Some other common workflow patterns include paralellization, where several LLM calls are made at once and the results are combined, routing, where an LLM classifies an input then makes one of several possible LLM calls with the classified input, orchestrator-worker, where one LLM breaks down a task, calls worker LLMs to work on the smaller tasks, and finally an LLM is called to put the results together, and evaluator-optimizer, where an LLM generates a response to the user prompt and a second LLM call evaluates that response and either presents it to the user if it’s acceptable or returns it with feeback to the first LLM conversation for refinement.

Figure 1-2 shows this prompt-chaining workflow in detail: the input is sent to the LLM to detect the language of the input. The output is the guessed language, which is checked in code to see if there was a detection or not. It branches to the “fail” state if a language was not detected, and to the “success” state if one was. The next state is also deterministic, and checks if we have a deterministic parser for the detected language. If so, the next state is simply calling the parser and reporting the output to the user. If not, the next state is to call an LLM again and prompt it to do the translation for you, returning the output to the user.

Figure 1-2. A hypothetical prompt-chaining workflow for a language detection and translation agent.

The workflow is initiated by the first LLM call, which detects that the workflow should be initiated. In this workflow, a series of LLM calls with prompts that build on the results of the previous calls are made, with the workflow’s direction driven primarily by the code, rather than the LLM.

By contrast, an agentic approach, however, would provide language detection and translation tools to the LLM, and simply ask it to detect the language of the input and use that to pick the correct translation tool to do the translation. The next figure shows this process: The user provides code input, and the LLM is called. It loops through the action-feedback loop, detecting the language of the input, then checking the environment for any tools to do the translation. If there is an appropriate tool for the agent to use, it calls it, if not, it will autonomously attempt the translation on its own. Whatever result it gets back, it will return to the user.

Figure 1-3. A language detection and translation agenti.

Figure 1-3 shows the same process as the prompt chaining workflow in Figure 1-2, but implemented as an autonomous agent. The agent is provided with language detection and translation tools, and is able to use them at its own discretion given the user’s prompt. This is opposed to the prompt chaining workflow, where the code determines the progression of the workflow.

The chief difference to remember is that agents are able to decide which tools to use and actions to take over 1 or more action-feedback loops, while workflows use code paths to determine which tools to use and actions to take, and how to take those actions.

How are Agents Used in Generative AI?

As you saw above, agents are deployed for complex tasks where planning, decision-making, and tool use are necessary to complete the given task. Typically, to the user, there isn’t any obvious difference between how they interact with an agent than how they interact with non-agentic LLMs: there’s a chat box with a chatbot at the “other end” of it, they ask it questions, and it answers. One key difference with agents, though, is that users can ask them to do something, and they can take action and interact with their environment given tools. A user might experience this as nothing more than a more powerful chatbot, but agents can provide customized experiences to the user via their usage of tools, specialized prompts, data resources, memory, and the ability to coordinate and collaborate with other agents.

Because agents are autonomous modifications of existing LLMs, using agents looks just like using an LLM: you give it text (a prompt), and get a response back. But with agents, LLMs now can choose to use or even write small bits of code to execute, can retain memory of interactions with multiple users, and autonomously do research on the web, parallelizing research tasks that would be mostly sequential in a non-agentic or human system.

What Agents Enable in Generative AI

Agents and the design patterns that are being invented and explored for them are enabling some of the most exciting new advances in generative AI. A single agent transforms an LLM with a static source of information (from its training data) into something that can access new information, take action on its environment via tools, act semi-autonomously, and communicate with other agents. All of this behavior comes from both the architecture of the agent (specifically, the action-feedback loop discussed earlier in this chapter), and the abilities that are added on to an LLM in an agentic system: data retrieval, tools, and memory.

Before agents were widespread, it was most common to interact directly with an LLM via a chat interface. The only information it had available came from its training data so the models were frozen in time, and a model couldn’t remember anything about the user’s chat history or use tools. Retrieval-augmented generation (RAG) was an early way to give LLMs more information to work with, and gave rise to chatbots that could answer questions with information in an organization’s knowledge base, support documents, or other data sources. Other techniques, like fine-tuning, were also used to do things like expanding the foundation model’s knowledge or giving the chatbot a different personality.

Agents, however, allowed for something potentially even more powerful: the ability to act autonomously, to call deterministic code using its own judgment and environmental feedback, to coordinate with other agents, and to store short-term memory of the user’s chat history and other information. While most of these things aren’t themselves requirements for a system to be considered an agent, the power of LLMs combined with an autonomous agent loop and these bolted-on model augmentations has led to some of the most exciting advances in generative AI yet.

Examples and Use Cases for Agents

To really explore how agents are used, what they allow us to do, and what kinds of tasks they excel at, it will be helpful to look at some use cases of agents, real-life examples of those use cases, and, where possible, their architectures. Agentic systems are being deployed seemingly everywhere, but a few use cases have really shined recently:

Coding agents

Research agents

Customer service agents

Business-specific copilots/assistants

Coding agents. These might be the most common and popular agents, and it’s likely that you’ve worked with one or more. Unsupervised applications like Anthropic’s [Claude Code](https://www.anthropic.com/claude-code) and OpenAI’s [Codex](https://openai.com/codex/) provide a command line interface for instructing the agent(s), but code changes are done in the background, freeing you up to do other tasks while it works. Supervised applications include [Cursor](https://www.cursor.com/) [Windsurf](https://windsurf.dev/), and the [Github Copilot](https://github.com/features/copilot) extension for VS Code and provide a user interface within a familiar code editor, allowing you to view and approve the changes the agent makes.

Research agents. These agents are used to do deep research on a topic, making use of memory, tools like web search, and in some cases, additional autonomous agents to pursue various avenues of research. Anthropic’s [research agent](https://www.anthropic.com/news/research) is a good example of this, where it uses a combination of built-in and user-provided tools to conduct its research, a memory store to provide context and a scratchpad of current research directions, and subagents orchestrated by the main agent to pursue individual research directions. You can learn more about Claude Research’s architecture and how it works [here](https://www.anthropic.com/engineering/built-multi-agent-research-system).

Customer service agents. Customer service agents are becoming ubiquitous. While most in the wild are simple decision trees or LLM-powered chatbots with RAG, more and more customer service agents are becoming true agents, adding tool use and memory to better service customers’ questions, guide them through fixing their issue (or attempting to fix it directly), and, when needed, more accurately escalate issues.

Software-specific copilots/assistants. Many software applications and platforms, especially those that service enterprise customers, are adding copilots that help their customers use and manage the software. These agents will often have tools that are specific to the software: one may be able to build charts with user data, another may generate branded reports, and yet another may help users query their software’s data with natural language.

These are just a few of the most popular use cases for agents, but agents’ inherent flexibility and autonomy make the possibilities for their use nearly endless. Interactive [art projects](https://www.youtube.com/watch?v=7fNYj0EXxMs), AI “personalities” built up over many interactions with people and other agents (like [Void](https://cameron.pfiffer.org/blog/void/) on Bluesky), 3D modeling agents, agents “emboided” in physical objects, and agents that act as an all-in-one personal assistant are just a few less common but perhaps more interesting uses for agents.

The Model Context Protocol’s Role in Agentic AI

Before MCP, even though agentic AI was still fairly new, organizations that were building internal generative AI platforms and public frameworks like LangChain had to come up with different ways for users to implement tools and then give them to an LLM, with different LLMs requiring their own connector. This led to what is known as the “MxN problem”: for each of M LLMs that needs to be supported, you need N connectors for each tool, data source, etc. that you want to connect. If you support 3 models and 4 tools, that means you have to write 12 connectors that largely repeat themselves, increasing the opportunities for hard-to-diagnose bugs to pop up on.

Figure 1-4. An illustration of the MxN problem where 3 models all need access to 4 different tools. This results in 12 (4 x 3) connectors being needed.

The Model Context Protocol, inspired by the Language Server Protocol (LSP), provides a common interface for building tools, prompts, and data resources and connecting them to LLMs. In this way, for any number M of LLMs, you just write N connectors, transforming the M x N problem into M + N. In our 3 model and 4 tool example, this would mean we only need 7 connectors instead of 12.

Figure 1-5. With MCP providing a common interface between tools and models, we now only need 7 connectors to provide the three models access to the four tools instead of 12. Each model or AI application simply needs its own connector to the MCP server, and MCP provides a common interface for all of them to use the available tools.

By creating a simple, standard interface for models to use tools, MCP servers reduce the MxN problem to an M+N problem: any model that supports tool use can be connected to an MCP server via an MCP client, so only one connector (for MCP, this is the client) is needed for all of the tools provided by a single MCP server. Every server that supports tools exposes ListTools and CallTool endpoints, which the host application calls via its client to get the tools available from the server and call them, respectively.

MCP Components

The main components of MCP are the MCP server, the MCP client, and the MCP transport. The server provides tools, prompts, data resources, and more via a common interface. The client is the connector that interfaces between a host application and an MCP server. The transport is the underlying communication protocol that allows communication between the client and server. The MCP architecture as a whole will be covered in greater detail in the next chapter.

Other Protocols in Agentic AI

With the success of MCP, other groups have announced their own agentic protocols. While MCP focuses on standardizing how LLMs access and use tools, prompts, and data resources, other protocols focus on how agents communicate with each other. The most notable of these is Google’s [Agent2Agent protocol](https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/) (A2A). When A2A was released, its creators made clear that it wasn’t a competitor to MCP, but rather a complement to it, and agents that use MCP to access tools and data can still use A2A to communicate with other agents.

[Agent Communication Protocol](https://agentcommunicationprotocol.dev/), like A2A, focuses on how agents communicate with each other, providing a RESTful API for agents to communicate with each other, allowing agents built with different tech stacks and models to work together. [Agent Network Protocol](https://agent-network-protocol.com/) is a Chinese-developed protocol, again fouses on a making inter-agent communiction easier and less brittle. This protocol has identity and authentication built in, and supports encrypted communication between agents.

While these protocols are interesting and likely to become a part of an overall agentic protocol tech stack (much like the Internet protocol tech stack) that includes MCP and, if we follow the path of the Internet, several other purpose-built protocols, these other protocols are not the focus of this book. You should explore them, however, and I challenge you to figure out how to incorporate them with MCP.

What You’ll Learn

The aim of this book is twofold. The primary purpose is to get you comfortable with every feature and component of the Model Context Protocol, enabling you to build host applications (i.e. agents, given our definition from the beginning of the chapter), clients, servers, and even contribute to the protocol and its various SDKs. The first part of this book, chapters 2 through 5, accomplishes this via explanation and example. We’ll examine every component of MCP and the official Python SDK, discuss what they’re used for, what they can do, and how the implementation relates back to the protocol. As you progress through these components, you will encounter code examples designed to show a practical implementation with minimal cruft, allowing you to focus on the topic at hand. The second part, chapters 6-9, guides you through building a project that uses every part of the MCP architecture. This will give you real, hands-on experience in building applications that can interface with MCP servers via MCP clients, MCP servers, and even transport layers.

The secondary purpose of this book is to serve as a reference guide. The sections of the following chapters dealing with the architecture and Python implementation of the Model Context Protocol include links to code, the protocol specification, and articles and projects that address or otherwise use the protocol. These will serve as starting points whenever you need to learn more about a concept addressed in the book or where that concept fits into the current (and fast-moving) state of the protocol. In addition, I hope both the code examples and project can serve as reference points that can get you started quickly on your own projects.

Chapter 2. An Introduction to the Model Context Protocol

A Note for Early Release Readers

With Early Release ebooks, you get books in their earliest form—the author’s raw and unedited content as they write—so you can take advantage of these technologies long before the official release of these titles.

This will be the 2nd chapter of the final book. There is a GitHub repository in progress with code examples.

If you’d like to be actively involved in reviewing and commenting on this draft, please reach out to the editor at jleonard@oreilly.com.

To best understand MCP, it’s important to understand its history, what inspired its creation, what problems it solves for developers, and how it is designed. The growth of the protocol’s usage has been nothing short of explosive, and with the rapid development of the protocol itself, the official and unofficial software development kits, and the wider ecosystem, it’s easy to lose sight of why MCP was created in the first place and what problems it was designed to solve. Having a deep understanding of these things will help you better understand the design choices made in the protocol and how to best leverage the components that MCP provides.

First, we will get into a time machine and travel all the way back to 2024, when an Anthropic engineer was getting annoyed at having to constantly switch between Claude Desktop and his code editor. You will learn about the surprising origin story of MCP, the diverse tools and frustrations that inspired its creation. Then, we will take a look at some of the problems MCP was built to solve through the lens of its creation. Then, we will look at some real-world usages of MCP, such as MCP servers (both local and remote) and applications that host MCP clients, enabling the use of MCP servers. You will see how they solve not only their specific problems, but the broader ones that the protocol was designed to handle. Finally, with all of this context, we will dig into the architecture of MCP itself: its components, how they work together, and how you can customize and build with them.

The Genesis of MCP

MCP was born out of an internal project at Anthropic. David Soria Parra, a software engineer at Anthropic, was using Claude Desktop to assist him with his day-to-day work: writing developer tools. But he was frustrated by having to constantly copy and paste code between Claude Desktop and his code editor. If you were doing AI-assisted coding before the current wave of LLM-powered coding tools, you’ve probably experienced the same sort of frustration. He had also been working on a Language Server Protocol (LSP) project, which inspired him to take a similar approach to this problem.

The LSP is an open protocol that uses JSON-RPC to enable communication between a client and a server in order to provide advanced language knowledge to an IDE. If you’ve ever used a non-LLM autocomplete, hovered over a variable or a function call to get its definition, or renamed all instances of a variable from your IDE, you’ve likely used LSP and one or more language servers. The protocol was developed by Microsoft for VS Code and standardized in 2016, rapidly becoming the de facto standard for providing advanced language-specific capabilities to IDEs.

Language Server Protocol (LSP)

The homepage for the LSP protocol specification, which includes a list of programming language servers and SDKs, can be found at microsoft.github.io/language-server-protocol.

While stewing on his specific problem, David was thinking more broadly about how to make it easier for developers to create integrations with Claude Desktop specifically and LLMs in general. He noticed that writing integrations for LLMs, especially for more than one, created the MxN problem you learned about in the previous chapter: for each of M LLMs that you want to write N integrations for, you need to write MxN connectors. You can see this in Figure 1-4. With the LSP project still in the back of his mind, it dawned on David that an LSP-like protocol could be the perfect solution to the MxN problem. This reduces the MxN problem to an M+N problem (see Figure 1-5), now the integrations don’t need unique connectors for each LLM, the integrations and LLM(s) just need to be able to communicate over a shared protocol, allowing a common interface for integrations and LLMs to implement their own connectors. These connectors only need to interface with MCP servers, rather than every combination of LLM and integration, and so the MxN problem is reduced to an M+N problem.

David brought his idea to Justin Spahr-Summers, and the two began setting the foundations for what would become MCP. Later that year, before MCP’s initial release, Anthropic had an internal hackathon. Several teams adopted MCP right away and begin building all kinds of tools and other integrations to Claude Desktop using MCP. This rapid, organic adoption of MCP was, in hindsight, a sign of things to come only a month later, when MCP was first released to the public.

The Beginnings of MCP

Much of this story comes from an interview with David and Justin on The Latent Space Podcast, which you can find at the Latent Space.

Before MCP was released, a number of vendors got early access and were able to build clients and servers for their applications with the help of Anthropic. This immediate integration of MCP into several major IDEs and other tools used by developers daily was a major contributor to MCP’s runaway success. The other, arguably bigger contributor, was how easy MCP made it to build and distribute new tools as MCP servers. This ease of creation led to a near-immediate proliferation of MCP servers and tutorials on how to build servers. The ecosystem was born. This ease of use did lead to some issues, though, as some servers were built with little to no authentication or authorizations, others leaked sensitive information, and some just didn’t work as expected. This hasn’t stopped the community though, as more developers gained more experience, they began forming communities like r/mcp and r/modelcontextprotocol on Reddit, as well as a community-run MCP Discord server to share their experiences and help each other out, developing best practices and standards for development, and contributing to MCP documentation, protocol specifications, the SDKs, and more (like this book!).

Why This Book?

Given all these resources that have become available, what’s the point of this book? When I began building with MCP, there were plenty of tutorials of varying quality on building MCP servers. Most people were just integrating servers into applications that already had clients built in, like Cursor or Claude Desktop, but I wanted to build client support for MCP into an agent platform and framework I was working on. There were, surprisingly, very few resources (if any) on building clients. This inspired me to write this book, to help others understand both the model context protocol itself, deeply understand each of its components, and be able to wield the protocol via the Python SDK and fully participate and improve the larger MCP ecosystem and community.

The story of MCP’s beginnings is a bit entertaining, but it is also an important bit of context for truly understanding MCP’s place in the larger generative AI tooling ecosystem. It’s not REST API, it’s more akin to LSP that happens to also allow remote servers to exist. It’s a protocol, and not tied to any specific SDK or implementation. It exists to be open, and to allow agents to discover tools, prompts, data, and decide when and how to use them. Specifically, MCP was designed to make it easier to write integrations for LLMs, allow for 2-way communication between agents and servers, and to allow teams to easily distribute their tools, prompts, and data sources to other teams and the public.

The Problems MCP Addresses

MCP wasn’t just created to prevent users from having to copy and paste code between Claude Desktop and their favorite code editor. Sure, that was the immediate problem that led to the creation of MCP, but MCP was built in order to solve other, more general problems. The copy and paste problem that David faced and that inspired MCP’s creation was a part of a larger problem that MCP could solve: the need for integrations between Claude Desktop (and LLMs more generally) and other applications like IDEs. MCP addresses this problem by not only providing a common interface for discovering and using integrations, but also by making it extremely simple to build and distribute new integrations, largely due to being able to separate integrations from application code. MCP servers don’t just send integrations to the LLM, but they have to be able to receive information from LLMs to do things like executing the tool that the LLM chooses or providing lists of integrations to the host application and LLM.

Integrations

One of the most exciting developments of agents and generative AI has been how they give other applications superpowers. You can ask your notes questions, you can ask your IDE to write a function for you, you can use an agent to organize your calendar, or prune your files. However, these uses all require integrations between the application and the LLM that powers the agent. Historically, these integrations were built by the application’s developers and tightly coupled to the application’s codebase. This causes several downstream issues that this section will explore in more detail, but the one of the most important of these is that if an application wants to support multiple models, developers will have to write a connector for each integration for each model.

This is is the MxN problem that we discussed in the previous chapter, and at some point makes building integrations untenable. MCP addresses this problem with the MCP server. The server provides a common interface for both discovering and using integrations such as tools, prompts, and data sources. This means that application developers using MCP no longer have to write custom connectors for each model and for each integration that they want to support. Instead, they can just connect to the server, get the lists of integrations the server provides, and then call the same function (e.g. for tools in Python: use_tool() with the tool name and arguments passed as parameters to the function) to use the integration. This “universal connector” architecture is often described as a “USB-C” architecture for integrations, because the connector is the same for all integrations.

Integrations or Tools?

In this chapter, we’ve talked about integrations and tools somewhat interchangeably. Tools are a type of LLM integration, and are units of deterministic code (typically functions) that are called and executed by the LLM. Integrations are a more general term that includes tools but also anything else, such as data sources as prompt context, that can be integrated into an LLM request.

Distribution

Previously, teams working with agents had to build their own tools and connectors, and because these were often bespoke and environment-specific, they were not reusable and thus difficult to distribute among teams, let alone the public. With MCP, tools, prompts, data resources,and more can be shared either indirectly via a code-sharing platform like GitHub or as a running server that models can connect to directly. Because MCP unifies the interface for discovering and using integrations, the integrations themselves can be decoupled from application code, and any agent consuming the integrations just needs to discover what is available and then use the appropriate integration.

This unlocks a force multiplier effect for teams and individuals. Anyone can make their datasets or tools AI-ready by writing an MCP server and sharing it with the world. Teams within a large organization can either dedicate themselves to writing MCP servers and distributing them for use to other teams within the organization, or teams can collaborate on MCP servers and share them throughout the organization, standardizing tools, prompts, and data for the entire business.

2-Way Communication

Like many communication protocols, MCP is designed for 2-way communication. This means that MCP servers can not only send information to the host application and LLM, but that they can receive information from them. This is necessary for autonomous communications protocols, because both parties often need to send each other data to do basic things like establishing a connection or requesting a list of available tools from a connected MCP server. MCP mandates two-way communication in the protocol specification itself, by defining the full connection lifecycle, including initialization requests from client to server, and the appropriate response from server to client, as one example of 2-way communication at the level of the protocol itself. Figure 2-1 shows the full connection lifecycle between client and server in MCP, covering the initialization, operation and shutdown phases.

Figure 2-1. MCP’s connection lifecycle consists of three phases: Initialization, operation, and disconnection. Within each phase, messages are passed between client and server in a series of requests and responses, demonstrating the bidirectional nature of client-server communication in MCP.

Because MCP is a protocol, MCP itself just requires that communication between the client and server is bidirectional. The implementation is handled by transports, which are responsible for handling the underlying details of client-server communication, such as maintaining connections and passing messages over the established communication channel. MCP comes with two default transports: Streamable HTTP which allows for connections to remote MCP servers, and stdio, which is used for local MCP servers. Users, are able to implement their own transports as well, and can be plugged right into the existing MCP SDKs. You’ll learn more about transports and how to build them in Chapter 5.

Real-World Examples of MCP

When deciding whether to use MCP, just like any other open technology, it’s important to evaluate the community and ecosystem around the technology. For open source technologies, this can help you decide if the technology is worth the time and effort to learn and use, if it has a strong community that can rapidly respond to bugs and build new features, and if it is stable enough to include in your production applications. Typically, you can get a feel for this by looking at the number of users, who is using it, how many people are contributing to development, and how the technology is being used in the wild.

MCP is a very new protocol, but already has a strong community and ecosystem. The community-run Discord server mentioned earlier in the chapter has around 1000 members as of this writing, the two subreddits have over a combined 70,000 subscribers, and the official MCP specification GitHub repository has over 4,800 stars. It also has over 150 active issues, where proposals to change the protocol are discussed. More importantly, MCP has more than 350 official MCP servers listed on its official MCP server repository, and just under 650 community-built servers. On its official list of example clients, there are 76, many of which were available at MCP’s official launch. But what lead to this rapid adoption?

While the generative AI field is as hot as its ever been, the vibrant MCP ecosystem isn’t just a product of the hype. Upon launch, MCP became supported by a number of major applications both from Anthropic and from other vendors, like Claude Desktop, Cursor, and Zed. Having working implementations of MCP in applications favored by developers, already widely using Anthropic’s Claude family of models because of its coding abilities, was crucial in encouraging rapid early adoption of the protocol. In addition, the protocol made it incredibly easy to build and share tools, prompts, and data resources via the server, which was released with ample documentation for building and incorporating via several SDKs. From that, a wealth of resources for getting started with building MCP servers followed, and then frameworks like FastMCP came along (with v1 incorporated into the official Python SDK) to further simplify MCP server development.

These are the seeds, planted by Anthropic and nourished by the community, that grew like weeds into the vibrant, thriving ecosystem we see today. Many servers and client integrations are focused on developers, like AWS’ wealth of MCP servers supporting AWS developers, the official GitHub MCP server for intelligently managing GitHub repositories, and Linear’s official MCP server for interacting with Linear projects and tickets. But it’s not just developers that are benefiting from MCP, Canva released an MCP server to allow agentic software to help designers with their process, Uberall’s MCP server augments agents that work with salespeople, the FetchSERP’s MCP server brings agents tools for doing SEO analysis, web scraping, and more, a boon for growth marketers.

It’s not just servers that show real-world uses of MCP. Client integrations bring MCP tools to specific host applications that have already implemented agentic workflows. This allows those agents to become even more powerful, giving them the gift of tool use. Many of these, like Cline, Cursor, GitHub Copilot, and Windsurf are IDEs or IDE plugins that allow augmenting the code assistant workflows used in these applications. Beyond editors, chat applications like Slack and BoltAI, the popoular API testing client Postman, and desktop assistants like Witsy and 5ire all have MCP clients, allowing users to enhance their experiences with these applications by adding the capabilities provided by MCP servers.

A Developer’s MCP Use Case

Because MCP is geared towards the developers building with it, it probably makes sense that the most well-established tools and use cases serve developers. When MCP was released publicly, the popular AI-powered IDEs all added support for adding MCP servers to their respective agents. This has been a boon for developers, who can now add even more knowledge and abilities to the code assistants as long as their model of choice supports features like tool calling. Let’s consider a Python developer who uses AWS heavily, especially the AWS CLI and their CDK infrastructure-as-code (IaC) library and is using the Cursor IDE. Cursor has had built-in support for MCP servers and has since around the time MCP was first released in November 2024, and so our developer can add any servers they want to their IDE. Since this developer uses AWS, they can pick from over a dozen official AWS MCP servers that provide access to documentation, tooling (like CDK Nag), and more. From those, they might choose the CDK MCP server which can, among other things, provide an interface to the expansive CDK documentation, give advice on CDK best practices given existing code, and assist with using Lambda Powertools within a CDK application. They could also include the AWS documentation MCP server to get more accurate answers to questions about AWS and the AWS API MCP server to allow their agent to run AWS commands given natural language prompts. Then, they could decide to add one of several LSP MCP servers to give their agent access to all the LSP features that their IDE also has access to, like code completions, and maybe even a memory MCP server to create a personality for their agent.

Too Many Tools

Due to tool name collisions, overlapping definitions and use cases, and other factors, tool choice accuracy (how often an agent picks the correct tool given a prompt) can be negatively affected when there are a large amount of tools. Before you add a dozen servers to your agent of choice, make sure you note or otherwise evaluate (using a tool like Promptfoo) the tool choice accuracy of your agent before and after adding servers.

The possibilities are as close to endless as could be: if none of the hundreds of available MCP servers fit your specific need, this book will give you the tools to build your own and share it with the world. One of those tools is an understanding of the architecture of MCP, which at the highest level consists of three major components: the client, the server, and the transport. All three will be introduced in the next section.

The Architecture of MCP

The Model Context Protocol follows a client-server architecture, in which a client application like an IDE or chat agent can connect to a local or remote server and exchange messages with it. While most are familiar with this architecture from the simplified form of how browsers interact with websites, a look at the design of the Language Server Protocol can provide a good introduction to the MCP architecture. So we will start with a short discussion of the overall LSP and MCP architecture, comparing and contrasting the two, then we will discuss the major components of the protocol itself, what they do individually, and how they work together. These components are the host application, the MCP client, the MCP server, MCP transports, and then we will talk about some development tools that have popped up to help with MCP development.

From LSP to MCP

In the beginning of the chapter, during the discussion about the origins of MCP, we learned that MCP was inspired by the Language Server Protocol, something that is ubiquitous in modern IDEs and code editors today. Before LSP, it was common to have language-specific IDEs partially because language support had to be built separately for each language an IDE was to support — another classic example of the MxN problem (see Figure 1-4). LSP solved this by establishing a protocol in which host applications could communicate with language servers running in separate processes via a standard JSON-RPC communication interface. This is done with client code that is included with the host application and manages connections to language servers, and servers that are launched and run as separate processes. Language servers provide a any of a number of additional capabilities to the host application for the language that the server supports using a universal messaging language and format. Figure 2-2 shows the interactions between a host application and several language servers using LSP.

Figure 2-2. The LSP architecture consists of a host application (the IDE) with a client and 1 or more language servers. The host application emits events to the client, which can turn them into a request or notification to the language server. The language server responds to a request with a response, which the client relays to the host application.

If you are coming into this book with some existing knowledge of MCP, this architecture will look very familiar. Like LSP, MCP involves host applications, client software that manages connnections to servers, servers that provide additional capabilities to the host application, and a JSON-RPC communication layer. The architectural influence of LSP is clear, but MCP does have some significant differences. While LSP focuses on extending the capabilities of IDEs, MCP is more agnostic to the kind of host application that uses it. The only requirement to using MCP is a soft one: the host application should be agentic in some way, but there is nothing to stop you from using it in a non-agentic host system. With LSP, servers are typically run as processes local to the host application. When you start your IDE, your language servers are also started up on your machine. MCP, on the other hand, supports both local and remote servers. When a user adds a local server to a host application like Claude Desktop, they add a server definition that includes the command for starting the server as well as any start-time parameters the server may require and messages are sent over a local transport layer, typically stdio. Remote servers, on the other hand, are defined by a URL and often support some form of authentication, like API keys or OAuth tokens, using remote transport layers like Streamable HTTP to pass JSON-RPC messages.

Streamable HTTP

The Streamable HTTP transport is the default transport layer for remote MCP servers and is supported out of the box by the official MCP software development kits. This allows MCP servers to run on their own process while accepting multiple outside connections. Communication is done with familiar HTTP methods (specifically GETs and PUTs), with an optional flag that enables server-sent events-style (SSE) streaming communication between the client and remote server. You can learn more about the transport [in the official documentation](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) and in Chapter 5.

These can be hosted by a third party on their own web servers or within an organization within their own private network. This really unlocks the power of agentic AI, because now tools can be easily distributed as easily as REST APIs are for traditional web applications. Figure 2-3 shows how host applications, clients, and servers interact in MCP. If it looks like a copy of Figure 2-1, that’s because it essentially is, and underscores the deep inspiration LSP had on MCP’s design. In this image, the MCP client is attached to the host application, and it acts as a nexus point between the host application (along with its LLM) and the MCP server. The host application can call the client, which can then send a request to the server and then receive a response from it, however elicitations and sampling (which you will learn more about in Chapter 3) reverse this order. Clients and servers can also send notifications to each other, which don’t require a response from the other party.

Figure 2-3. The MCP architecture consists of a host application with a client and 1 or more MCP servers (only one client instance may connect to a single server). The host application calls the client, which can turn them into a request or notification to the MCP server. The MCP server responds to a request with a response, which the client relays to the host application. With elicitations and sampling, the MCP server cal also send requests to the client which will handle them with the application or LLM and send a request back to the server.

In Part 1 of this book, we will be focusing on the structure of MCP itself, how it works, and how to use MCP’s features through the official Python SDK. To do so, we will break the MCP architecture down into its major components: the host application and MCP client, the MCP server, and the MCP transport. In Part 2, we will work together to build a project that spans all of these components, giving you hands-on experience with every aspect of the protocol. The following sections will introduce each of these major components along with their uses and how they fit into the larger protocol.

The Host Application and MCP Client

We will start with host applications and the MCP client. Of course, host applications aren’t technically part of the MCP architecture, but they are necessary to actually use MCP. Host applications are any applications that host an MCP client and use it to extend their capabilities by interacting with MCP servers. There are no restrictions to what host applications can be; some common application types are IDEs, chatbot applications like Claude Desktop, frameworks for building agents, and AI-powered terminals like Warp.

Clients are a part of the host application, and are responsible for managing connections to MCP servers as well as routing requests from the LLM to the appropriate server and back to the LLM. A client instance maintains a 1:1 connection with a single server, relaying messages between the host application and server. Clients can determine which server features make it to the host application and LLM. Some might only allow a server’s tools to be used regardless of which of the MCP building blocks (tools, resources, and prompts) are available, while others may allow all of a server’s building blocks to be used.

Client or Client

Some sources may refer to any applications that consume the context provided by MCP clients as clients, and in certain contexts, this is a valid usage. However, because this book is focused heavily on the code, we separated the “client” into two distinct concepts: the host application and the MCP client. The host application is the application that hosts the MCP client code and interacts with an LLM to provide functionality to its users. Outside of the interaction point with the LLM, the rest of the application code may be completely agnostic to the presence of an MCP client. The MCP client, then, is the specific code within the host application that connects to MCP servers and communicates with them.

Clients also provide a few few features to servers, augmenting their functionality. These features are sampling, roots, and elicitation. Sampling allows a server to make calls to the host application’s language model. This allows servers to provide complex, AI-enabled workflows within their tools. As an example, consider report generation. Let’s assume you have an MCP server that has access to your organization’s database of sales data. You could prompt your application to build a report of the last 6 months of sales, then it would call the sales reporting tool, which would retrieve your raw data from the database, then send it along with templates stored in the server back to the client along with a prompt, having the host LLM generate the report given the prompt, raw data, and report template. This allows the server to control the prompt and assets like report templates that lend the server its main functionality while not saddling it with a separate dependency on a language model. See Figure 2-3 for a visual representation of this workflow.

Figure 2-4. This sequence diagram shows an example sampling workflow. In it, the user asks the host application for a sales report. The host’s LLM chooses to call the sales reporting tool, which triggers the client to send a tool execution request to the server with the tool name and parameters. The server runs the code, and in its response returns the data, a report template, and a prompt. The client then can forward everything to the host LLM to generate the report and then return the result to the user.

Roots allow clients to mark specific parts of the filesystem that a connected server has access to. These can be useful across many workflows, such as automatically restricting a server’s access to a specific project directory or allowing users to select specific files and directories to make available to the application and thus any connected servers. It’s a powerful way to provide filesystem context to servers that need it, however it is not a secure way to limit the blast radius of a malicious server. This is because the MCP specification only says that servers should respect any roots provided by the client, but does not require it. This probably serves as a compatibility feature for servers that don’t access the filesystem, but roots being ignorable make them a poor substitute for a proper access control system.

Elicitations are a relative newcomer to the MCP client feature set. Elicitations are similar in purpose to sampling: where sampling allows the server to make calls to the host application’s language model, elicitations allow the server to reach back into the host application via the client and request input from the user. This means that servers, when connected to clients that suport the feature, can construct not only complex LLM-powered workflows, but also workflows that include human feedback, providing even more flexibility and out-of-the-box functionality to host applications as long as the application’s client supports it.

The client exists to provide a connection between the host application and the MCP server. It manages the connection itself, relays messages between the host’s language model and the server, and provides features that enable more complex workflows to be run by the server itself. The server is the other major component that you will encounter as an MCP developer. In fact, you will typically spend the most time working with servers, whether you are installing them for personal use, connecting them to your own agentic applications, or building them for others to use.

The MCP Server

Servers in MCP are the most visible part of the MCP architecture. Like their predecessors in LSP, MCP servers are responsible for providing a wide range of capabilities to the host application that is connected to it. In LSP, these capabilities are typically language-specific and include things like code completion, documentation tooltips, and other things along those lines. In MCP, however, servers don’t need to run local to the host application, and servers have a much broader range of capabilities they can augment the host application with. These capabilities typically fall into three categories: actions, data, and language model prompts. These represent things the host application can do, what it can do them with, and how to interact with the language model, respectively, and fit into the three major MCP server building blocks: tools, resources, and prompts.

Tools represent actions. These are typically self-contained functions that can be called by the host application’s language model. Their names and descriptions are provided to the LLM via the client, and based on the user prompt, the LLM can choose to call the tool or not. If the tool is called, it’ll run wherever the MCP server is running, with the results being sent back to the client and application. Tools are the most common building block in MCP servers, and are the most versatile and common way to augment an application’s capabilities by expanding the actions it can take.

Resources represent data. They can come in the form of text files, blob files (like PDFs), database schemas, code documentation, and more. If tools are the verbs of the MCP server’s grammar, then resources are the nouns. Resources can be used directly to provide more context to the LLM, or used by tools to operate on the data, or even as a cache for reducing the size and cost of your LLM calls.

And, finally, prompts are the language model prompts that can be used by the host application to interact with their own LLM. While this may seem really similar to sampling on the client side, they actually serve very different purposes. Sampling allows the server to directly call the LLM being used by the host application, while server-provided prompts are controlled by the application: it can decide when and how to use the prompts provided by the server. These are useful when you have a server built for a specific service or application and the builders know it well enough to have tested and built optimized prompts for interacting with said service.

Warning

As with other external tools like packages and traditional APIs, MCP servers can provide additional attack vectors for hackers to access your data and systems. An example attack is the rug pull, where an originally safe server is made malicious by the server’s owner. This is less problematic with servers running locally, but it is a risk that should be considered and mitigated when using MCP servers both locally and remotely. Chapter 4 covers server security in more detail.

While not quite a building block in the same way that tools, resources, and prompts are, MCP servers also provide utilities that help with server development and debugging. In a strong nod to MCP’s inspiration in LSP, MCP servers can provide a completions capability, which provides autocompletion suggestions to users for prompts and resources. MCP servers may also implement logging capabilities, which allow clients to receive and filter structured logs from the server they’re connected to. The final server utility is pagination. If you’ve worked with web APIs (especially REST) or directly with databases before, you’ve most likely encountered pagination already. Pagination allows servers to return a subset of a response to the requesting client along with a token that represents the current position in the overall response data, which can help improve performance when dealing with large datasets both locally and remotely.

Servers will be covered in greater detail beginning in Chapter 5, and you can expect to see more about the major building blocks and utilities, and how to build support for them there. The next major component of the model context protocol is the transport layer.

The MCP Transport

Transports in MCP implement the protocol’s communication layer. At the most basic level, they are responsible for sending and receiving messages between the client and server. MCP SDKs come with two default transports: stdio for local connections and Streamable HTTP for remote connections. While these are the most commonly used, the protocol allows for the implementation of custom transports as well, which can support use cases for which the default transports are less well-suited, as long as the underlying communication channel being used allows for bidirectional message passing. Transports may be stateless (as in the case of the stdio transport) or optionally stateful (as in the case of the Streamable HTTP transport). For stateful transports, the transport is responsible for maintaining a session between the client and server and may support things like resuming after a network interruption, or supporting multiple clients connecting to the same server. All transports must manage the entire connection lifecycle, which is covered in Chapter 5.

Supporting Multiple Transports

When you are building a client or a server, you are not limited to using a single transport. By supporting multiple transports, you are giving your users the flexibility to choose the best transport for their use case. Anthropic strongly recommends that the stdio transport should always be supported, and you can add support for the streamable HTTP transport if you plan to deploy a remote server or have your application support adding remote servers.

The main functionality of transports, however, is to manage connections and handle messages. No matter which transport is used, the messages sent between the client and server are always JSON-RPC messages. This means that the transport is responsible for loading the messages into the correct format for the client and server to understand, something the built-in transports handle on their own. There are three main types of messages in MCP: requests, responses, and notifications. Requests can be sent from either a client or a server, and are used to initiate some action from the connected entity. Responses are replies to requests, and these hold either the result of an operation or error information if that operation has failed. Notifications are one-way messages and can be sent by either the client or the server, typically notifying the receiver of some change in state.

As with any part of the protocol, you are able to build your own transports and can use them just like you would use the built-in ones. This is less common, but can be worth doing if you have unique needs, especially around performance and security. Transports should be thought of as a security boundary, and if you have security needs beyond what is provided by the built-in transports, building you own is a good option. You could also build your own if you are integrating MCP with an existing system that already is using a communication protocol or messaging system. You will learn more about the structure of transports and messages in Chapter 5.

The MCP Connection Lifecycle

Before moving on to the next chapters, let’s briefly cover the MCP connection lifecycle. This lifecycle governs how each of the MCP components handle creating, using, and closing a connection which is underscored by the names of the major phases of the connection lifecycle:

Initialization: The client and server exchange messages to establish which protocol versions they’re compatible with, to share and negotiate supported capabilities, and to share any implementation details that may be relevant to the connection.

Operation: This is the main “loop” where everything happens. Based on the negotiated capabilities from the initialization phase, the client sends a request to the server, and the server replies with a response, which could include the results of the requested operation or an error if there was a failure.

Shutdown: During this phase, the client terminates the protocol connection, with the transport layer being responsible for communicating this termination to the server.

If you’re using the built-in transports for your client or server, the connection lifecycle is almost entirely managed for you, and this becomes less important. Instead, you can just focus on connecting and disconnecting from the appropriate built-in session manager provided by the SDK in your language of choice. The protocol also enables authorization at the transport layer, and transport developers who want to include authorization are required to implement [OAuth 2.1](https://oauth.net/2.1/) authorization support. These topics will all be covered in more detail in Chapter 5.

Protocol Utilities & Other Developer Tools

Part of the story of MCP’s rapid ascent is due to the tooling around it, both built by Anthropic and by outside developers in the MCP ecosystem. In the protocol itself is support for special utility messages that can be used by both clients and servers, while outside of it are SDKs, testing and debugging tools, frameworks, and more. These tools, especially the frameworks early on, made interacting with the protocol very simple, very early, contributing to the protocol’s rapid update. More recent tools focus on improving the developer experience, allowing for major overhauls on the official SDKs, directly testing MCP servers, visualizing logs, and similar developer-focused tasks. This will be crucial to MCP’s continued adoption beyond the already stunning explosion in popularity, since developers will be working on and/or near the protocol.

Developers, developers, developers, developers!

Steve Ballmer

While servers have their own specialized utilities, the base protocol also supports some common utilities that both clients and servers can use, typically to get feedback about or change the state of the connection. The first utility is cancellation. This takes the form of a notification message from either the client or the server that holds a request ID and reason for cancellation, and is used to cancel requests that are still being worked on. Ping is available as a parameterless JSON-RCP message which are sent either by the client or the server to check if the connection is still alive. And finally, progress reporting is also available at the protocol level and allows clients or servers to display an accurate progress bar back to the user that communicates the progress of any operation. You will learn more about all of these in chapters 3 and 4.

Note

If you want to use these tools, you’ll have to ensure that the client and server both support them.

For those of you building with MCP (hopefully everyone reading this book!), there are already a number of developer tools designed to make your life easier. In addition to SDKs in several languages (such as Python, Typescript, Go, Rust, C#, and several more) The primary way you will interact with MCP as a developer is through the official software development kits. These “implement the protocol,” using the language they’re in to manage, handle, and route the messages that are at the core of MCP, and are available in Python, Typescript, Go, Rust, C#, and more. All of these handle the core functionality of clients, servers, and transports. Another popular tool is MCP Inspector. This gives you a web UI client and proxy that you can use to connect to your servers via any built-in transport and visually test them, which you can see in Figure 2-7.

Figure 2-5. The MCP Inspector UI, showing the connection setup in the left menubar, the server function selectors in the top menu, and the tool inspector UI in the center.

The MCP Inspector UI can give you deep visibility into the workings of an individual MCP server. Being able to inspect resources, prompts, tools, and roots, being able to run tools, send ping messages, and even run sampling and elicitation workflows allows you to manually test every possible aspect of an MCP server. You will learn more about MCP Inspector in [Link to Come].

In the next chapters, you will get an inside look at how the major MCP components work, what their code can look like with the Python SDK, and how to implement your own client and servers. This background—​what inspired MCP, the problems it solves, how it’s being used today, and how it was designed—​should give you more context and a deeper understanding of the components you are going to learn about next.

Chapter 3. Making an Intelligent Application Agentic with MCP Clients

A Note for Early Release Readers

With Early Release ebooks, you get books in their earliest form—the author’s raw and unedited content as they write—so you can take advantage of these technologies long before the official release of these titles.

This will be the 3rd chapter of the final book. There is a GitHub repository in progress with code examples.

If you’d like to be actively involved in reviewing and commenting on this draft, please reach out to the editor at jleonard@oreilly.com.

When working with the Model Context Protocol, you will typically be working with (or building!) clients or servers. On some projects, you may be able to build both. To really understand the protocol and use it to its full potential on your projects, you will need to be conversant with all components of the MCP architecture. In this chapter, you will learn about the the consumer side of this architecture: host applications and clients.

First, we will dig in to host applications: what are they, what do they do, and what kinds of applications can benefit from incorporating MCP? Then, we will examine the client itself. This is what makes the host application a host: it hosts the client(s) which in turn enable the host application to communicate with MCP servers.

There is one catch, though: a single client can only talk to a single server. If you want to access multiple servers, you will have to launch multiple client instances or have a single client that only makes short, temporary connections to servers. We will examine the pros and cons of each approach, and which use cases each approach best serves.

Then, you will see a very simple host application and the client that it hosts. We will go line-by-line to help you understand the structure of a client and how it provides functionality to the host application. Finally, you will learn about best practices when it comes to building a client into a host application, ensuring that your application remains secure, reliable, and responsive.

The Host Application

Your application can be anything that hosts the client(s) that will manage connections to MCP servers, from a simple chatbot script that takes some user input and sends it to an LLM for a response to a fully-featured IDE like Cursor or Windsurf.

Thanks to the modularity of MCP, there are very few restrictions on what your host application does in order to support MCP. It only needs to communicate with an LLM and host one or more MCP clients. If you plan to use tools, then the LLM your host application uses should also support tool calling.

In the example below, you will see a barebones host application. All it does in its current state is take user input in a constant loop and pass it on to an LLM. You can find this script and all other code for this chapter in ch3 in the book’s Github repo.

Example: A Simple Host Application

import os

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()

LLM_API_KEY = os.environ["LLM_API_KEY"]
anthropic_client = Anthropic(api_key=LLM_API_KEY)

print("Welcome to your AI Assistant. Type 'goodbye' to quit.")

def main():
    while True:
        prompt = input("You: ")
        if prompt.lower() == "goodbye":
            print("AI Assistant: Goodbye!")
            break
        message = anthropic_client.messages.create(
            max_tokens=4096,
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                }
            ],
            model="claude-sonnet-4-0",
        )
        for response in message.content:
            print(f"Assistant: {response.text}")

if __name__ == "__main__":
    main()

Here we have the beginnings of an MCP host: a very simple script that takes some user input and sends it to an LLM (in this case, Anthropic’s Claude 3.5) and prints a response. Let’s go through it line by line.

In lines 1-4, we import a few packages: os for loading environment variables, anthropic for the Anthropic client class, and dotenv for temporarily loading key-value pairs from a file into environment variables. This allows you to store sensitive information like API keys outside of the code in a file conventionally called .env in a KEY=VALUE format.

Warning

Do not commit your .env files to version control, as this can expose your API keys to the public, leading to consequences like unauthorized use and high costs for pay-as-you-go API access. If you accidentally do this, immediately log in to your key provider and de-authorize the compromised key or keys.

In lines 6-9, the application calls load_dotenv() which does the work of loading your keys into environment variables, then gets the loaded key by accessing the os.environ dictionary. That key is then used to instantiate the Anthropic client.

After providing a welcome message to the user in line 11, the code enters an infinite loop. Line 14 requests a prompt from the user, while lines 15-17 check the prompt for the exit phrase. If it is found, the application prints a goodbye message to the user and exists.

Lines 18-28 handles communication with the model. This is a pattern you’ll see over and over again when making LLM calls from code. From the Anthropic client, you access the .messages.create() function, and provide it with parameters like max_tokens to control the upper limit of tokens to generate in response to the user’s query, system to set the system prompt, shaping how the model responds to your user, messages which is a list of dictionaries of prompts with a specified role (notice that we set the role to “user” and the content to the prompt from the user), and, finally the model specifies which model should be used.

The messages interface within the larger Anthropic API is very powerful, and the create() function has a number of other parameters that you can experiment with to tune the kinds of responses that you get from the model. You can read more at the official Anthropic documentation.

The final lines, 29-30, loop through the responses generated by the model, printing the text content of any messages received by the Anthropic client.

Next, let’s shift from the Anthropic model client to the MCP client that our application will host.

The Client

When you’re building support for MCP into your applications, the client is one of the most important components that you will be building and working with. Clients allow for communication between MCP servers and your application, providing the interface between the server, your application, and the large language model you’re using.

Because clients act as the interface between your application and MCP servers, it is up to them to decide which server features they will support. As of this writing, MCP servers can provide to a host application:

resources, which represent data such as text files, log files, and more

prompts

tools, which are executable code that an agent or model can run

sampling, a way for a server to request chat completions from the host application’s model,

images

context, which provides built-in MCP capabilities to tools

You can learn more about each of these in Chapter 4: The Server.

In addition to this, clients can also support roots, which define boundaries (such as a specific filesystem location) within which connecting servers should operate. Roots are not strictly enforced, so it is up to the server to respect them and up to the client user to review potential servers before using them.

As of this writing, there are two main transport mechanisms supported by MCP: Standard Input/Output (stdio) and Streamable HTTP. We will cover how to support each official mechanism in this chapter, and in Chapter 5 you will learn more about the transport layer itself, how official transport implementations work, and how to implement your own transport.

Note

Anthropic has recently released a beta version of MCP connector, which promises to allow users to access remote MCP servers directly via the Anthropic SDK’s Messages API. While it seems like this could reduce the need for custom clients, as of this writing it only supports tools and remote MCP servers which, as you will see in the chapter on MCP servers, could represent a security risk. It also ties the user to Anthropic models like Claude, which may not fit your particular use case.

Basic Client Design

In MCP, a client handles all communication and connections to a server. Because client-server connections are one-to-one, it is typically best to build a client class. A client needs to at least do the following things:

Connect to a server

Discover the server’s resources

Make those resources available to an LLM

Beyond these basic operations, it is often helpful to implement the following features:

Authentication

Resource filtering

Model agnosticism

For the rest of this chapter, you will learn how to build each of these features and what they enable for your applications. Let’s start by sketching out an interface for a client class. If you are following along in the GitHub repository, the code for this section is in client.py, with all host application code in agent.py. We will continue with this structure throughout the rest of the chapter.

...
class MCPClient:
    def __init__(self) -> None:
        pass

    async def connect(self) -> None:
        """
        Connect to the server set in the constructor.
        """
        pass

    async def get_available_tools(self) -> list[Any]:
        """
        Retrieve tools that the server has made available.
        """
        pass

    async def use_tool(self, tool_name: str, tool_args: list | None = None):
        """
        Given a tool name and optionally a list of argumnents, execute the
        tool
        """
        pass

    async def disconnect(self) -> None:
        """
        Clean up any resources
        """
        pass

In this class, you created the signatures for 3 methods, excluding the constructor:

connect() - this will initialize your connection to the server. The implementation will look different based on what you choose for your transport layer

get_available_tools() - this will use the client’s server connection to retrieve the tools that the server makes available

use_tool() - this function will call a tool given its name with any arguments provided by the caller

Another good name for your client could be MCPServer, as is done in the official Python examples, since from the perspective of the host application the instantiated object would represent an MCP server. This can be confusing, so for this example I decided to call it MCPClient. In your projects, choose what makes the most sense for you and your users.

Notice that this skeleton started with support for tools. Tools are one of the major MCP primitives, and arguably the most popular use case for MCP. Tools can help give agents their agency, augment the knowledge of LLMs, and much more, so we’re starting with implementing tool support and will move on to show how to support all of the different resources an MCP server can provide.

[!NOTE] The three MCP primitives are tools, prompts, and resources.

Initializing the Client and Connecting to a Server

Before writing any code, let’s think about how we want to connect to a server. This means thinking about which transport we want to use. In MCP, a transport is an implementation of the protocol’s transport layer and manages how messages are sent between the client and server.

As of this writing, MCP has two major built-in transport implementations: stdio and streamable HTTP. The stdio transport is suitable for use cases where you expect servers will be run alongside the host application, because it uses standard input and output streams to carry communication between the client and server. This is the most common transport, as it is simple to write for and because most commercially available applications that host MCP clients expect the user to install and run servers themselves.

Note

The Python MCP SDK also includes a websocket transport and an HTTP Server-Sent Events (SSE) transport, which is being superseded by Streamable HTTP. Because of this, we will just cover connecting with Streamable HTTP, but the patterns established here along with Anthropic’s backwards compatibility guide should give you enough to support both remote transports as a client developer. All of these transports will be explored in more detail in chapter 5.

The built-in Streamable HTTP transport, on the other hand, is best suited for applications where the MCP servers they consume can’t be expected to be run alongside their host applications. This is most apparent in platform development and in hosted tools.

When developing a platform or framework for building and deploying downstream products, you can’t guarantee that MCP servers will be deployed to the same machine as the application. If they’re not, then a stdio client will never be able to talk to a server. Some organizations are experimenting with hosting MCP servers on their own network, exposing them to the public. If you wish to use those, you’ll want to build Streamable HTTP support into your client.

Warning

Warning: Any remote transports, including Streamable HTTP, need to be properly secured to prevent security problems. This includes authenticating the connection and validating origin headers.

First, let’s look at the simplest case: connecting via stdio. In this section, you will build out the constructor and connect() methods for the MCPClient class. After that, you will do the same to build a Streamable HTTP client, which can support connecting to remote MCP servers.

After that, we will look at an example of a client supporting all three methods and how to choose the appropriate one.

Connecting with stdio

When you connect to an MCP server with stdio, the client will launch the server as a subprocess and then read messages from its standard input and send messages via its standard output. Luckily for us, the MCP Python SDK includes all the structures we will need to construct our client and connect via stdio.

First, let’s set up the Client’s constructor:

# client.py
from contextlib import AsyncExitStack
from typing import Any

from mcp import ClientSession

class MCPClient:
    def __init__(self, name: str, command: str, server_args: list[str], env_vars: dict[str, str]=None) -> None:
        self.name = name
        self.command = command
        self.server_args = server_args
        self.env_vars = env_vars
        self._session: ClientSession = None
        self._exit_stack: AsyncExitStack = AsyncExitStack()
        self._connected: bool = False
    ...

In this constructor, you set up several important properties that will enable you to connect to and interact with an MCP server. The first, name just gives a human-readable name to the instantiated client. This is useful for logging, especially if you are maintaining connections to multiple servers, because it allows you to easily identify the specific origin of any emitted logs. It is not, however, required for an MCP client.

The next 3 parameters are used for setting up the server. command identifies the executable to be run to initiate the server. Most commonly, servers will run with either python or node (or npx depending on your local installation). It is common enough that for most use cases, your client could define an enum to ensure that a supported command is being used. server_args is a list of all the command-line arguments that are to be passed to the command executable. This will include, at the very least, the path to the server file. If the file takes command-line arguments, let’s say --port 8000 --verbose, your server_args list will look like ["server.py", "--port", "8080"]. Next is a dictionary of environment variables in env_vars. These work like typical environment variables, and you can even unpack your system’s environment variables into this dictionary with {**os.env}. This isn’t recommended, however, as it could expose sensitive information to your server.

We also include some “private” connection management properties, such as _session, which will hold the client session, _exit_stack which manages the asynchronous connection contexts (more on that later), and _connected which will prevent us from connecting to a server when we’re already connected.

The next step is to implement the connect() and disconnect() methods. Here, you’ll use the Python MCP SDK’s built-in connection management objects to make and hold the server connection.

# client.py
from contextlib import AsyncExitStack
from typing import Any

from mcp import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client

class MCPClient:
    ...
    async def connect(self) -> None:
        """
        Connect to the server set in the constructor.
        """
        if self._connected:
            raise RuntimeError("Client is already connected")

        server_parameters = StdioServerParameters(
            command=self.command,
            args=self.server_args,
            env=self.env_vars if self.env_vars else None
        )

        # Connect to stdio server, starting subprocess
        stdio_connection = await self._exit_stack.enter_async_context(stdio_client(server_parameters))
        self.read, self.write = stdio_connection

        # Start MCP client session
        self._session = await self._exit_stack.enter_async_context(ClientSession(read_stream=self.read, write_stream=self.write))

        # Initialize session
        await self._session.initialize()
        self._connected = True

    async def disconnect(self) -> None:
        """
        Clean up any resources
        """
        if self._exit_stack:
            await self._exit_stack.aclose()
            self._connected = False
            self._session = None

This may look complex, especially if you don’t have any experience with writing asynchronous Python. Let’s break down what we’re doing here to make it more digestible, starting with the connect() method. The very first thing this method does is check the _connected() property. This will prevent the user from trying to open an already-open connection. Then, we instantiate a StdioServerParameters object, passing in the properties we set in the constructor. The rest of the code is creating the necessary processes and connections to run and use the MCP server. We do this via _exit_stack, which holds an instance of AsyncExitStack.

[!NOTE] AsyncExitStack allows you to manually nest asynchronous context managers without using the more common async with context manager syntax. This is an ideal choice because you can dynamically add more asynchronous context managers to the stack and can safely release all the resources on the stack in order with just one call. The latter feature is extremely useful because we can determine when to unwind the stack, rather than having to rely on the code leaving the scope of each context manager. This is done by calling the enter_async_context() function.

First, we open a connection to the stdio server, which starts the subprocess on which the MCP session will run. We pass in an instance of the stdio_client that is instantiated with the server_params built just before it. Then, we unpack the results of that call into self.read and self.write which represent the session’s read and write streams, respectively. The next step is to start the MCP client session, again by calling enter_async_context(), but this time with an instance of ClientSession with the read and write streams set to self.read and self.write. This is stored in self._session, which we then initialize with initialize(). This method handles initiating the connection to the server, advertising the client’s capabilities to the server, checking the protocol version supported by the server, and telling the server that the client has been successfully initialized. Finally, we set self._connected to True.

In sum, when writing the connection code for a stdio server, you:

create an instance of StdioServerParams

start the stdio server subprocess in an asynchronous context

start the MCP client session in another (nested) asynchronous context and store it in self._session

initialize the session itself

In this example, we also created the disconnect() method, which just calls self._exit_stack.aclose() to close all server connections in order, then sets self._connected to False and self._session to None. With this, you can cleanly connect and disconnect to MCP servers using the stdio transport at will. While you can’t do anything with the servers just yet, you’ve completed the most complex part. Give yourself a pat on the back. Now we will do the same thing, but in support of the Streamable HTTP transport.

Connecting with Streamable HTTP

Third-party example Streamable HTTP is the main transport designed for use with remote MCP servers. Originally, HTTP Server-Sent Events (SSE) were the standard for connecting to remote servers, but a number of production issues with them made their continued support untenable. Those issues, and the architecture of both transports, will be covered in Chapter 5.

When you connect to a remote server with Streamable HTTP, you will be connected via a single endpoint defined by the server, and then responses can be either immediate standard HTTP responses (triggered by sending a POST to the server and not requiring an always-open connection) or streaming SSE responses (triggered by sending an empty GET to the server), the latter being optional and dependent on the server’s implementation.

Note

“Wait,"” you might be saying, “I thought Streamable HTTP replaces HTTP+SSE, how is this different?"” We will go into the upgrades in Chapter 5, but the major difference with this is that getting an SSE response is optional - both by the client that has to request a streaming response, and by the server that can choose whether or not to support it.

To support Streamable HTTP, we will again focus on building the MCPClient’s constructor, connect(), and disconnect() methods. Let’s start with the constructor:

# client.py
from contextlib import AsyncExitStack
from typing import Callable

from mcp import ClientSession

class MCPClient:
    def __init__(self, name: str, server_url: str) -> None:
        self.name = name
        self.server_url = server_url
        self._session: ClientSession = None
        self.exit_stack = AsyncExitStack()
        self._connected: bool = False
        self._get_session_id: Callable[[], str] = None

This is eerily similar to the constructor for the stdio client. Because we’re not starting a process locally, we don’t need command or server_args, but we do need a location for the server, which server_url provides. The rest of the properties are the same in the stdio client.

Connecting and disconnecting from the server is also pretty similar to how you do it for the stdio client:

# client.py
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

class MCPClient:
    ...
    async def connect(self, headers: dict | None = None) -> None:
        if self._connected:
            raise RuntimeError("Client is already connected")

        # Connect to Streamable HTTP server
        streamable_connection = await self._exit_stack.enter_async_context(streamablehttp_client(url=self.server_url, headers=headers))
        self.read, self.write, self._get_session_id = streamable_connection

        # Start MCP client session
        self._session = await self._exit_stack.enter_async_context(ClientSession(read_stream=self.read, write_stream=self.write))

        # Initialize session
        await self._session.initialize()
        self._connected = True

    async def disconnect(self) -> None:
        """
        Clean up any resources
        """
        if self._exit_stack:
            await self._exit_stack.aclose()
            self._connected = False
            self._session = None

Hopefully this code looks very familiar - it’s almost exactly the same as the connect() and disconnect() code for the stdio client. What we do differently here is take an optional headers parameter and pass that with the server_url property to streamablehttp_client(), which along with the read_stream and write_stream returns a callback for retrieving a session ID from the server if it provides one. If the server supports it, this can be used for resuming broken sessions. We also don’t instantiate a StdioServerParams object, because the streamablehttp_client function doesn’t take a special parameters object, instead taking the URL and headers directly as parameters. Other parameters that would be of interest for a client developer include:

timeout - how long in seconds until HTTP operations time out, of type datetime.timedelta. Defaults to 30 seconds.

sse_read_timeout - how long in seconds to wait for an additional event before timing out, of type datetime.timedelta. Defaults to 5 minutes.

auth - this handles authentication and is of type httpx.auth. You will learn how to authenticate sessions with servers that support it in a later section.

While you are building out your client class, let’s take a look at one strategy for integrating it into a host chatbot application. We are going to use the stripped-down chatbot introduced at the beginning of this chapter to demonstrate how the MCP client fits in with a wider LLM-powered application. The only additions here will be to instantiate an stdio MCP client and open a connection to a made-up MCP server that provides calculator tools like add_two_numbers, subtract_two_numbers, multiply_two_numbers, and divide_two_numbers.

import asyncio
from pathlib import Path
...
mcp_client = MCPClient(
    name="calculator_server_connection",
    command="uv",
    server_args=[
        "--directory",
        str(Path(__file__).parent.resolve()),
        "run",
        "calculator_server.py",
    ],
)

print("Welcome to your AI Assistant. Type 'goodbye' to quit.")

def main():
    await mcp_client.connect()
    while True:
        prompt = input("You: ")
        if prompt.lower() == "goodbye":
            print("AI Assistant: Goodbye!")
            break
        message = anthropic_client.messages.create(
            max_tokens=4096,
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                }
            ],
            model="claude-sonnet-4-0",
        )
        for response in message.content:
            print(f"Assistant: {response.text}")
    await mcp_client.disconnect()

if __name__ == "__main__":
    asyncio.run(main())

All that we did here was instantiate the client and then call connect() to open a connection to the server. Then, after the chat loop is exited, we call disconnect() to close the connection. How would you change this for a Streamable HTTP server?

Note

When instantiating the client, our command is uv instead of python. uv is useful here because it can be run from anywhere and makes managing virtual environments and dependencies simple. While you can change the command to python or python3, be sure to check out uv.

Now let’s look at how to make use of everything an MCP server can provide to the client.

Interacting with MCP Server Capabilities

MCP servers can provide a wide variety of capabilities, including the MCP primitives tools, resources, and prompts. As a client developer, you will interact with them via the instantiated ClientSession object, and generally use the same two-step workflow for each of these: discovery and use. For each of these capabilities, discovery happens via a <primitive>/list request, which in the Python SDK is wrapped by the list_<primitive>s() methods. These calls, if the server provides the respective capability, will return a list of the available tools, resources, or prompts. Using the resulting capabilities requires a <primitive>/<verb> call, where the verb depends on the primitive being called. In the Python SDK, these calls are wrapped with methods of the form <verb>_<primitive>() such as call_tool() for tool/call.

Note

The protocol also allows notification messages to be sent by the server (as well as by the client), then it is up to the receiver to handle the notifications as it wishes. For Streamable HTTP connections, these notifications will be sent like any other message as defined by the transport, but will be sent as a JSONRPCNotification, and implementation of the JSONRPCMessage class.

While the available MCP SDKs wrap the various request calls in methods that your client can call directly, it’s often a good idea to wrap them in your own object methods so that you can add logging, retries, method parameters and any other customizations to your calls to the server, like passing any of the list_<primitive>() methods the optional cursor parameter for pagination.

Tools

Tools are the most common resource MCP servers provide, and with good reason: agentic workflows often require tools to augment the abilities of a base LLM. Tools are simply a deterministic function that an LLM can decide to call if needed. Naturally, the function can do anything you can think of doing in code, from calculating numbers to finding the weather at a given location. MCP servers allow clients to discover and use these tools.

To discover tools using the Python SDK, your client would just call self.session.list_tools() within the client, which returns a response object with a tools property. This holds a list of any tools made available by the server connected to the client, where tools have the properties name, description, inputSchema, annotations, and model_config. You can see the current structure of the Tool class in the MCP Python SDK’s types module.

Calling list_tools() directly can be fine for basic use cases, but if you’re building a client that will be used for different servers with different capabilities and possibly in different applications, it can be advantageous to write your own function that has some guardrails, logging, and anything else your users might need abstracted away. For example, you could implement the following function:

# client.py
import logging
...
logger = logging.getLogger(__name__)

class MCPClient:
    ...
    async def get_available_tools(self) -> list[dict[str, Any]]:
        if not self._connected:
            raise RuntimeError("Client not connected to a server")

        tools_result = await self._session.list_tools()
        if not tools_result.tools:
            logger.warning("No tools found on server")
        available_tools = [
            {
                "name": tool.name,
                "description": tool.description,
                "input_schema": tool.inputSchema,
            }
            for tool in tools_result.tools
        ]
        return available_tools
...
# agent.py
async def main():
    await mcp_client.connect()
    available_tools = await mcp_client.get_available_tools()
    print(f"Available tools: {", ".join([tool['name'] for tool in available_tools])}")
    while True:
        ...

This method is a very simple wrapper around the Session object’s list_tools() method which itself wraps the tools/list request. While simple, the example shows how you can implement some simple guardrails like the server connection status check, as well as more detailed logging like the empty tool result check. The method also converts the MCP-native tool object list into a list of dictionaries with the keys “name”, “description”, and “input_schema”. This ensures compatibility with the Anthropic messages API. You can extend this pattern to add retries, translate MCP’s tool format to another format compatible with another LLM, and more. Another helpful pattern that you will see later in this chapter is converting MCP’s Tool objects into your own objects where you can customize their structure, implement translation functions to support other LLMs, and more. In the main() function, we added a list comprehension to build a list of the available tool names and printed them to ensure that our code is working.

Now that your client has tools, what do you do with them? Use them, of course! Like in the get_available_tools() example, we will wrap the Session object’s call_tool() function and add just a bit of logging to it and handle the different available response types. call_tool() returns a CallToolResult, which is a list of any of the SDK’s content types:

TextContent - The result of the tool is any kind of text that you would return to user, and it’s stored in the text property.

ImageContent - If the result of the tool is an image, it will be returned as an ImageContent object, with the image data itself stored as a base64-encoded string in the data property

AudioContent- Some tools return audio content. Like ImageContent, the data is base64-encoded and lives in the data property

EmbeddedResource - Tools can return embedded resources as part of their response, typically for extra context or data caching. The resource data lives in the resource property, which can be either TextResourceContents (representing the text content of, say, a configuration file resource) or BlobResourceContents (representing the binary data of the resource as a base64-encoded string)

One key thing to keep in mind is that tool use requests return lists of content, and in some cases, such as with an EmbeddedResource response being used as additional context, that should be expected and handled as such. In the example below, you will wrap call_tool() and return string representations of each of the valid tool content response types.

Warning

For some use cases, handling the response types in the client itself may not be desirable. When building a client, consider your user: will they want or need control over how to handle the different content types?

# client.py
from typing import Any
from mcp.types import TextResourceContents

class MCPClient:
    ...
    async def use_tool(self, tool_name: str, arguments: dict[str, Any] | None=None) -> list[str]:
        if not self._connected:
            raise RuntimeError("Client not connected to a server")

        tool_call_result = await self._session.call_tool(name=tool_name, arguments=arguments)
        logger.debug(f"Calling tool {tool_name} with arguments {arguments}")

        results = []
        if tool_call_result.content:
            for content in tool_call_result.content:
                match content.type:
                    case "text":
                        results.append(content.text)
                    case "image" | "audio":
                        results.append(content.data)
                    case "resource":
                        if isinstance(content.resource, TextResourceContents):
                            results.append(content.resource.text)
                        else:
                            results.append(content.resource.blob)
        else:
            logger.warning(f"No content in tool call result for tool {tool_name}")
        return results

Here, we built a use_tool() method that, like the list_tools() method, first checks that the client is connected to a server session and raises an exception if it isn’t. Then we make the call_tool() call, passing in the tool’s name and arguments. On the next line, we added a debug statement so we know what tool is being called. If you want the user to decide how to handle the different content types, you could return the results immediately at this point. But for this example, we check that the content list is not empty, then iterate through each item in it. There, we check the item’s type and add the proper string representation to our results list. If content is empty, we show the user a warning, and then finally return the results list. Just like with list_tools(), wrapping call_tool() gives us much more control over what happens during the tool call.

Warning

When building agents that support MCP, keep a close eye on tool-calling and overall performance as you add tools to the agent. Performance tends to drop dramatically as more tools are added, typically due to an increased difficulty in selecting the correct tool for a prompt. This can be due to ambiguity or overlap in tool descriptions as well as descriptions and interfaces having too much information in aggregate for the LLM to process accurately.

If you’re building your client to serve a specific application, like the simplified chat application introduced in the beginning of this chapter, you will also have to use these calls. Many LLM APIs that support tool-calling have an optional tools parameter, making sending the tools available to a model simple. Other models require building a list of tool names, descriptions, and schemas into your system prompt, but for this example we are focusing on Anthropic’s Claude API, which does accept a tools parameter. This will be added to any user messages to the model, and then the resulting responses will be analyzed by the host application for any tool usage. If it’s found, the application will formulate a tool_result message and add it to a messages list that gets sent back to the model to generate the final result for the user.

the tools made available by the MCP server, and allow it to call them.

# agent.py
...
mcp_client = MCPClient(
    name="calculator_server_connection",
    command="uv",
    server_args=[
        "--directory",
        str(Path(__file__).parent.resolve()),
        "run",
        "calculator_server.py",
    ],
)

print("Welcome to your AI Assistant. Type 'goodbye' to quit.")

async def main():
    """Main async function to run the assistant."""
    try:
        await mcp_client.connect()
        available_tools = await mcp_client.get_available_tools()
        print(
            f"Available tools: {", ".join([tool['name'] for tool in available_tools])}"
        )

        while True:
            prompt = input("You: ")
            if prompt.lower() == "goodbye":
                print("AI Assistant: Goodbye!")
                break

            # Build conversation starting with user message
            conversation_messages = [{"role": "user", "content": prompt}]

            # Tool use loop - continue until we get a final text response
            while True:
                # Get LLM response
                current_response = anthropic_client.messages.create(
                    max_tokens=4096,
                    messages=conversation_messages,
                    model="claude-sonnet-4-0",
                    tools=available_tools,
                    tool_choice={"type": "auto"},
                )

                # Add assistant message to conversation
                conversation_messages.append(
                    {"role": "assistant", "content": current_response.content}
                )

                # Check if we need to use tools
                if current_response.stop_reason == "tool_use":
                    # Extract tool use blocks
                    tool_use_blocks = [
                        block
                        for block in current_response.content
                        if block.type == "tool_use"
                    ]

                    # Execute all tools and collect results
                    tool_results = []
                    for tool_use in tool_use_blocks:
                        print(f"Using tool: {tool_use.name}")
                        tool_result = await mcp_client.use_tool(
                            tool_name=tool_use.name, arguments=tool_use.input
                        )
                        tool_results.append(
                            {
                                "type": "tool_result",
                                "tool_use_id": tool_use.id,
                                "content": "\n".join(tool_result),
                            }
                        )

                    # Add tool results to conversation
                    conversation_messages.append(
                        {"role": "user", "content": tool_results}
                    )

                    continue

                else:
                    # No tools needed, extract final text response
                    text_blocks = [
                        content.text
                        for content in current_response.content
                        if hasattr(content, "text") and content.text.strip()
                    ]

                    if text_blocks:
                        print(f"Assistant: {text_blocks[0]}")
                    else:
                        print("Assistant: [No text response available]")

                    break
    finally:
        await mcp_client.disconnect()

if __name__ == "__main__":
    asyncio.run(main())

This code has a number of changes to handle getting and using any required tools. First, during the initialization phase, we get all available tools from the server we’re connected to with get_available_tools(). Then, we transform the list of Tool objects in to a list of plain dictionaries to properly feed it back to the model. This is done at the top of the module in order to only pull the list of tools once. If you expect the server or servers you connect to to change the tools they provide while connected, you will want to either get a fresh list of available tools for every user input to the model or have your client listen to and handle a list_changed notification from the server.

Next, we’ve updated the code where we create the user message to the model to pass in the list of tools to the tools parameter, as well as setting tool_choice to a dictionary with the key "type" set to ` “auto”. You don’t have to do this, since auto is the default setting and allows the model to decide which tool or tools to use to answer the user’s query. You can also set it to `"any", where it will use any (but at least one) tool that is available to it, or a tool name and type "tool" to force the model to use a specific tool, or "none" to prevent the model from using any available tools.

After this, we inspect the result and initialize a potential tool use message that will be sent back to the model. If the message response has a stop_reason of tool_use, then we know the model is waiting for a response with the results of the tool or tools that it is requesting. We create tool use message blocks from the response contents, and then execute any tools in a loop. From the results, we build a dictionary with the key type set to "tool_use", tool_use_id set to the ID from the model’s tool use message response, and content to the result of the tool call. All of those are then added to the conversation_messages list. Once we’ve made all of the tool calls, the results and the original user question are sent beck to the LLM for evaluation. Because there are potentially multiple tool calls that could be made before returning to the user (such as asking a model what 5 * 3 + 7 is), we wrap all of this in a while loop.

If we don’t have any tool call responses in the tool use message, either no tools were necessary or that we’ve come to the end of the tool use phase. Either way, we extract the text blocks from the latest response and print them if available. Finally, the whole thing is wrapped in a try/finally block to ensure that the connection is disconnected from the proper scope when the user quits the application.

In this section, you learned about MCP server-provided tools, how to discover them on a server, how to use them, and how to use them in the context of a chat application. In the next section, you will learn about resources and how you can use those in your application like you just did with tools.

Resources

As you learned in Chapter 2, MCP resources can play many roles, some of which have yet to be fully explored by the community members themselves, like as a cache to optimize prompt size. Basically, resources give a host application and the LLM it interacts with access to data served by the MCP server. This can be database records, images, text files, and more. Resources also have several actions you can take on them:

resources/list or list_resources()

requests a list of available resources from the server

resources/templates/list or list_resource_templates()

requests a list of resource URI templates from the server. These allow you to construct a resource’s URI dynamically based on other information you might have. In the Python SDK, these strings look like f-strings, with variable sections enclosed in curly braces.

resources/read or read_resource()

accesses the file provided by the server, sending the data in text or blob format to the LLM.

resources/subscribe or subscribe_resource()

subscribes the client to updates to to the resource URI you specify in the call.

resources/unsubscribe or unsubscribe_resource()

unsubscribes the client from updates to the resource specified by resource URI in the parameters.

Note

See the tupac project by Tim Kellogg for a minimalist client implementation that utilizes MCP resources as a cache for efficient prompting.

First, we will implement wrappers for the two list methods. We will continue the pattern that we established in the last section for get_available_tools(): a light wrapper with a few basic guardrails and logging that can be used as a base for more complex workflows.

# client.py
from mcp.types import Resource, ResourceTemplate

class MCPClient:
    ...
    async def get_available_resources(self) -> list[Resource]:
        if not self._connected:
            raise RuntimeError("Client not connected to a server")

        resources_result = await self._session.list_resources()
        if not resources_result.resources:
            logger.warning("No resources found on server")
        return resources_result.resources

    async def get_available_resource_templates(self) -> list[ResourceTemplate]:
        if not self._connected:
            raise RuntimeError("Client not connected to a server")

        resource_templates_result = await self._session.list_resource_templates()
        if not resource_templates_result.resources:
            logger.warning("No resource templates found on server")
        return resource_templates_result.resources

These two methods are almost identical to each other and to the get_available_tools() method. Again, each function checks for a connection before making any server requests, then calls the appropriate list method, checks for a result, and then returns it, including a warning if the result is empty. Like the list_tools() method, both of these list methods take an optional cursor parameter for paginating results. We will see this pattern again for prompts in the next section, so think about how you could expand the functionality of the list method wrappers and how you could also make the code more concise.

Note

Resources and resource templates have almost the exact same set of properties: uri, name, description, mimeType, size, annotations, and model_config. For resource templates, instead of a uri, they have a uriTemplate property.

Next, we’ll implement a wrapper of the read_resource() session method, and following the pattern the previous example established, we’ll call it use_resource(). This code will take a resource URI (or a filled in URI from a resource template), read the resource and return it to the caller either as a text string or a base64-encoded data string.

# client.py
from mcp.types import BlobResourceContents, TextResourceContents

class MCPClient:
    ...
    async def get_resource(self, uri: str) -> list[BlobResourceContents | TextResourceContents]:
        if not self._connected:
            raise RuntimeError("Client not connected to a server")
        resource_read_result = await self._session.read_resource(uri=uri)

        if not resource_read_result.contents:
            logger.warning(f"No content read for resource URI {uri}")
        return resource_read_result.contents

This method requires little extra: just our now-standard connection guardrail and empty result warning. Similarly to get_available_tools(), instead of transforming the MCP BlobResourceContents and TextResourceContents classes, we return them as-is to the user, since they provide valuable information about the type of string being returned and this keeps with the minimal processing philosophy of this set of examples.

The MCP protocol also allows clients to subscribe to resource change notifications, however as of this writing, notification handling is only partially implemented in the Python SDK and there is no provided way to route notifications to handler functions or register handlers. This does seem to be available in the Typescript SDK, however. There is a message handler callback in the ClientSession class, but it handles all incoming messages, including requests, responses, and notifications.

So far, you learned how to get a list of available resources and resource templates and, when armed with a resource URI, how to read that resource. But how would you use them in a host application? Remember that resources primarily exist to provide additional context to user queries, allowing your application to do things like adding a Bash script to a user query about their script. To see this in action, let’s use our minimal host chatbot application example from the beginning of the chapter again, but this time we will look for the key “context:” in the user prompt and use that as context to the model request. When the application starts and any time the user issues the command “refresh,” the application will get the current list of resources from the server and then display them. When the application finds “context:” in the user prompt, it will use what comes after to select a specific resource and add it as context to the original user question.

If you’re following along with the code in the Github repository, you’ll notice that the code for this next example has been split into two files: client.py and agent.py. This is to help reduce the complexity of the code, and to make it more readable. The following code example will begin with agent.py, which now contains the Agent class. This class will hold the previous main() function as run(), and will also hold a few utility functions for handling resources. These are extract_resource_name() which extracts the resource name from the user prompt, display_resources() which prints the available resource names and descriptions to the user, and refresh_resources() which refreshes the available resources and returns them as a dictionary.

# agent.py
...
class Agent:
    def __init__(self, mcp_client: MCPClient, anthropic_client: Anthropic):
        self.mcp_client = mcp_client
        self.anthropic_client = anthropic_client
        self.available_resources = {}

    async def _select_resources(self, prompt: str) -> list[str]:
        """Use LLM to intelligently select relevant resources."""
        if not self.available_resources:
            return []

        resource_descriptions = {
            name: resource.description or f"Resource: {name}"
            for name, resource in self.available_resources.items()
        }

        selection_prompt = f"""
Given this user question: "{prompt}"

And these available resources:
{json.dumps(resource_descriptions, indent=2)}

Which resources (if any) would be helpful to answer the user's question?
Return a JSON array of resource names, or an empty array if no resources are needed.
Only include resources that are directly relevant.

Example: ["math-constants"] or []
"""

        try:
            response = self.anthropic_client.messages.create(
                max_tokens=200,
                messages=[{"role": "user", "content": selection_prompt}],
                model="claude-sonnet-4-0",
            )

            response_text = response.content[0].text.strip()
            # Extract JSON from response (handle case where LLM adds explanation)
            if "[" in response_text and "]" in response_text:
                start = response_text.find("[")
                end = response_text.rfind("]") + 1
                json_part = response_text[start:end]
                selected_resources = json.loads(json_part)
                return [r for r in selected_resources if r in self.available_resources]

        except Exception as e:
            logger.warning(f"Failed to select resources with LLM: {e}")

        return []

    async def _load_selected_resources(
        self, resource_names: list[str]
    ) -> list[dict[str, Any]]:
        """Load the specified resources."""
        context_messages = []

        for resource_name in resource_names:
            if resource_name in self.available_resources:
                print(f"LLM selected resource: {resource_name}")
                try:
                    resource = self.available_resources[resource_name]
                    resource_contents = await self.mcp_client.get_resource(
                        uri=resource.uri
                    )
                    for content in resource_contents:
                        if isinstance(content, TextResourceContents):
                            context_messages.append(
                                {
                                    "type": "text",
                                    "text": f"[Resource: {resource_name}]\n{content.text}",
                                }
                            )
                        elif content.mimeType in [
                            "image/jpeg",
                            "image/png",
                            "image/gif",
                            "image/webp",
                        ]:  # b64-encoded image
                            context_messages.append(
                                {
                                    "type": "image",
                                    "source": {
                                        "type": "base64",
                                        "media_type": content.mimeType,
                                        "data": content.blob,
                                    },
                                }
                            )
                        else:
                            print(
                                f"WARNING: Unable to process mimeType {resource_contents.mimeType} for resource {resource_name}"
                            )
                except Exception as e:
                    print(f"Error loading resource {resource_name}: {e}")

        return context_messages

    async def _refresh_resources(self) -> None:
        available_resources = await self.mcp_client.get_available_resources()
        self.available_resources = {
            resource.name: resource for resource in available_resources
        }

    async def run(self):
        try:
            print(
                "Welcome to your AI Assistant. Type 'goodbye' to quit or 'refresh' to reload and redisplay available resources."
            )
            await self.mcp_client.connect()
            available_tools = await self.mcp_client.get_available_tools()
            await self._refresh_resources()

            while True:
                prompt = input("You: ")

                if prompt.lower() == "goodbye":
                    print("AI Assistant: Goodbye!")
                    break

                if prompt.lower() == "refresh":
                    await self._refresh_resources()
                    continue

                selected_resource_names = await self._select_resources(prompt)
                context_messages = await self._load_selected_resources(
                    selected_resource_names
                )

                # Build conversation with initial user message and any context
                user_content = [{"type": "text", "text": prompt}]
                if context_messages:
                    user_content.extend(context_messages)

                conversation_messages = [{"role": "user", "content": user_content}]

                # Tool use loop - continue until we get a final text response
                while True:
                    # Get LLM response
                    current_response = anthropic_client.messages.create(
                        max_tokens=4096,
                        messages=conversation_messages,
                        model="claude-sonnet-4-0",
                        tools=available_tools,
                        tool_choice={"type": "auto"},
                    )

                    # Add assistant message to conversation
                    conversation_messages.append(
                        {"role": "assistant", "content": current_response.content}
                    )

                    # Check if we need to use tools
                    if current_response.stop_reason == "tool_use":
                        # Extract tool use blocks
                        tool_use_blocks = [
                            block
                            for block in current_response.content
                            if block.type == "tool_use"
                        ]

                        print(f"Executing {len(tool_use_blocks)} tool(s)...")

                        # Execute all tools and collect results
                        tool_results = []
                        for tool_use in tool_use_blocks:
                            print(f"Using tool: {tool_use.name}")
                            tool_result = await self.mcp_client.use_tool(
                                tool_name=tool_use.name, arguments=tool_use.input
                            )
                            tool_results.append(
                                {
                                    "type": "tool_result",
                                    "tool_use_id": tool_use.id,
                                    "content": "\n".join(tool_result),
                                }
                            )

                        # Add tool results to conversation
                        conversation_messages.append(
                            {"role": "user", "content": tool_results}
                        )

                        # Continue loop to get next LLM response
                        continue

                    else:
                        # No tools needed, extract final text response
                        text_blocks = [
                            content.text
                            for content in current_response.content
                            if hasattr(content, "text") and content.text.strip()
                        ]

                        if text_blocks:
                            print(f"Assistant: {text_blocks[0]}")
                        else:
                            print("Assistant: [No text response available]")

                        # Exit the tool use loop
                        break
        finally:
            await self.mcp_client.disconnect()

if __name__ == "__main__":
    mcp_client = MCPClient(
        name="calculator_server_connection",
        command="uv",
        server_args=[
            "--directory",
            str(Path(__file__).parent.parent.resolve()),
            "run",
            "calculator_server.py",
        ],
    )
    agent = Agent(mcp_client, anthropic_client)
    asyncio.run(agent.run())

This specitific integration started just like the integration you did in the Tools section: connect to the MCP server via the client, discover available primitives, like resources in this case, and load those primitives into memory. We added two new utility functions to the agent:

_select_resources(): This creates a name: description dictionary of available resources, and then uses the LLM to select any relevant resources given the user’s prompt.

_load_selected_resources(): This takes a list of resource names from _select_resources() and for valid resource names, it uses the client to get the resource contents. If those contents are text, we create a content block with the text. If it is a blob, we check that the mimeType matches any of the Anthropic model API-supported image types and if so, construct a content block with that data and add that to a final list of messages to be send back to the caller.

_refresh_resources(): this refreshes the available resources and stores them in the available_resources dictionary on the Agent object.

In the run() function, we updated the welcome message to let the user know that a message with only the word refresh will refresh the resource list. The resource list is then filled with the results of the refresh() method. Then once we’re prompted by the user, we, we get the LLM to select any resources pertinent to the user prompt with _select_resources(). Then we call _load_selected_resources() to add any named resources to the list of context_messages. Context messages provide additional context to the LLM, and we bundle them with the user prompt in a list. This list of user prompt and selected resources is then sent to the LLM, and the model will be able to use the resource contents to answer the user’s prompt if needed.

Note

When doing things like adding context to a user prompt, you should not create a separate user message like you do with the user prompt. Instead, you convert the user message to its full form: a nested dictionary with a type key and a content key, which holds a list of dictionaries and add any context messages to that list.

There are several improvements you can make to this example. To stretch your knowledge, try to implement support for listing and using resource templates along with normal resources. Then, try to support multiple context files instead of just one, either with regular text parsing or even another LLM call. You can do some more cleanup: currently, our application does 1 extra potentially expensive LLM csll for each user input in order to select the most relevant resources for the user’s query. How could you make this more efficient?

Note

As you might have picked up on, many of these improvements are typical software engineering best practices and not really specific to generative AI. While AI engineering is becoming a discipline unto itself, it is still software engineering at its core and thus the practices and tools you have as a software engineer will also serve you well building for AI.

Prompts

Prompts are the last of the three main primitives provided by MCP servers. These are designed to be controlled by the application user, who should be able to pick out which prompt or prompts they want to use, similar to the interface you saw in the host application example in the previous section. Each prompt definition has a unique identifier name, an optional human-readable description, and optional arguments, which is a list of dictionaries with a name, optional human-readable description and an optional required boolean indicating if the argument is required or not. It is then up to the client implementation or host application using the prompt to inject values for each of the arguments. These are called dynamic prompts.

The Python SDK simplifies handling prompts for us, with the Session class’s list_prompts() method returning a list of Prompt objects, which have the prompt definition properties listed in the previous paragraph. When you get a specific prompt with get_prompt(), you provide the prompt’s name and arguments and get back a list of PromptMessage objects, which are ready-to-use messages that can be converted to a plain dictionary and sent directly to the LLM. Take a look at the host integration example in the last section, paying close attention to the structure of the messages that are sent to the LLM. They are dictionaries with role and content keys, and those are the two required properties of a PromptMessage object. The content itself can be TextContent, ImageContent, AudioContent, or an`EmbeddedResource`. Embedded resources are just what you think they are: resources that are embedded in a message content block, just like our example host application built in the previous section.

Let’s continue the pattern from the previous two sections and wrap these two methods in our own client:

# client.py
from mcp.types import Prompt

class MCPClient:
    ...
    async def get_available_prompts(self) -> list[Prompt]:
        if not self._connected:
            raise RuntimeError("Client not connected to a server")

        prompt_result = await self._session.list_prompts()
        if not prompt_result.prompts:
            logger.warning("No prompts found on server")
        return prompt_result.prompts

There isn’t too much to explain about this pattern that you haven’t seen before. We set up a connection check, get the list of prompts, and ensure that the result list isn’t empty before returning it to the user. The get_prompt() wrapper will be very familiar as well. We will get the prompt name and arguments from the user, load the prompt from the server with the provided arguments, and return the MCP PromptMessage object back to the application.

# client.py
from mcp.types import PromptMessage

class MCPClient:
    ...
    async def load_prompt(self, name: str, arguments: dict[str, str]) -> list[PromptMessage]:
        if not self._connected:
            raise RuntimeError("Client not connected to a server")
        prompt_load_result = await self._session.get_prompt(name=name, arguments=arguments)

        if not prompt_load_result.messages:
            logger.warning(f"No prompt found for prompt {name}")
        else:
            logger.warning(f"Loaded prompt {name} with description {prompt_load_result.description}")
        return prompt_load_result.messages

This is again similar to how we retrieved resources in the last section. We have our standard guardrails, we pass the name and arguments unchanged to the session’s get_prompt() method, and add some logging.

Note

As with resources and tools, in the protocol there is support for notifications when the list of available prompts has changed. Unfortunately, there is also no user-facing API for handling and listening for these notifications from the server, although clients can subscribe to them. If you want to use them, you will have to handle routing to a callback on your own, but there are a few examples in the SDK’s source code you can follow, such as how sampling callbacks are routed in Session._received_request(). Source code. You could also create a new function that handles all messages and pass that to the client constructor in the message_handler parameter. This would have to handle all messages, but would allow you to build notification handling logic as well.

Like the other primitives, there are as many ways to integrate prompts into your application as you can imagine, and then some. To show off how you can use the available prompt functionality in your application, we will use the same base application as the last two examples so that the usage of the server and prompt primitives is clear and not obscured by too much application logic. We will also use a similar user interaction pattern to the one we used in the Resources section. In this pattern, the application gets a list of available primitives (in this case, prompts), displays them to the user, and allows the user to select which one to use within their input. That prompt will be sent as-is by itself to the LLM and the application will handle and display the response.

with prompts.

# agent.py
class Agent:
    def __init__(self, mcp_client: MCPClient, anthropic_client: Anthropic):
        self.mcp_client = mcp_client
        self.anthropic_client = anthropic_client
        self.available_resources = {}
        self.available_prompts = {}

    async def _select_resources(self, user_query: str) -> list[str]:
        """Use LLM to intelligently select relevant resources."""
        ...

    async def _select_prompts(self, user_query: str) -> list[dict[str, Any]]:
        """Use LLM to intelligently select relevant prompts."""
        if not self.available_prompts:
            return []

        prompts = [
            prompt.model_dump_json() for prompt in self.available_prompts.values()
        ]

        selection_prompt = f"""
Given this user question: "{user_query}"

And these available prompt templates:
{json.dumps(prompts, indent=2)}

Which prompts (if any) would provide helpful instructions or guidance for answering this question?
Return a JSON array of prompt objects which have a name (string) and arguments (objects where the
keys are the named parameter name and value is the argument value), or an empty array if no prompts
are needed. Only include prompts that are directly relevant.

Example: [{{"name": "calculation-helper", "arguments": {{"operation": "addition"}}]}},
 {{"name": "step-by-step-math", "arguments": {{}}}}] or []
"""

        try:
            response = self.anthropic_client.messages.create(
                max_tokens=200,
                messages=[{"role": "user", "content": selection_prompt}],
                model="claude-sonnet-4-0",
            )

            response_text = response.content[0].text.strip()
            if "[" in response_text and "]" in response_text:
                start = response_text.find("[")
                end = response_text.rfind("]") + 1
                json_part = response_text[start:end]
                selected_prompts = json.loads(json_part)
                return [
                    p for p in selected_prompts if p["name"] in self.available_prompts
                ]

        except Exception as e:
            logger.warning(f"Failed to select prompts with LLM: {e}")

        return []

    async def _load_selected_resources(
        self, resource_names: list[str]
    ) -> list[dict[str, Any]]:
        ...

    async def _load_selected_prompts(self, prompts: list[dict[str, Any]]) -> str:
        """Load the specified prompts as system instructions."""
        system_instructions = []

        for prompt in prompts:
            if prompt["name"] in self.available_prompts:
                print(f"Using prompt: {prompt['name']}")
                try:
                    prompt_content = await self.mcp_client.load_prompt(
                        name=prompt["name"], arguments=prompt["arguments"]
                    )

                    # Extract the prompt text
                    prompt_text = ""
                    for message in prompt_content:
                        if hasattr(message.content, "text"):
                            prompt_text += message.content.text + "\n"
                        elif isinstance(message.content, str):
                            prompt_text += message.content + "\n"

                    if prompt_text.strip():
                        system_instructions.append(
                            f"[Prompt: {prompt['name']}]\n{prompt_text.strip()}"
                        )

                except Exception as e:
                    print(f"Error loading prompt {prompt['name']}: {e}")

        return "\n\n".join(system_instructions)

    async def _refresh(self) -> None:
        available_resources = await self.mcp_client.get_available_resources()
        self.available_resources = {
            resource.name: resource for resource in available_resources
        }
        available_prompts = await self.mcp_client.get_available_prompts()
        self.available_prompts = {prompt.name: prompt for prompt in available_prompts}

    async def run(self):
        try:
            print(
                "Welcome to your AI Assistant. Type 'goodbye' to quit or 'refresh' to reload and redisplay available resources."
            )
            await self.mcp_client.connect()
            available_tools = await self.mcp_client.get_available_tools()
            await self._refresh()

            print(
                f"Loaded {len(self.available_resources)} resources and {len(self.available_prompts)} prompts"
            )

            while True:
                prompt = input("You: ")

                if prompt.lower() == "goodbye":
                    print("AI Assistant: Goodbye!")
                    break

                if prompt.lower() == "refresh":
                    await self._refresh()
                    continue

                # Select relevant resources and prompts
                selected_resource_names = await self._select_resources(prompt)
                selected_prompt_names = await self._select_prompts(prompt)

                # Load relevant resources and prompts
                context_messages = await self._load_selected_resources(
                    selected_resource_names
                )
                system_instructions = await self._load_selected_prompts(
                    selected_prompt_names
                )

                # Build conversation with initial user message and any context
                user_content = [{"type": "text", "text": prompt}]
                if context_messages:
                    user_content.extend(context_messages)

                conversation_messages = [{"role": "user", "content": user_content}]

                # Tool use loop - continue until we get a final text response
                while True:
                    create_message_args = {
                        "max_tokens": 4096,
                        "messages": conversation_messages,
                        "model": "claude-sonnet-4-0",
                        "tools": available_tools,
                        "tool_choice": {"type": "auto"},
                    }

                    if system_instructions:
                        create_message_args["system"] = system_instructions

                    current_response = self.anthropic_client.messages.create(
                        **create_message_args
                    )

                    # Add assistant message to conversation
                    conversation_messages.append(
                        {"role": "assistant", "content": current_response.content}
                    )

                    # Check if we need to use tools
                    if current_response.stop_reason == "tool_use":
                        # Extract tool use blocks
                        tool_use_blocks = [
                            block
                            for block in current_response.content
                            if block.type == "tool_use"
                        ]

                        # Execute all tools and collect results
                        tool_results = []
                        for tool_use in tool_use_blocks:
                            print(f"Using tool: {tool_use.name}")
                            tool_result = await self.mcp_client.use_tool(
                                tool_name=tool_use.name, arguments=tool_use.input
                            )
                            tool_results.append(
                                {
                                    "type": "tool_result",
                                    "tool_use_id": tool_use.id,
                                    "content": "\n".join(tool_result),
                                }
                            )

                        # Add tool results to conversation
                        conversation_messages.append(
                            {"role": "user", "content": tool_results}
                        )

                        # Continue loop to get next LLM response
                        continue

                    else:
                        # No tools needed, extract final text response
                        text_blocks = [
                            content.text
                            for content in current_response.content
                            if hasattr(content, "text") and content.text.strip()
                        ]

                        if text_blocks:
                            print(f"Assistant: {text_blocks[0]}")
                        else:
                            print("Assistant: [No text response available]")

                        # Exit the tool use loop
                        break
        finally:
            await self.mcp_client.disconnect()
...

This version of the code does all the things we set up in the introduction to this example. Starting with the main loops, we first call a _refresh_() function that calls the get_available_prompts() method that we built into the client, and fills the available_prompts attribute with a dictionary similar to the Resources example, where the keys are the prompt names and the values are the prompt objects. In the agent’s loop, we call _select_prompts() to get the LLM to select the most relevant prompts for the user’s query, just like in the Resources section, with just a few differences. First, we dump the Prompt object to a JSON string with model_dump_json(), allowing our selection prompt to have the full prompt object - including the description and arguments - to help it decide on the best prompt or prompts to use. Then we call _load_selected_prompts() to filter any hallucinated prompts, get any remaining prompts via the client, and then transform them into the system prompt format.

In this example, before calling the LLM, we create a dictionary of message arguments, then add the loaded prompts to those arguments (if there are any) with the system argument key. We then unpack these arguments in Anthropic’s create() call, but the rest of the agent remains the same.

Chapter 4. MCP Clients: Advanced Use and Best Practices

A Note for Early Release Readers

With Early Release ebooks, you get books in their earliest form—the author’s raw and unedited content as they write—so you can take advantage of these technologies long before the official release of these titles.

This will be the 4th chapter of the final book. There is a GitHub repository in progress with code examples.

If you’d like to be actively involved in reviewing and commenting on this draft, please reach out to the editor at jleonard@oreilly.com.

At the base of MCP, as well as the various SDK implementations of the protocol, is a system that sends and receives messages between clients and servers. In the Python SDK, all of the session methods we wrapped themselves wrap a send_request() method. This method can be found in mcp.shared.session.BaseSession, which both the client- and server-specific Session classes inherit from. Because of this, you could theoretically also send your own custom requests to a server that supports them, but will have to create your own Request, RequestParams, and Result classes to do so.

There are a few other methods you can make use of in the client session object. send_ping() sends a ping request to the connected server. The server will then return an EmptyResult within a ServerResult, which carries no data but signifies the presence of a working connection to the server. set_logging_level(), given a LoggingLevel object from MCP’s types library, can change the server’s logging level for the connection if the server allows it. Server-initiated logs are sent to the client via notifications, however unlike subscription notifications, logging notifications have their own parameter in the ClientSession constructor called logging_callback. You would implement your own logging callback function that adheres to the LoggingFnT protocol (source code) and pass it to the constructor via the logging_callback parameter:

Example 4-1. Creating a logging callback function in the client.

from mcp.types import LoggingMessageNotificationParams

class MCPClient:
    ...
    async def _handle_logs(self, params: LoggingMessageNotificationParams) -> None:
        if params.level in ("debug", "error", "critical", "alert", "emergency"):
            print(f"[{params.level}] - {params.data}")

    async def connect(self) -> None:
        """
        Connect to the server set in the constructor.
        """
        ...  # Removing setup code for brevity
        # Start MCP client session
        self._session = await self._exit_stack.enter_async_context(
            ClientSession(
                read_stream=self.read,
                write_stream=self.write,
                logging_callback=self._handle_logs,
            ),
        )

        # Initialize session
        await self._session.initialize()
        self._connected = True

Here, handle_logs() is a very simple function that checks the reported log level in the server-provided params, and if the level is within the set we provided, prints the level and data. This list of levels is not exhaustive, but MCP adheres to the syslog severity levels defined in RFC 5424 . In addition to level and data, LoggingMessageNotificationsParams also provides an optional logger name and model_config dictionary.

send_progress_notification() sends a notification to the server with some progress_token and the amount of progress as a float. Optionally, you can also send a total float and message string to the server. How these values end up looking will depend on the server you’re connecting to and what the server requires for progress updates.

Providing MCP Client Capabilities

In the previous section, you saw an example of implementing a callback to handle log messages from a connected MCP server. In this one, you’ll see this pattern repeated, but in service of providing client capabilities to the connected MCP server. There are three major capabilities that a client can provide to a server: sampling, where the server is able to request chat completions from the LLM the client is connected to via the client, roots, which indicate to servers what areas in the host application’s filesystem the server has access to, and elicitations which allow the connected server to request information from the user.

Servers are not required to use or respect these capabilities, but if your client provides them, you are required to advertise that your client does provide them, which happens during the initialization phase of the client-server connection. If you are using the Python SDK, this occurs automatically when you implement a callback function for the capability or capabilities that you are supporting and pass it to the ClientSession constructor via the appropriate parameter. For providing client capabilities, the general workflow is as follows:

In your client class, implement a callback function that will be called when the server requests it.

Ensure that the callback follows the established protocol for the capability, such as SamplingFnT.

Pass the callback function to the ClientSession constructor via the appropriate parameter, such as sampling_callback for sampling.

And that’s it. Of course, the majority of the effort comes in step 1, and will be unique depending on your use case, interaction flow.

Supporting Sampling

Sampling is a client capability that allows MCP server to make use of whichever LLM the client and its host application is connected to. This opens up agentic workflows from the MCP server, such as allowing nested tool choice and use, or for the server to ask the LLM a question and use that to choose or fill information into a prompt that it provides, or whatever else your imagination can come up with.

At the protocol level, this begins with the server sending a sampling/createMessage request to the client to initiate sampling. When the client receives this request, it should trigger a human-in-the-loop (HITL) workflow to get the user’s approval. Once the user has approved the request, the client can then forward the message to the LLM for a response. The response comes back to the client, which presents another opportunity for user approval before returning the response to the server. Once the response is approved, the client can return the response to the server.

Because this is a client-provided capability, there is slightly less work to do here than there is for using server-provided capabilities. Like you saw with logging, the ClientSession class provides a SamplingFnT protocol which defines the signature of the sampling callback function you will implement, and the constructor has a sampling_callback parameter that will take your callback to be used if and when a connected server sends a sampling request. When the client receives a sampling request, it will call your callback function with a context parameter of type RequestContext and a params parameter of type CreateMessageRequestParams, and expect either a CreateMessageResult or an ErrorData object to be returned. The CreateMessageRequestParams object contains the parameters for the sampling request:

messages: a list of SamplingMessage objects, which holds the properties role and content, which you will use to construct a message to send to your LLM.

modelPreferences: an optional parameter which gives the client a hint as to the preferred model to use for the sampling request along with several priority preferences (cost, speed, and intelligence). It is up to you to decide whether to respect this preference or not.

systemPrompt: also optional, this holds a server-provided system prompt that you can either use with your existing system prompt, on its own (overriding your own system prompt), or not at all.

includeContext: this is an optional string literal that determines whether the server wants to include the context of a request in the response, either from the connected server or all servers.

temperature: an optional float to control the temperature of the LLM’s generation.

maxTokens: an optional integer to control the maximum number of tokens to use for generation.

stopSequences: an optional list of strings that signify the end of a generation.

The CreateMessageResult object that your callback will return is simpler: it includes the role of the message, its content (which can be TextContent, ImageContent, or AudioContent), a string model representing the model that was used to generate the response, and a stopReason that indicates why the generation stopped.

Warning

Because of the risk of giving an external server access to the LLM your application is using and that you are paying for, both myself and Anthropic strongly recommend that you implement some form of human-in-the-loop (HITL) approval process before a server is allowed to send a request to an LLM via your client and application.

In this example, we will follow the general workflow above, by creating a simple sampling callback that implements the SamplingFnT interface and passing it into the ClientSession constructor. In order to show the basic implementation, we will refrain from implementing the HITL workflow, since it is highly dependent on the host application and its user interface. In practice, the protocol defines several “shoulds” that you should consider when developing your sampling callbacks:

You should implement a HITL workflow to get user approval before sending a message to the LLM and before returning the response to the server.

You should respect all model preference hints provided by the server.

You should implement rate limiting.

While these aren’t required by the protocol, when implementing this in the wild following these guidelines will help you safeguard your users and your LLM from abuse. As such, you will see more complex implementations of sampling callbacks in the later project-based chapters.

Example 4-2. A sampling callback function in the client.

# client.py
from anthropic import Anthropic
from mcp.shared.context import RequestContext
from mcp.types import (
    CreateMessageRequestParams,
    CreateMessageResult,
    ErrorData,
    SamplingMessage
    TextContent
)

class MCPClient:
    def __init__(self, name: str, server_url: str, llm_client: Anthropic) -> None:
        self.name = name
        self.server_url = server_url
        self._session: ClientSession = None
        self.exit_stack = AsyncExitStack()
        self._connected: bool = False
        self._llm_client = llm_client

    async def _handle_sampling(
        self,
        context: RequestContext[ClientSession, None],
        params: CreateMessageRequestParams
    ) -> CreateMessageResult | ErrorData:
        messages = []
        for message in params.messages:
            if isinstance(message.content, TextContent):
                messages.append(
                    {"role": message.role, "content": message.content.text}
                )
            else:
                # Handle other content types if needed
                messages.append(
                    {"role": message.role, "content": str(message.content)}
                )

        response = self._llm_client.messages.create(
            max_tokens=params.maxTokens,
            messages=messages,
            model="claude-sonnet-4-0",
        )

        # Extract content from the response - content is a list of content blocks
        if response.content and len(response.content) > 0:
            content = response.content[0]
            if hasattr(content, "text"):
                content_data = TextContent(type="text", text=content.text)
            elif hasattr(content, "data"):
                content_data = BlobResourceContents(
                    type="blob",
                    data=content.data,
                    mimeType=content.mimeType,
                )
            else:
                # Fallback to string representation
                content_data = TextContent(type="text", text=str(content))
        else:
            # No content in response
            content_data = ""

        return CreateMessageResult(
            role=response.role, content=content_data, model="claude-sonnet-4-0"
        )

    async def connect(self) -> None:
        """
        Connect to the server set in the constructor.
        """
        ...  # Removing setup code for brevity
        # Start MCP client session
        self._session = await self._exit_stack.enter_async_context(
            ClientSession(
                read_stream=self.read,
                write_stream=self.write,
                logging_callback=self._handle_logs,
                sampling_callback=self._handle_sampling,
            ),
        )

        # Initialize session
        await self._session.initialize()
        self._connected = True
    ...
# agent.py
...
if __name__ == "__main__":
    mcp_client = MCPClient(
        name="calculator_server_connection",
        command="uv",
        server_args=[
            "--directory",
            str(Path(__file__).parent.parent.resolve()),
            "run",
            "calculator_server.py",
        ],
        llm_client=anthropic_client,
    )
    agent = Agent(mcp_client, anthropic_client)
    asyncio.run(agent.run())
...

In agent.py, we added an llm_client parameter to the MCPClient constructor, since the client will need access to the LLM client to route messages between the LLM and the server. The handler, _handle_sampling() constructs a message from any SamplingMessages sent by the MCP server to the client by extracting each message’s content and adding it to the messages list. The messages list is then sent to the LLM for a response via the _llm_client. The response is then extracted and transformed into either TextContent or BlobResourceContents and sent back to the server in a CreateMessageResult object.

Warning

This implementation is simplified, and not safe for deployment in the real world. Your handler should request permission from the user before sending a server’s prompt to the application’s model.

In the connect() method in client.py, we pass the sampling handler method into the ClientSession constructor via sampling_callback. An MCP server can send many different parameters to the client, all via the CreateMessageRequestParams object. This object holds all of the parameters that you can expect to use for creating a sampling message, such as maxTokens, which defines the upper bound of tokens to use in a generation, system_prompt to define a system prompt, for the LLM call, and model_preferences which tells the client which models are best for the server to use. Note that it is not required for the client to respect these preferences. To see all of the parameters that a server is able to send you, you can view the CreateMessageParams class definition in GitHub.

Supporting Roots

Roots are a more recent addition to MCP and are used by clients to indicate which files and directories a connected MCP server has access to. These are usually configured by the user of the host application, like when providing a code assistant a project directory to work from. Because this list can potentially be changed by the user at any time, the client must be able to emit a ListChanged notification to the server to let it know that the list of roots has changed. When you support roots with the Python SDK, this is taken care of for you.

Warning

Roots are not a strong security measure, and should not be used as such. This is because servers can choose to not respect the boundaries set by roots, so your host application should have the final say in which files the server or LLM can actually access.

Beyond supporting ListChanged notifications, supporting roots requires that you are be able to send the actual list of roots to the server as well. This gives the server the information it needs to do its filesystem operations. As specified in the protocol, the list is a list of JSON objects that have uri and name properties. As of this writing, the uri property must use the file:// scheme, and the name property can be any string that provides a human-readable name for the root. In the Python SDK, roots are represented by the Root class, which has the uri and name properties, but also a _meta property. This is a dictionary that can be used to store any additional metadata about the root where keys have an optional dot-separated prefix that ends with a / and a required name, and the value can be whatever you want.

To support roots in your client using the Python SDK, you will implement a callback function and pass it to the session constructor just like you did for logging and sampling, using the parameter list_roots_callback. The callback function has to fulfill the ListRootsFnT protocol, which takes a context parameter of type RequestContext, specifically a ClientSession context, and returns either a ListRootsResult or an ErrorData object. ListRootsResult is a subclass of the Result class, and has a roots property that is just a list of Root objects. It is up to you to decide how your host application gathers the list of roots from your users and passes it to the client, however. The ErrorData class allows you to handle JSON-RPC error data, and has the properties code for the JSON-RPC error code, message for the error message, and data for the error data.

Because of the security implications of providing explicit access to the host machine’s filesystem, the protocol provides several “musts” and “shoulds” that you either must or should follow when implementing roots support in your client. These are:

You must only expose roots with permissions appropriate for the intended use.

You must validate root URIs to avoid path traversal attacks.

You must implement access controls to further prevent unauthorized access to files and directories.

You must monitor the accessibility of roots to ensure they are not blocked by firewalls or other network restrictions.

You should get user consent before providing roots to the server.

You should provide an understandable user interface for adding, removing, and inspecting roots and their availability.

You can find the full and up-to-date list of security and implementation guidelines in the MCP specification itself.

In the example below, you will see a simple _handle_roots() callback function that returns a list of Root objects for the server to use. This approach sets up an instance variable in the MCPClient class to store any roots that the host application provides, which the callback can then access. This also allows the list of roots to be updated by the host application at any time.

Example 4-3. Implementing a callback to support roots.

# client.py
from mcp.types import ListRoolsResult, Root

class MCPClient:
    def __init__(
        self,
        name: str,
        command: str,
        server_args: list[str],
        llm_client: Anthropic,
        env_vars: dict[str, str] = None,
        file_roots: list[str] = None,
    ) -> None:
        self.name = name
        self.command = command
        self.server_args = server_args
        self.env_vars = env_vars
        self.file_roots = file_roots
        self._session: ClientSession = None
        self._exit_stack: AsyncExitStack = AsyncExitStack()
        self._connected: bool = False
        self._llm_client = llm_client
    ...
    async def _handle_roots(
        self,
        context: RequestContext[ClientSession, Any],
    ) -> ListRootsResult | ErrorData:
        """
        Roots handler that returns the file roots, implementing the RootsFnT protocol.
        """
        roots_result = []
        for root in self.file_roots:
            if not root.startswith("file:///"):
                logger.warning(f"Root {root} does not start with file:///, ignoring")
            else:
                roots_result.append(Root(uri=root))
        if roots_result is None:
            return ErrorData(code=-32602, message="No valid file roots provided")
        return ListRootsResult(roots=roots_result)

    async def connect(self) -> None:
        ...
                # Start MCP client session
        self._session = await self._exit_stack.enter_async_context(
            ClientSession(
                read_stream=self.read,
                write_stream=self.write,
                logging_callback=self._handle_logs,
                sampling_callback=self._handle_sampling,
                list_roots_callback=self._handle_roots,
            ),
        )
        ...
# agent.py
...
if __name__ == "__main__":
    mcp_client = MCPClient(
        name="calculator_server_connection",
        command="uv",
        server_args=[
            "--directory",
            str(Path(__file__).parent.parent.resolve()),
            "run",
            "calculator_server.py",
        ],
        llm_client=anthropic_client,
        file_roots=[
            f"file:///{str(Path(__file__).parent.resolve())}",
        ],
    )
    agent = Agent(mcp_client, anthropic_client)
    asyncio.run(agent.run())

In this example, we’ve made some small and some bigger changes to the client. The major change was adding the _handle_roots() callback function to the client class. The function is called when the server sends a ListRoots request, and it returns a list of Root objects for the server to use. While it’s entirely up to you to decide how to gather and handle the list of roots from your host application or users, this code just checks a new instance variable file_roots to see if any roots were passed in to the client constructor. This approach also allows the host application to update the list of roots at any time, which gives this technique a lot of flexibility.

The _handle_roots() function does some minor checks like ensuring each root begins with the file:// scheme and omits the fully qualified hostname of the machine, then creates a Root object from the root URI string. If there are no roots, it returns an ErrorData object with a JSON-RPC error code of -32602 and a message indicating that no valid file roots were provided. If there are roots, those are returned in a ListRootsResult object. The ClientSession constructor was also updated to pass in the _handle_roots() callback function to the list_roots_callback parameter.

In agent.py, the changes are minimal: we only need to pass the file_roots list to the MCPClient constructor, which will then be used by the client in the _handle_roots() function. If you’re following along in the GitHub repository, try out this version of our MCP-powered agent by asking it to tell you how many files are in a directory. Ask it things like “how many files are in /full/path/to/directory?” for a directory that’s within your given root boundaries, and again for one outside of those boundaries. An actual conversation using the example code (including logging statements) is shown below, where the server is restricted by the roots provided by the client:

Example 4-4. Output from the roots example.

You: How many files are in /full/path/to/ai_agents_mcp_examples/ch3?

Using tool: count_files
Processing request of type CallToolRequest
[error] - Access denied: /full/path/to//ai_agents_mcp_examples/ch3 is not within allowed roots [FileUrl('file:///full/path/to/ai_agents_mcp_examples/ch3/16_using_roots')]

Assistant: It looks like I don't have access to that specific directory path. The system is restricting access to only a subdirectory: `/full/path/to/ai_agents_mcp_examples/ch3/16_using_roots`.

I can only count files within the allowed directory. Would you like me to count the files in `/full/path/to/ai_agents_mcp_examples/ch3/16_using_roots` instead, or do you need to adjust the file permissions to allow access to the broader ch3 directory?

The next and final client-provided capability is the ability to support elicitations, or human feedback.

Supporting Elicitations

Elicitations are a newer feature in the Model Context Protocol that allows MCP servers to request additional information from users via the client. A common use for elicitations is to add a human to the agent loop to help it complete tasks that require user input, such as getting a clarification on a task, or to get a user’s approval for a decision. They can also be used to get information from a user as it is needed, rather than forcing the user to provide everything up front in the initial prompt.

Elicitations are server-initiated, and when they’re received by the client, the client can respond with one of three types of response types: accept, decline, or cancel. This empowers the application user to control whether they give the requested information to the server or not, as well as how to proceed with the conversation if they decide not to. An accept response means that the user has approved a response to the elicit request and that the response has the requested information. A decline response means that the user has rejected the information request, and a response is returned either without a content field or with an empty one. A cancel response is similar to the decline response, but is used to indicate that the user didn’t explicitly reject the request. This would be due to the user doing an action that cancels the conversation, such as closing a chat dialog window or modal, without explicitly denying the request.

Servers can also provide a response schema to clients using a subset of JSON, which allows for flat objects that only allow a few property types: strings, numbers, booleans, and enums. This allows for a server to elicit structured feedback from the application user and use it for its own purposes. Luckily, the Python SDK provides Pydantic models for all of the response types and schemas, so that you can use them with a familiar API within your client.

Again, the protocol provides several “shoulds” that you should follow when implementing support for elicitations in your client, as they can be an attack vector for malicious servers gathering personal information from users. These are: - You should implement controls that allow a user to accept, decline, or cancel an elicitation request. - You should validate elicitation content against the provided schema. - You should indicate very clearly to the user which server is requesting the elicitation. - You should implement rate limiting. - You should present elicitations requests to the user that make it clear what information is being requested and why.

To support elicitations in your client, you will follow a similar pattern to supporting logging, sampling, and roots. You need to implement a callback function that conforms to the ElicitationFnT protocol, which takes a context parameter that takes a ClienstSession request context and a params parameter that expects an ElicitRequesstParams object. The callback has to return either an ElicitResult object or an ErrorData object. The ElicitRequestParams object comes from the server and is comprised of a string message and a requestedSchema property of type ElicitRequestedSchema, which is a type alias for a Python dictionary. The ElicitResult return type consists of a string literal action that reflects the user’s response to the request (“accept”, “decline”, or “cancel”) and a content dictionary that holds the structured information requested by the server, which is only present if action is “accept”.

In the example below, you will see a _handle_elicitations() callback implementation. Be sure to notice how it makes very clear that the user is responding to a server-initiated request, and that the client is returning the user’s response to the server. What isn’t shown is the _collect_form_data() helper function that parses the elicitation’s request schema and displays it to the user to get structured information. You can find the full code in the GitHub repo for this book.

Example 4-5. Implementing a callback to support elicitations.

# source.py
from mcp.types import ElicitResult, ElicitRequestParams, ElicitRequestedSchema
...
class MCPClient:
    ...
        async def _handle_elicitation(
        self,
        context: RequestContext[ClientSession, Any],
        params: ElicitRequestParams,
    ) -> ElicitResult | ErrorData:
        """
        Elicitation handler that displays the server request to the user, handles
        their accept/decline response, and collects form data when accepted,
        implementing the ElicitFnT protocol.
        """
        # Get the server name from the client instance
        requesting_server = self.name

        # Display the elicitation request to the user
        print(f"\n{'='*60}")
        print(f"ELICITATION REQUEST FROM SERVER: {requesting_server}")
        print(f"{'='*60}")
        print(f"Message: {params.message}")
        print(f"{'='*60}")

        # Get user input for accept/decline
        while True:
            user_response = (
                input("\nDo you want to accept this request? (y/n/c for cancel): ")
                .lower()
                .strip()
            )

            if user_response in ["y", "yes", "accept"]:
                print("Request accepted")
                # Collect form data based on the schema
                form_data = self._collect_form_data(params.requestedSchema)
                if form_data is not None:
                    print("Form data collected successfully")
                    return ElicitResult(action="accept", content=form_data)
                else:
                    print("Form data collection cancelled")
                    return ElicitResult(action="cancel")
            elif user_response in ["n", "no", "decline"]:
                print("Request declined")
                return ElicitResult(action="decline")
            elif user_response in ["c", "cancel"]:
                print("Request cancelled")
                return ElicitResult(action="cancel")
            else:
                print(
                    "Invalid response. Please enter 'y' (accept), 'n' (decline), or 'c' (cancel)."
                )
    ...
    async def connect(self) -> None:
        ...
        self._session = await self._exit_stack.enter_async_context(
            ClientSession(
                read_stream=self.read,
                write_stream=self.write,
                logging_callback=self._handle_logs,
                sampling_callback=self._handle_sampling,
                list_roots_callback=self._handle_roots,
                elicitation_callback=self._handle_elicitation,
            ),
        )
        ...

As with the other client-provided capabilities, we implement a _handle*() callback function and pass it to the ClientSession constructor via the elicitation_callback parameter. This is how the client is able to advertise its capabilities to the connected server. Within the callback, we use asterisks and text in all caps to make it extremely clear that the server is requesting information from the user, along with the name of the server that is making the request. Then, we start an infinite loop to ask the user to accept, decline, or cancel the request. If the user accepts it, we pass the elicitation’s request schema (in params.requestedSchema) to the helper function _collect_form_data(), which takes the schema, displays it to the user, and gets their input for each property. The result is sent back to the server as an ElicitResult object, and the server can do whatever it does with user information. This is written to be as generic as possible: the main feature of MCP is to decouple agentic applications from the tools and resources they use, so the client should need no knowledge of the elicitation or schema that the server is requesting.

Supporting Multiple Models

One of the major benefits of using MCP is that it gives you the freedom, as the application developer, to allow your users to use many models, or the model of your choice. With MCP, you aren’t tied to a single model, despite the examples in this chapter working with an Anthropic model. The simplest way to support this would be to write a translation function in your client for each primitive-model family pair that turns MCP objects into the format expected by the model family in question. You can improve on this basic design by creating a class for each primitive that can be constructed from the MCP object and has a translation method as a member of that class. To incorporate this into your client, you could modify get_available_tools() to return a list of `InternalTool objects to the host application, have the host application call the appropriate translation method on each tool and then send the results to the appropriate LLM.

This workflow is shown in the following example, which starts with a new file called internal_tool.py which implements the InternalTool class and adds as a member the translate_to_openai() method. This just returns a dictionary with the keys type, name, description and parameters. The get_available_tools() method in the client is updated to return a list of InternalTool objects instead of Anthropic API-formatted dictionaries. This now means that the host application can change the model that it is calling without any changes to the client itself, allowing further decoupling of the client and the host application.

Example 4-6. Handling multiple LLMs by allowing the client to translate between them.

#internal_tool.py
from typing import Any

class InternalTool:
    def __init__(
        self, name: str, input_schema: dict[str, Any], description: str | None = None
    ) -> None:
        self.name = name
        self.input_schema = input_schema
        self.description = description

    def translate_to_openai(self) -> dict[str, Any]:
        return {
            "type": "function",
            "name": self.name,
            "description": self.description,
            "parameters": self.input_schema,
        }

    def translate_to_anthropic(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "description": self.description,
            "input_schema": self.input_schema,
        }

example_tool = InternalTool(name=mcp_tool.name, input_schema=mcp_tool.inputSchema, description=mcp_tool.description)
openai_tool = example_tool.translate_to_openai()

# client.py
from internal_tool import InternalTool
...
class MCPClient:
    ...
    async def get_available_tools(self) -> list[dict[str, Any]]:
        if not self._connected:
            raise RuntimeError("Client not connected to a server")

        tools_result = await self._session.list_tools()
        if not tools_result.tools:
            logger.warning("No tools found on server")
        available_tools = [
            InternalTool(
                name=tool.name,
                description=tool.description,
                input_schema=tool.inputSchema,
            )
            for tool in tools_result.tools
        ]
        return available_tools
    ...
# agent.py
from internal_tool import InternalTool
...
    async def run(self):
        try:
            print(
                "Welcome to your AI Assistant. Type 'goodbye' to quit or 'refresh' to reload and redisplay available resources."
            )
            await self.mcp_client.connect()
            available_tools: list[
                InternalTool
            ] = await self.mcp_client.get_available_tools()
            available_tools: list[dict[str, str]] = [
                tool.translate_to_anthropic() for tool in available_tools
            ]
            await self._refresh()
...

In this example, we are working with all three files. First, internal_tool.py defines our application’s internal tool representation, which holds translation methods for each model family we want to support. Then, the client’s get_available_tools() method is updated to return a list of InternalTool objects, offloading the decision of which model to use back to the host application, where it belongs.

Note

In the previous example, we wrote a function to translate a Tool object from our internal representation to OpenAI’s function-calling format. OpenAI’s API also supports directly calling remote MCP servers.

Using Multiple Servers

All of the examples so far have shown your client connecting to a single server. And while a single client can only connect to a single server, the Python SDK provides a ClientSessionGroup class to help manage multiple sessions concurrently within a client. It does this by connecting either to an existing ClientSession with connect_with_session() or to a new server by creating a new ClientSession with conenct_to_server() and then loading all primitives/components (tools, resources, and prompts) offered by the connected server into object properties. Sessions are also stored in the ClientSessionGroup object, along with a mapping of each tool to the appropriate session. Sessions themselves can be accessed via the sessions property, while the MCP primitives can be accessed via the prompts, resources, and tools properties. Tools in a SessionGroup can still be called with call_tool(), just like you would call them in a seingle ClientSession. You are able to disconnect from individual servers with disconnect_from_server(), which will also remove any loaded server components. In the example below, we will create a simple client that connects to servers via a ClientSessionGroup.

Example 4-7. A client that connects to multiple servers via a ClientSessionGroup.

# client.py
from mcp.client.session_group import ClientSessionGroup, ServerParameters

class MCPClient:
    def __init__(
        self,
        name: str,
        llm_client: Anthropic,
    ) -> None:
        self.name = name
        self._llm_client = llm_client
        self._session_group = ClientSessionGroup()
    ...
    async def connect(self, server_parameters: ServerParameters) -> None:
        """
        Connect to the server set in the constructor.
        """
        connected_server = await self._session_group.connect_to_server(
            server_params=server_parameters,
        )
        connected_server._logging_callback = self._handle_logs
        connected_server._sampling_callback = self._handle_sampling
        connected_server._list_roots_callback = self._handle_roots
        connected_server._elicitation_callback = self._handle_elicitation

    async def get_available_resources(self) -> list[Resource]:
        if not self._session_group.sessions:
            raise RuntimeError("Client not connected to a server")

        resources_result = list(self._session_group.resources.values())
        if not resources_result:
            logger.warning("No resources found on server")
        return resources_result

    async def get_resource(
        self, uri: str
    ) -> list[BlobResourceContents | TextResourceContents]:
        if not self._connected:
            raise RuntimeError("Client not connected to a server")
        # Read resource from session group
        resource_read_result = await self._session_group.read_resource(uri=uri)

        if not resource_read_result.contents:
            logger.warning(f"No content read for resource URI {uri}")
        return resource_read_result.contents
    ...
    async def disconnect(self) -> None:
        """
        Clean up any resources
        """
        for session in self._session_group.sessions:
            await self._session_group.disconnect_from_server(session)

...
# agent.py
async def main():
    """Main async function to run the agent with proper connection management."""
    calculator_server_parameters = StdioServerParameters(
        command="uv",
        args=[
            "--directory",
            str(Path(__file__).parent.parent.resolve()),
            "run",
            "calculator_server.py",
        ],
    )
    mcp_client = MCPClient(
        name="calculator_multi_client",
        llm_client=anthropic_client,
    )
    await mcp_client.connect(calculator_server_parameters)
    agent = Agent(mcp_client, anthropic_client)
    await agent.run()

if __name__ == "__main__":
    asyncio.run(main())

This is simpler from the client perspective, since the ClientSessionGroup handles all of the connection management that the single-server client was in previous examples. Our client no longer has as many parameters for its constructor, since it’s no longer defined by the parameters of a single server. It also has fewer properties overall: instead of a _session property that is None until a connection is made, we instantiate a ClientSessionGroup object and store it in _session_group. All of the methods that call the underlying session’s methods (such as call_tool()) have to be updated to use _session_group instead of _session. Additionally, the client no longer has the _connected property because the check is no longer necessary: under the hood, the ClientSessionGroup treats all the primitives as coming from a single server, and so connecting to the same server multiple times will result in an exception. The full code has removed the checks of the _connected property that ensure that the client is connected to a server, and replaced it with a check of the _session_group.sessions list to ensure there is at least one connected server.

To get the available tools, resources, and prompts, we have to slightly change the get_available_*() methods. These should follow the pattern shown for get_available_resources(), where we iterate over the resources dictionary in the ClientSessionGroup and return the values as a list.

Warning

Because primitives are treated as coming from a single server, you cannot have primitives with the same name in your ClientSessionGroup. The primitive names are stored in a dictionary, and so adding a primitive such as a tool with the same name as an existing one will result in an exception.

Then, we have the connect() method, which just takes a ServerParameters object. This is actually a Python type alias for StdioServerParameters, SseServerParameters, or StreamableHttpParameters. We use that to connect to a single server, and all connection management is handled by the session group. The flipside to this is that there isn’t support yet for including callbacks when establishing the single ClientSession objects. A workaround for this is shown in the example: since the ClientSessionGroup’s connect_to_server() function returns the connected session, you can set the _logging_callback and _sampling_callback properties on the session directly. This isn’t the cleanest way to do this, since the properties are technically private, but until the ClientSessionGroup class natively supports callbacks, this is the simplest way to get around this particular limitation. We also updated our disconnect() method to iterate over the sessions list in the ClientSessionGroup and call disconnect_from_server() on each one.

The host application also needed to be updated. In the example above, the if __name__ == "__main__" block code is largely moved to main(). The parameters for our calculator server are stored in a StdioServerParameters object, then the `mcp_client is instantiated without those parameters. After that, though, we call connect() on our own, passing the built server parameters object to the connect() method, which creates a session between the client and the server and adds it to the internal list of active sessions.

The ClientSessionGroup constructor also takes an optional hook in the parameter component_name_hook, which is for renaming a component so as to prevent naming collisions. This is just any function that takes a string (the component name) and an Implementation object, and returns another string (the renamed component).

Best Practices

Some best practices aren’t yet fully established, some are established by the community, some are general engineering best practices, but an overview of all of these as they apply to clients will help you quickly build resilient, production-ready MCP clients for your own applications. These revolve around security, connection management, and user experience.

Security

LLMs are powerful tools, but because of their broad generalizability, they have a broad attack surface. Interacting with third-party servers only adds to that. There are a few things you can do to mitigate security risks that come with using the model context protocol. For example, many servers require authentication. For stdio connections, this is best done by accessing credentials from the environment. For streaming HTTP connections, your client should use a Python OAuth2.1 client library to handle the authentication process. The client should initiate the authentication process after accessing the server, which will get the access token from the server to be able to access protected tools and resources.

Resuming Connections

Connections between clients and servers are not always stable. Failure modes abound, and are typically specific to a particular use case. Luckily, streamable HTTP connections are resumable, which allows for broken connections to re-establish themselves where they left off. MCP servers can optionally attach an id to each SSE event that they emit. The client can then use this id to resume the connection from the last event it received. At the protocol level, this is done by the server including the Last-Event-ID header to a GET request to the server it was connected to. This is done similarly in Python.

Your client should be maintaining its session ID and most recent event ID in a property. Before reconnecting, you should take the session and event IDs and add them to a headers dictionary that you will pass to the connection function.

If you remember the streamable HTTP connection section, you might remember that connecting to a server is simpler than connecting to a stdio server. This is also the case for resuming connections. Before resuming, you would add the session and event IDs to the headers dictionary and pass it to the connection function in the headers parameter. In the below example, we simply get the already-saved session ID and event ID, then add them to the headers dictionary. That dictionary is then passed to the streamable HTTP connection constructor.

Example 4-8. Resuming a streamable HTTP connection.

from mcp.client.streamable_http import streamablehttp_client

class MCPClient:
    """MCP Client class for connecting to and interacting with MCP servers."""

    def __init__(self, name: str, server_url: str) -> None:
        """Initialize the MCPClient with server connection parameters."""
        self.name = name
        self.server_url = server_url
        self._session: ClientSession = None
        self.exit_stack = AsyncExitStack()
        self._connected: bool = False
        self._get_session_id: Callable[[], str] = None
        self._last_event_id: str = None

    async def connect(self, headers: dict | None = None) -> None:
        """Connect to the server set in the constructor."""
        headers["Last-Event-ID"] = self._last_event_id

        # Connect to Streamable HTTP server
        streamable_connection = await self._exit_stack.enter_async_context(
            streamablehttp_client(url=self.server_url, headers=headers)
        )
        ...

This is a very simpified implementation: we add _last_event_id to the headers dictionary with the key Last-Event-ID, then pass the entire dictionary to the streamablehttp_client, with the headers parameter.

Paginating Results

When you retrieve results from a server, you may want to paginate them if they’re very long. This is worth following since you don’t know which kinds of agents your users will be using, and you don’t want to overwhelm them with too many results at once. This is typically initiated by the server returning a result with a nextCursor property.

If you wish to get the next page of results, you call the same method you used to get the results, but pass in the nextCursor property as the cursor parameter. This should get you the next page of results from the server. Currently, only the list functions support pagination, in the Python SDK these are represented by list_resources(), list_resource_templates(), list_prompts(), and list_tools() methods.

Chapter 5. Building MCP Servers: Providing Tools, Prompts and Resources to Applications

A Note for Early Release Readers

With Early Release ebooks, you get books in their earliest form—the author’s raw and unedited content as they write—so you can take advantage of these technologies long before the official release of these titles.

This will be the 5th chapter of the final book. There is a GitHub repository in progress with code examples.

If you’d like to be actively involved in reviewing and commenting on this draft, please reach out to the editor at jleonard@oreilly.com.

On the other side of the MCP equation from the client is the server. In Chapter 3, the client examples all made use of a server that gave the host application the ability to do math. This serverwas pre-built and was run locally. In this chapter, our examples will show the construction of this same server from scratch, including all of the primitives and features that servers can provide to clients and host applications. We will begin with a high-level view of the server, including what servers do and how they’re useful, how they connect to clients, and the two main paths for building them with the Python SDK. Then, we will get right into the details:

server primitives (tools, prompts, and resources) and how to serve them

server utilities (completion, logging, notifications, and pagination) and how to use them

server security

using client-provided resources like sampling and elicitation

As a server developer, you will also need to become comfortable with testing your server. You’ll learn about MCP Inspsector, a tool to visualize and interact with your server and its tools, and evaluations, tests used on traditional AI systems that we can repurpose for our own use.

Servers, What Are They Good For?

Servers are the most popular entry point for developers getting started with MCP. Popular tools such as Claude Desktop, Claude Code, Cursor, and more all have built-in support for adding servers to their applications, and the maintainers of the various MCP SDKs have focused very heavily on making server development as easy as possible for developers. This has had a few consequences, like insecure and poorly performing servers being built and immediately released, and hiding some of the deeper benefits that MCP servers provide.

The most obvious thing that servers do is provide applicatons with access to MCP primitives, granting applications new abilities they didn’t have before. These primitives are the building blocks of MCP, and consist of tools, prompts, and resources. You encountered these in Chapter 3, where your client integrated these primitives with a simple chat application that the client was a part of. By using these primitives, you learned what they offered: tools offer programmed actions, resources offer structured data that can be use as context by the language model, and prompts offer interactive instructions for the host application’s language model. In the MCP specification, each primitive is designed to be controlled by a different entity: tools are controlled by the model that calls them, resources are controlled by the application that decides how to use them, and prompts are controlled by the application user that triggers their use and provides their inputs.

These lists of uses and controllers is summarized in the following table, which is reproduced with some modifications from the MCP specification:

Table 5-1. MCP server primitives, controllers, and descriptions, from the MCP server specification

Primitive
Controller
Description

Tool

The model

Functions exposed to the LLM to take some action

Resource

The application

Contextual data managed by the application using the server

Prompt

The application user

Interactive templates invoked by user choice

But the real magic of servers lies somewhere else, but is still related to how they augment applications: they solve the problem of distribution. Before MCP, tools, data sources, and prompts were all tightly coupled to the intelligent application that used them, which made them difficult to share and reuse. If someone wanted their application’s LLM to have access to, e.g. AWS documentation, they would have to connect to it, write the appropriate code to retrieve it, and then make it available to their application’s LLM. With MCP, someone at AWS who knows the provided tool better can write an MCP server that provides this functionality to anyone who can connect to it. Now, application developers can focus on their application’s logic, letting MCP servers handle the rest. Server writers can distribute their servers either via code-sharing platforms like GitHub or as a running server that MCP clients can connect to remotely. When an MCP server can be written in a single file, it makes it much easier to share and reuse—​even more so when the server is deployed to a remote web server.

Connecting Servers to Applications Using Transports

The key to how servers talk to applications via MCP clients is the transport. You encountered these in the client examples in Chapter 3 and will get into the gory details of them in [Link to Come], but for server development it’s important to understand the options available for distributing your server to client applications. The protocol itself uses JSON-RPC as the underlying communication protocol, and transports serve to communicate messages encoded with that protocol between the client and server. There are two major transports supported by MCP and its SDKs: stdio for local connections and Streamable HTTP for remote connections. When you are developing a server, the protocol encourages you to support stdio as long as its possible. Supporting stdio makes it easy for users to connect to your server by loading the server script locally in their application and allowing the MCP client to call it with a set command (usually communicated via the server’s README). When using this transport, the MCP client executes the server script as a long-running subprocess and messages are read by the server’s standard input and written to its standard output. Figure 5-1 shows the basic flow of a stdio connection.

Streamable HTTP, on the other hand, is designed to support remote connections, such that the server and client do not need to be running on the same system. When implementing a server with this transport, you have to provide a single HTTP endpoint (the MCP specification calls this the MCP endpoint) that supports GET and POST requests, as Streamable HTTP makes use of both methods: GET for the client to initiate a connection with the server and POST for the client to send requests, notifications, and events to the server. What makes Streamable HTTP streamable is that it also allows for optional SSE streaming between the client and server. This allows, among other things, for a single MCP client instance to connect to multiple servers at once using separate streams.

Note

While Streamable HTTP is primarily intended for remote servers, it can also be used locally, which can help with debugging and testing. When using this transport locally, the specification recommends using localhost (127.0.0.1) over 0.0.0.0.

Figure 5-2 shows the basic flow of a Streamable HTTP connection.

Because of the unique challenges that come with running a remote server available on the public Internet, the MCP specification provides for both streaming session resumability and authorization via OAuth. To support session resumability, your server would include an id field on each SSE event with a globally unique ID for that event. If the streaming connection is broken, the client can send a GET request with the header _Last-Event-ID_ to the server with the last event ID it received. Your server can then send again any messages that would have been sent after that event ID on the same stream (since the server can have multiple streams open at once, you should never send messages destined for different streams).

Don’t cross the streams

Dr. Egon Spengler, Ghostbusters (1984)

While resuming streaming sessions is optional to implement, when running a remote server, authorization is a must. The MCP specification provides for authorization via OAuth, and generally requires the server developer to use a language-native OAuth library to handle the authorization flow. The unique nature of MCP and generative AI more broadly opens MCP servers especially up to threats new and old, like man-in-the-middle attacks, session hijacking, token passthrough attacks, tool poisoning, and more. Defending against these will need to be top of mind for you, the server developer, so that you can safeguard your own and your users’ data. We will cover this in more depth in Server Security, later on in this chapter.

Outside of security and authorization, most of the details of connection management are abstracted away by the MCP protocol SDKs, similarly to how you saw in the client examples in Chapter 3. In the next section, we will introduce the two ways to build MCP servers with the Python SDK, and then drill into the details of how to use one of those techniques in your own projects.

Building Servers with the Python SDK

When MCP was first released with its various SDKs, there was only one way to build MCP servers. This is normally unsurprising: SDKs are typically opinionated about how you build with them, and this was no exception. However, not long after the release of MCP, FastMCP was released, which provided a much-simplified way to build MCP servers in Python. It proved to be so popular that it was officially incorporated into the official MCP Python SDK. This led to a 2-tier structure of MCP server development techniques: FastMCP for nearly all use cases, and the pre-existing “low-level” API for those who need more control.

Warning

Do not confuse FastMCP 1.0 with FastMCP 2.0 (or FastAPI). FastMCP 1.0 focsed on redesigning the MCP server development experience, while FastMCP 2.0 broadens its focus to the entire Python SDK, implementing MCP with Python using their own design.

Antipattern Alert: Creating MCP Servers from REST APIs

One thing I’ve seen come up a lot in MCP communities and launched servers is the automated generation of MCP servers from existing REST APIs. This is a common antipattern, exacerbated by many products and tools that offer to do just that for organizations wanting to expose their APIs to agents. REST APIs are typically very granular, stateless, and polymorphic, while agents do best when a tool does a single, well-defined action with an equally well-defined shape of its inputs and outputs. When you need to call several REST API endpoints to accomplish a single activity, you run the dual risk of increasing the probability of tool choice errors and exploding the costs of your users’ agent actions. Not only does an agent having access to too many tools tend to increase the error rate when it comes to tool choice, but needing to choose and call several tools for a single action can compound the tool choice error rate. For example, if your agent has a 95% success rate of choosing the correct tool for an action, having to choose 5 tools will drop your agent’s success rate to around 77%.

This is also the basis for exploding costs: if your agent is calling 5 tools to do a single action, each turn’s input token count includes the input and output tokens of the previous turns, which quickly adds up. Using a back-of-the-envelope calculation derived from Chapter 3’s simple example server, your overall token usage could increase by a factor of around 8x and your cost by nearly 7x. Instead of deploying an autogenerated MCP server, you should follow the advice of Prefect CEO Jeremiah Lowin and use an autogenerated MCP server to bootstrap your final server, then “aggressively curate” the generated tools to remove what your agent doesn’t need, unclear variables and more. Even better is his suggestion to start your server from scratch, using “agent stories” much like user stories are used in traditional product-focused software development, to build the server that an agent can best use.

FastMCP

FastMCP is now the default way to build MCP servers with the Python SDK, and provides much of the power of the SDK’s original design with a much simpler interface. It largely interacts with your server code via decorators, much like FastAPI, and natively supports features like server authentication. Most importantly, FastMCP supports all MCP primitives along with client-side abilities, like elicitations and sampling. It also allows you to manage the server’s lifecycle, so you can e.g. make a database connection when the FastMCP server is instantiated and close it when the server is stopped. Using the FastMCP functionality is much simpler than the original design, at the most basic level, you just need to instantiate the FastMCP object and pass it your server’s name:

Example 5-1. Instantiating a FastMCP server.

from mcp.server.fastmcp import Context, FastMCP

mcp = FastMCP(name="server_name")

This just instantiates the FastMCP object, which you will use to add MCP primitives to your server. You can add many more options to the FastMCP constructor too, such as instructions (shown to the client), such as a flag for debug mode, the default log level, a json response flag, and more. These are all exposed in your server as a Context object, which can be passed into any of the server’s functions as a parameter for use within those functions.

After that, you can start the server with mcp.run(), which will block until the server is stopped. You can also pass in a transport method to the run() method, which will start the server and return a coroutine that you can await to start the server.

Example 5-2. Running a FastMCP server.

if __name__ == "__main__":
    mcp.run()

In addition to direct execution, you can use [Link to Come] to run the server in development mode with uv run mcp dev server.py or install it to be run via Claude Desktop with uv run mcp install server.py. These techniques only work with FastMCP servers, not servers built with the low-level API, and require your project to have the MCP development tools installed, which you can install by running uv add "mcp[cli]" in your project.

Low-level

The low-level API provides the full MCP protocol, including all of the features of the FastMCP API, but also gives you full control over how you handle client requests, the server lifespan, and how you handle tool calls. In FastMCP, you simply decorate Python functions with something like @mcp.tool() (you will see exactly how in “Server Primitives: Tools, Prompts, and Resources”) and when a client requests the list of tools from your server, FastMCP will do the work behind the scenes to build and return the list of tools. With the low-level API, however, you are able to implement your own responses for the tool/list request, giving you more control over what your server returns to the client when it makes a tool/list request. To instantiate a server with the low-level API, you will need to import Server from mcp.server.lowlevel, create an async function, set the initialization options of the server (things like the server name, version, and capabilities), and then instantiate the server within an async stdio_server() context manager. When you instantiate the server, you will also need to pass in a read stream and a write stream, which you can get from the context manager. You will also pass in your initialization options here. Then, you can simply call that function with asyncio.run() to start the server asynchronously.

Example 5-3. Starting a low-level MCP server.

import asyncio

import mcp.server.stdio
from mcp.server.lowlevel import NotificationOptions, Server
from mcp.server.models import InitializationOptions

# Create a server instance
server = Server("low-level-server")

async def run():
    print("Running low-level server")
    initialization_options = InitializationOptions(
        server_name="low-level-server",
        server_version="0.1.0",
        capabilities=server.get_capabilities(
            notification_options=NotificationOptions(),
            experimental_capabilities={},
        ),
    )

    async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
        await server.run(
            read_stream=read_stream,
            write_stream=write_stream,
            initialization_options=initialization_options,
        )

if __name__ == "__main__":
    asyncio.run(run())

As you can tell, instantiating a server with the low-level API is more verbose than with FastMCP, but it gives you some extra capabilities and flexibility, even if it is rare to need it. One thing that wasn’t explained in in the introduction to this example is the get_capabilities() method. This is interesting because what it does is check if the server has any handlers registered with it for a capability, like supporting prompts, resources, or tools. If those handlers are found, an instance of a class representing that capability (e.g. PromptsCapability) is created and passed to a ServerCapabilities object, which is included in the InitializationOptions object. This is how capability declaration, something mandated by the MCP specification, is handled in the low-level API. The NotificationOptions object passed in to get_capabilities() is used to determine what which capabilities support notifications.

To support tool use, you will need to implement a handler for the tools/list and tool/call requests. This will also be the case for prompts and resources. As you will see in “Server Primitives: Tools, Prompts, and Resources”, this process is more involved than it is with FastMCP, which infers your tool list from the declared tool functions. To handle the list tools request, you will write a function that returns a list of Tool objects. Each Tool instance has a name, description, and inputSchema, along with an optional outputSchema. The inputSchema must be in the form of a JSON object, which means it needs type, properties, and required keys. The properties key points to a dictionary where the keys are the Tool function’s arguments, and the values are a dictionary that holds the argument’s type and description. The required key just points to a list of the required arguments. In the following example, this is implemented as list_tools(). The tool is implemented in add(), and is declared a tool with the @server.call_tool() decorator. Any tool functions in the low-level API take a name parameter, which is the tool’s name, and an args parameter, which is a dictionary of the tool’s arguments and their values.

To ensure the correct tool is being called, you can check the content of the name parameter to ensure it is what you expect it to be, and to use the arguments you defined in the inputSchema, you can address them as keys in the args parameter. This gives you all the ingredients you need to to implement a tool’s logic, and if it returns something, it can be returned either as a Content object (such as TextContent) or as a dictionary that declaires the type of the result along with the result itself. The example below takes the latter approach, returning a type of text and an f-string showing the calculation and result of the tool.

Example 5-4. Listing tools and making them available via a low-level MCP server.

from typing import Any

import mcp.server.stdio
...
from mcp.types import Tool
...
@server.list_tools()
async def list_tools() -> list[Tool]:
    """List all tools available on the server."""
    return [
        Tool(
            name="add",
            description="Add two numbers together.",
            inputSchema={
                "type": "object",
                "properties": {
                    "a": {
                        "type": "number",
                        "description": "The first number to add"
                    },
                    "b": {
                        "type": "number",
                        "description": "The second number to add"
                    },
                },
                "required": ["a", "b"],
            },
        )
    ]

@server.call_tool()
async def add(name: str, args: dict[str, Any]) -> dict[str, Any]:
    """Add two numbers together.

    Args:
        a: First number
        b: Second number
    """
    if name != "add":
        raise ValueError(f"Unknown tool: {name}")
    result = args["a"] + args["b"]
    return {"type": "text", "text": f"{args['a']} + {args['b']} = {result}"}

This is a simple example, but it illustrates the basic process of implementing a function that lists some primitive (such as tools) and an tool’s logic as well. In addition, you can provide a structured output schema for the tool, which will ensure that the tool’s output is provided in a particular structured format to the MCP client and thus the host application. The output schema allows your tools to validate their output before sending them to the client and for them to either return structured data only or a tuple of structured data and human-readable content blocks, as opposed sending only content blocks like in the previous example. In the FastMCP implementation, this happens automatically and is generated from the tool’s return type annotation. The next example shows what changes would be necessary to return structured data from an existing tool using the low-level API.

Example 5-5. Returning structured data from a tool with a structured output schema.

@server.list_tools()
async def list_tools() -> list[Tool]:
    """List all tools available on the server."""
    return [
        Tool(
            name="add",
            description="Add two numbers together.",
            inputSchema={
                "type": "object",
                "properties": {
                    "a": {"type": "number", "description": "The first number to add"},
                    "b": {"type": "number", "description": "The second number to add"},
                },
                "required": ["a", "b"],
            },
            outputSchema={
                "type": "object",
                "properties": {
                    "augend": {
                        "type": "number",
                        "description": "The first number to add",
                    },
                    "addend": {
                        "type": "number",
                        "description": "The second number to add",
                    },
                    "sum": {
                        "type": "number",
                        "description": "The result of the addition",
                    },
                },
                "required": ["augend", "addend", "sum"],
            },
        )
    ]

@server.call_tool()
async def add(name: str, args: dict[str, Any]) -> dict[str, Any]:
    """Add two numbers together.

    Args:
        a: First number
        b: Second number
    """
    if name != "add":
        raise ValueError(f"Unknown tool: {name}")
    result = {"augend": args["a"], "addend": args["b"], "sum": args["a"] + args["b"]}
    return result

Using the output schema in the low-level API takes two steps: first you have to declare the schema in the proper tool definition in the list_tools() handler; second, you have to return the structured data from the tool itself. The output schema definition takes the same structure as the input schema: a Python dictionary that is structured like a JSON object with a type key that has the value "object" and another object under the key properties that holds the expected components of the tool’s output. There is also an optional required field that holds a list of required properties. If you don’t set any to be required, then any output will be valid. Then, in the tool itself, you just have to ensure that you return a dictionary with the same keys as properties in the output schema. MCP will automatically validate any output against the schema and raise an error if any required fields are missing, misspelled, of the wrong type, and more.

Try changing the name of one of the fields in add()’s result and running your server and tool in MCP Inspector. What happens?

The low-level server takes a lot of extra work just to support a simple example like this, but there are some benefits, like being able to work with the server’s lifespan API. The lifespan API allows you to do few things, but primarily it allows you to set up and tear down any resources that the server needs to use (like a database connection) on server start and shutdown. This is useful for any resources that are expensive to create or take time establish a connection to, and for those that need to be explicitly closed when the server is stopped. To do this, you set up an async function that is decorated with the @asyncontextmanager decorator, itself provided by the Python standard lirbary’s contextlib module. This should take an argument of type Server and yield an AsyncIterator object. In the function body, you do any setup in the main body, in a try block, yield whatever objects you want to provide via the context object as a dictionary, and in the finally block you can do any necessary teardown. Then, you have to pass this function to the Server constructor using the lifespan argument.

You can also provide these resources to your server’s tools, prompts, and resources with the via a lifespan context object that you can request from the server. To access the context object in one of your agent-facing functions, request it from the server using the request_context property of the server object, and it will give you the dictionary you yielded from your lifespan function. The next example shows this in action, building on the previous one.

Example 5-6. Using the low-level API to manage the server’s lifespan.

import asyncio
import sys
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import datetime

...

logs = []

@asynccontextmanager
async def lifespan(server: Server) -> AsyncGenerator[list[str]]:
    logs.append(f"{datetime.now()}: Server started")
    try:
        logs.append(f"{datetime.now()}: logs retrieved")
        yield logs
    finally:
        print(logs, file=sys.stderr)

# Create a server instance
server = Server("low-level-server", lifespan=lifespan)

@server.list_tools()
async def list_tools() -> list[Tool]:
    """List all tools available on the server."""
    ctx = server.request_context
    print(ctx.lifespan_context[-1], file=sys.stderr)
    print(logs[-1], file=sys.stderr)
    return [
        Tool(
            name="add",
            description="Add two numbers together.",
            inputSchema={
                "type": "object",
                "properties": {
                    "a": {"type": "number", "description": "The first number to add"},
                    "b": {"type": "number", "description": "The second number to add"},
                },
                "required": ["a", "b"],
            },
            outputSchema={
                "type": "object",
                "properties": {
                    "augend": {
                        "type": "number",
                        "description": "The first number to add",
                    },
                    "addend": {
                        "type": "number",
                        "description": "The second number to add",
                    },
                    "sum": {
                        "type": "number",
                        "description": "The result of the addition",
                    },
                },
                "required": ["augend", "addend", "sum"],
            },
        )
    ]

@server.call_tool()
async def add(name: str, args: dict[str, Any]) -> dict[str, Any]:
    """Add two numbers together.

    Args:
        a: First number
        b: Second number
    """
    if name != "add":
        raise ValueError(f"Unknown tool: {name}")
    result = {"augend": args["a"], "addend": args["b"], "sum": args["a"] + args["b"]}
    ctx = server.request_context
    logs = ctx.lifespan_context[-1]
    print(logs, file=sys.stderr)
    return result

async def run():
    print("Running low-level server", file=sys.stderr)
    print(logs, file=sys.stderr)
    ...

In this example, we’ve added a rudimentary logging capability to the server. We create a module-scoped global list to hold the logs, then define the function lifespan(), decorating it with @asynccontextmanager to allow it to be used as a context manager. Because the main body of the function is called during server startup, we added a log message indicating that the server was started. In the try block after that, we added another log message indicating that the logs were retrieved. Then, we yield the logs list. The finally block is used during server shutdown. Then, to the server initialization function, we add a parameter lifespan that takes the lifespan() function we defined. Then, when we call server.request_context anywhere in the code (but in our example, just in list_tools() and add()), we can access the logs list from the context object and update it by accessing it, then we print the most recent entry to stderr. We are printing to stderr since, if you are using MCP Inspector to debug your servers, you can see these printed messages in the notifications pane.

Why FastMCP?

While the lifespan API is undoutedly cool, it has limited applicability, making it hard to justify trading away FastMCP’s simplicity. That is why, in this book, we will focus on building servers with FastMCP rather than the low-level API. But beyond (and because of) FastMCP’s marriage of power and simplicity, it has quickly become the default way to build MCP servers with the Python SDK. Because of its ubiquity, all of the rest of the text and examples in this chapter will focus on the FastMCP API.

Server Primitives: Tools, Prompts, and Resources

When you develop an MCP server, the majority of your focus will likely be on implementing the primitives that your server will support. The MCP primitives are the main building blocks that an MCP server provides to connected clients and host applications. These primitives are tools, which are functions that the host application can choose to call, prompts, which you can provide to the host application to better guide how it interacts with what the server provides, and resources, which are data that is provided by the server to the host application. In this section, you will see learn to serve each of these primitives from an example MCP server. In Chapter 3 you used a server to run with the client examples, and now in Chapter 54, you will see how that server was put together.

First thing’s first, though: how do you actually create and start an MCP server? In the following example, you will see a server that does nothing but does run and allow connections to be made to it.

Example 5-7. A minimal stdio MCP server.

from mcp.server.fastmcp import FastMCP

# Initialize FastMCP server
mcp = FastMCP("minimal-stdio-server")

if __name__ == "__main__":
    # Initialize and run the server
    mcp.run()

This example is just what you saw in the FastMCP section, and creates an instance of a FastMCP server with the name minimal-stdio-server, and runs it with mcp.run() when the script is run directly. You can run this with uv run server.py or uv run mcp dev server.py, which will run the [Link to Come], and it’ll start and do nothing. Going forward, all of the examples will be able to be run this way, and it is highly recommended to use MCP Inspector so that you can easily interact with the server in the examples.

Tip

When using MCP Inspector, you can use command line flags to install additional dependencies or mount local code so that you can see your changes in real time in the Inspector UI. For example, to give your server access to the Pydantic library without installing it to your local dev environment, you can run the following command:

uv run mcp dev server.py --with pydantic

And to mount local code so that you can make edits without restarting the server, you can run the following from the server’s directory:

uv run mcp dev server.py --with-editable .

If you want your server to support Streamable HTTP, you can do so by passing the the appropriate transport string ('streamable-http') to the run() method, which you can make configurable in any number of ways, if needed.

if __name__ == "__main__":
    # Initialize and run the server
    mcp.run("streamable-http")

And this is all it takes to build a running MCP server. Of course, these examples don’t do anything besides just run, and in the next section we will change that by implementing a tool that can be called by an MCP client.

Serving Tools

Tools are the backbone of the MCP ecosystem. They are what truly augments the behavior and abilities of an LLM, by providing them with access to just about anything that you can code, instantly giving them the ability to do anything that you can imagine. But what exactly is a tool? In the Python SDK, a tool is merely a Python function that is decorated with the @mcp.tool() decorator. This allows FastMCP to automatically send the tool’s name and description to the client when it requests the list of tools from the server, which it would typically pass to the host application’s language model. With FastMCP, the tool’s input and output schemas are automatically inferred from the tool’s function signature and any type annotations that you provide, while the tool description is inferred from the docstring of the tool function.

MCP is like The Matrix, you just jack in a server and all of a sudden you know kung fu!

Christopher Bailey, Host of the Real Python Podcast

Designing Tools

Before getting to examples, it’s important to understand how to design a tool for effective use by the LLM. We already talked about the importance of using the FastMCP API so that the tool name, parameters, and description can all be detected by FastMCP to be put in the appropriate parts of the data model. But not only do they need to be detected by FastMCP, they need to be understood by the LLM. This means that tools should have distinct names that relate to the actions they perform, descriptive but not overly verbose descriptions, and well-defined inputs and outputs. You should also strongly consider keeping your tools within appropriate namespaces. With MCP, a namespace is typically displayed as an underscore-separated prefix to the tool’s name, and can be especially handy if you are developing multiple similar tools with one key difference (such as a third-party API provider) between them. But even if you’re not, namespacing can help your users because it is likely that a purpose-defined agent (as opposted to a general-purpose one) will be using several somewhat similar tools, and giving the application’s model additional differentiators will only help its tool choice accuracy. Anthropic also recommends highly detailed tool descriptions, and analogizes crafting a tool descriptions to explaining how some code works to a new hire. You need to think deeply about the context that you hold implicitly about that code, and make it explicit to the new hire. It is the same for an LLM, and so you need to balance token efficiency with clarity and context that enables optimal tool choice performance.

With your tool code well-documented, you should shift your focus to what the tool is doing. LLMs work best with tools when they are end-to-end actions, rather than granular, composable, individual actions common to REST APIs. This is because, as mentioned in “Antipattern Alert: Creating MCP Servers from REST APIs”, the host application will need to call several tools to accomplish a single action, which can compound error rates and token costs, and blow up your context window. You can certainly break up your tool code into private functions and compose them within your server code for better testability and reusability, just be careful not to expose those building blocks as tools.

In traditional software development, many product management strategies include creating “user stories.” These are short descriptions of a feature or action that a user might want to use or perform, written from the perspective of the user. For example, a common format is “As a [user type], I want to do [action] so that I can [goal].” This allows developers and product managers to take the user’s perspective and design features that address a real need. MCP server development is different, as you are building tools for an agent to use, so it could be useful (as suggested in the article linked in “Antipattern Alert: Creating MCP Servers from REST APIs”) to write “agent stories” that describe the actions an agent might want to take, written from the agent’s perspective.

Beyond how you write individual tools, you should keep in mind that LLMs generally have soft limits on how many tools they can handle choosing from before their tool choice accuracy drops off. So while MCP client developers have some options for handling an overload of tools, as a server developer you should aim to keep the number of tools you expose to a reasonable number. Your users will likely use other servers as well, and they will appreciate having a few well-defined tools that they can reliably call in their applications.

Note

Client developers have more options to deal with too many available tools, such as filtering the tool list they receive (at a cost of reduced flexibility) or developing multi-agent systems with specialized subagents, than end users who are using your server directly with an application like their IDE or Claude Desktop. Be sure to keep all of your potential users in mind:

application and client developers using a known set of servers

client developers building generic MCP server support into their applications

end users who will add your server to applications they use, but are unable to modify

As mentioned earlier, the FastMCP API automatically infers input and output schemas from the tool’s function signature. A consequence of this is that for many output types, MCP tools return validated structured output by default, which ensures that the tool output has a consistent shape and is easier to work with. These output types are:

Pydantic models

Python TypedDicts

Dataclasses

Classes with type hints

Dictionaries with string keys and JSON-serializable values

Python primitive types, which get wrapped in a dictionary with a result key

Python generic types, which get wrapped in a dictionary with a result key

While this covers nearly every kind of output you can actually return in Python, you can find the most up-to-date list in the Python SDK’s README. In addition to the structured output types, the FastMCP package provides Image and Audio types that you can use as tool outputs, which automatically construct and return native ImageContent and AudioContent response blocks to the client, without you or the client developer having to do any additional work. The next example shows tools that return a Pydantic model and an image.

Example 5-8. Tools that return a Pydantic model and an image.

from mcp.server.fastmcp import FastMCP, Image
from PIL import Image as PILImage
from PIL import ImageDraw
from pydantic import BaseModel

# Initialize FastMCP server
mcp = FastMCP("structured-output-server")

class ReportCard(BaseModel):
    name: str
    grades: list[tuple[str, int]]  # class name and grade

@mcp.tool()
async def generate_report_card(name: str, grades: list[tuple[str, int]]) -> ReportCard:
    """
    Generate a report card for a student.

    Args:
        name: The name of the student
        grades: A list of tuples containing the class name and grade
    """
    return ReportCard(name=name, grades=grades)

@mcp.tool()
async def generate_report_card_image(report_card: ReportCard) -> Image:
    """
    Generate a report card image for a student.

    Args:
        report_card: The report card to generate an image for
    """
    image = PILImage.new("RGB", (400, 200), color=(255, 255, 255))
    draw = ImageDraw.Draw(image)
    draw.text((100, 100), report_card.name, fill=(0, 0, 0))
    return Image(data=image.tobytes())

if __name__ == "__main__":
    # Initialize and run the server
    mcp.run()

In this example, we have a Pydantic model called ReportCard which represents a student’s report card and includes their name and a list of class-grade tuples. This will define the structured output format for the tool and function generate_report_card(). Next is the implementation of the generate_report_card() tool which just takes the input data and returns it in the structured ReportCard model format. The other tool, generate_report_card_image() demonstrates how the FastMCP Image class works: you import the Image class from FastMCP and return an image object constructed from that class. In this example, we used Pillow to open an image, draw to it, then add the student’s name to it. We have to return a fastMCP.Image object, so we convert the image to a bytes object using the tobytes() method and pass it to the Image constructor, returning the intantiated Image object. You don’t have to use Pillow to manipulate your image, but you do have to be able to convert it to a bytes object.

Server developers do have some important security responsibilities when it comes to tools, however. Because your tools can be executed on a user’s machine, on an application’s server or internal network, or on a server you control, you need to control the inputs they take, what they output, and who exactly can access them. This means validating all inputs to your tools (both for type and content), sanitizing any outputs that could be sensitive, implementing access controls where necessary (such as providing authenticated access to a third-party service), and rate limiting tool calls. These actions all guard against abuse of your tools and safeguard sensitive data that your tools could access, and are highly dependent on what your tools actually do. Building effective safeguards also requires understanding how tools are used within the protocol, and the order of operations for model-client-server interactions when tools are provided and potentially used.

How Tools Are Used

Generally, tools are used by the model when and how it decides to call them. This is a necessary component of agency as defined by Anthropic and discussed in [Link to Come]. Tools aren’t actually limited to this interaction model, however: application developers are free to implement any interaction model that works for their application, such as an agentic workflow that calls tools in a more tightly controlled manner or a hybrid model where the LLM chooses some tools to use and the application handles other parts of the workflow. This choice, along with following the MCP specification’s guidelines on interacting with tools, is largely up to the application developer, while the server developer’s responsibilities lie in providing tool lists, the tools themselves, and depending on the deployment strategy, the execution environment for the tools.

When an application connects to a server via a client, the client will first send a list tools request to the server. In the Python SDK, this calls the server’s list_tools() function, which is automatically implemented by the FastMCP API or manually implemented when using the low-level API. This will return a list of tools to the client, which is sent with every following request to the model when the model is acting as an agent. The model will then select which tool to use, if any, and if a tool is selected, the client will send a tool call request to the server. In the Python SDK, this will call either the server’s call_tool() function or the FastMCP API’s tool() function, which decorates tool calls and allows the request to invoke the appropriate tool function with the appropriate arguments. This will validate input arguments and outputs against the tool’s input schema and output schema (whether crafted manually in the low-level API or automatically in the FastMCP API), and then return the results to the client.

A refresher on decorators

Decorators are a bit of syntactic sugar in Python that allow you to modify the behavior of a function without changing the code of the function itself. They work because functions are first-class objects in Python, which means, among other things, that you can pass functions as arguments to other functions. So when you decorate a function, the decorated function is passed as an argument to the decorator function, which then will call the decorated function at some point during the execution of the rest of its logic. This is done by defining a wrapper() or handler() function within the body of the decorator function definition (the name of the inner function doesn’t matter, these are just common conventions) that does its logic and calls the function that it was passed, and that inner function is returned by the outer (decorator) function. In the MCP Python SDK’s low-level API, this is the call_tool() function. It actually has two nested inner functions. The deepest inner function (called handler()) handles the request, parsing the tool name and arguments from the request and getting the tool definition from the server’s internal cache. It optionally will validate arguments against the tool’s input schema, and then calls the tool function with the tool’s name and any arguments. It takes the results and handles parsing them into structured and/or unstructured outputs depending on what the tool returns, then validates the output against the tool’s output schema. It then creates the SDK’s ServerResult object, which it returns. The function that wraps this one is called decorator(), which takes the function argument, defines the inner handler function, then registers that handler by adding it to the server’s request_handlers dictionary. The original function is then returned, and the outer function returns the decorator function, as is typical for decorators. This flow is often confusing, and it isn’t helped by the inherent complexity of the call_tools() function.

The FastMCP API’s tool() decorator is a bit simpler. It has only a single inner function called decorator(), and all it does is take a function as an argument and then calls add_tool() on the server instance, which adds the tool definition (as a Tool object) to the server’s internal cache, implemented in a separate class called ToolManager. If a tool needs to be called, the tool manager’s internal call_tool() method is called, which gets the tool from the registry and then calls the Tool object’s run() method, which, like the low-level API’s handler(), does optional argument validation along with output validation and conversion to the appropriate output type. This is simpler to follow, but more spread out across multiple classes in the codebase. You are also able to pass optional parameters to the tool() decorator itself, such as name which will override the tool function’s name, title which gives a human-readable name to the tool, description which will override the tool function’s docstring, annotations which takes a ToolAnnotations object with various hint properties to better describe the tool, and structured_output which always creates a tool with structured output if set to True, never does if set to False, and if not set at all it will try to infer the output type from the function’s return type annotation.

Decorators are often difficult to wrap your head around, and when writing my own I frequently find myself referring back to several tutorials to help refresh myself on the concept and write them appropriately. One of my favorite resources for this is Primer on Python Decorators by Geir Arne Hjelle on Real Python.

The result is passed to the client, which will then send it to the model to continue its own agent loop. In addition, the server is able to send list_changed notifications to the client any time the list of available tools changes. In the Python SDK, you can manually send this notification with ctx.session.send_tool_list_changed(), where ctx is the MCP context object that can be passed in to a tool function as a parameter. Depending on how the client is implemented, this may prompt the client to make a list tools request back to the server, which will then refresh the client’s tool list. The following figure illustrates the communication flow between the model, client, and server for tool use.

To tie all of this together, the following example implements a namespaced tool that returns structured output via a Pydantic model and sets a more human-readable name for the tool. It also creates a simpler tool that returns unstructured output, forcing the server to always return unstructured output and adding a read-only hint to the tool’s annotations.

Example 5-9. A full tool implementation using the features afforded by the FastMCP API.

from random import randint

from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations
from pydantic import BaseModel

# Initialize FastMCP server
mcp = FastMCP("full-tool-server")

class Class(BaseModel):
    title: str
    grade: int
    instructor: str
    credits: int

class ReportCard(BaseModel):
    name: str
    grades: list[Class]
    weighted_gpa: float | None = None
    unweighted_gpa: float | None = None

def _generate_classes() -> list[Class]:
    return [
        Class(
            title="Math",
            grade=randint(0, 100),
            instructor="Mr. Smith",
            credits=randint(1, 4),
        ),
        Class(
            title="Science",
            grade=randint(0, 100),
            instructor="Mrs. Johnson",
            credits=randint(1, 4),
        ),
        Class(
            title="History",
            grade=randint(0, 100),
            instructor="Mr. Brown",
            credits=randint(1, 4),
        ),
    ]

@mcp.tool(title="Generate Report Card")
def grader_generate_report_card(
    name: str, classes: list[Class] | None = None
) -> ReportCard:
    """
    Generates a full report card for a student and a list of classes.
    Can leave out the list of classes to use a randomly generated list.

    Args:
        name: The name of the student
        classes: An optional list of Class objects to add to the report card
    """
    if not classes:
        classes = _generate_classes()

    weighted_gpa = grader_calculate_gpa(classes)
    unweighted_gpa = grader_calculate_gpa(classes, weighted=False)
    return ReportCard(
        name=name,
        grades=classes,
        weighted_gpa=weighted_gpa,
        unweighted_gpa=unweighted_gpa,
    )

@mcp.tool(
    title="Calculate GPA",
    annotations=ToolAnnotations(readOnlyHint=True),
    structured_output=False,
)
def grader_calculate_gpa(classes: list[Class], weighted: bool = True) -> float:
    """
    Calculate the GPA for a list of classes. Calculates the weighted
    GPA by default, but can optionally calculate the unweighted GPA.

    Args:
        classes: A list of classes
        weighted: Whether to use weighted GPA
    """
    if weighted:
        return sum(_class.grade * _class.credits for _class in classes) / sum(
            _class.credits for _class in classes
        )
    return sum(_class.grade for _class in classes) / len(classes)

if __name__ == "__main__":
    # Initialize and run the server
    mcp.run()

In this example, we set up two Pydantic models: Class and ReportCard. These will serve as the optional input and output types for the tools to be implemented. Then, to aid in testing, we have _generate_classes() to randomly generate a list of classes. The first tool, grader_generate_report_card(), is a namespaced tool with the human-readable name “Generate Report Card” and a detailed description. If classes aren’t provided, it will generate a list of classes to add to the report card. It will then calculate the weighted and unweighted GPAs for the student, using the grader_calculate_gpa() tool as a regular Python function, and then returns the structured ReportCard object. Notice how this tool is set up to do a complete action, rather than rely on multiple separate tool calls from the application to compose the final result piece by piece. The second tool, grader_calculate_gpa(), is a simpler tool that makes use of a human-readable title, a read-only tool annotation, and forces structured output to be False. It takes a list of Class objects and a boolean flag to determine whether to calculate the weighted or unweighted GPA, makes the calculation, and returns a simple float result.

The next primitive that we will cover is the prompt. Your server can serve prompts to the host application, and it can choose to use them, which allows you to distribute your own well-tested prompts to your users, which can improve tool choice performance for them.

Serving Prompts

If you’re worked with LLMs for any length of time, you’re at least somewhat familiar with prompts. Prompts are the instructions that the LLM uses to generate its response and typically take the form of strings that are sent to the LLM to guide its response. For most popular models, there is a system prompt and a user prompt. The system prompt is persistent and guides the responses of the LLMs throughout the conversation, and you can typically either add to the default system prompt or completely replace it with a custom one. If you want to change the personality of the model, tell it how to use tools, give the model rules to follow, or provide additional context, you can do so by adding to or replacing the system prompt. The user prompt is usually generated by the user and is a direct query to the model, such as a simple question or a request to do a specific set of actions. It is not persistent, and is only sent to the model when the user actually submits their query.

Tip

System prompts are persistent, which means that they are sent to the model with every single user prompt and incur an input token cost. In designing system prompts, just like with user prompts, you should be mindful of the prompt’s token efficiency and cost.

In Python MCP servers, prompts are represented by functions that return either a string, a UserMessage, an AssistantMessage, or a list of one or both message objects representing a partial conversation, and are decorated with the @mcp.prompt() decorator. This probably sounds familiar: from the server’s perspective, prompts are essentially tools that have a restricted set of object types they can return and whose results are handled in a special way by the application. The structure of prompt functions is such that their parameters are typically used to inject users from the application into the prompt, but they can be used for anything that makes sense to include in your prompt function, like numbers that need to be computed together in order to inject the correct value into the prompt. With MCP, applications users or applications themselves that are using your server can decide how to use the prompts that you provide, so if you aren’t using the Message classes provided by the MCP SDK to guide the users, you may find your prompts being used in unexpected ways.

Designing Prompts

Remember prompt engineering? While the many pronouncements that it would be an entirely new discipline and profession were a bit premature, prompt engineering is still a valuable skill to have when working with LLMs. It almost goes without saying, then, that if you’re designing prompts to be distributed by your MCP server, you should have a solid grasp of prompt engineering. Prompt Engineering for Generative AI (O’Reilly, 2024) defines “Five Principles of Prompting,” which are:

Give direction

Specify format

Provide examples

Evaluate quality

Divide labor

The first principle, give direction, is the most basic and most fundamental principle of prompt enginering. Your prompts should always give clear direction to the LLM, both to guide the task (what you’re prompting it to do) and to guide the format (how you’re prompting it to respond). This means that the prompts that you provide for your MCP server should effectively do something, which you can accomplish by providing clear, simple, and concise instructions, and by listing specific guidelines or rules that the LLM should follow in its response. It also means that your prompt should do things like provide a persona or examples to augment both the content and the sound of the LLM’s response.

The next principle is to specify format. This typically refers to a structured output format, such as YAML or JSON, but can also refer to a more general layout of the response. Depending on the goal of your prompt, this may or may not be appropriate or necessary. For example, if you are providing prompts that only serve to accurately call your server’s tools, the output format doesn’t matter so much. But if you want your prompt to respond with a properly-formatted YAML document, you can directly specify your desired output format in the prompt, provide a partial or full example of the desired output in the prompt, or, more likely with MCP, create a YAML conversion tool, but that last option was already covered in depth in “Serving Tools”.

Given these techniques that we can use to specify formats in our prompts, it’s unsurprising that the third principle is to provide examples. Like people, LLMs work best when they have a few examples to work from. This is called few-shot learning, and is a powerful technique for improving the accuracy and performance of LLMs. There is a tradeoff to consider here however: the more examples you provide, the more constrained and less creative the LLM’s response will be. Sometimes, this is desirable, but other times, it’s not. You will have to consider what you really want out of your prompt and evaluate its performance with different example sets across many runs. Doing so will be covered in [Link to Come], but some tools you can start looking at now are PromptBench and PromptFoo.

This actually is the fourth principle: evaluate quality. You should always evaluate the quality of your prompt by running it against a variety of models, especially since you’ll be distributing your prompts to users who can use it with any model, thanks to the magic of MCP. You can use the tools mentioned above, or do simple A/B testing with two different prompts using a model’s API. The final principle is to divide labor. If you start noticing your prompt growing and attempting to do more, it may be time to split it up into separate prompts. With MCP, you can also use advanced techniques like sampling, covered in [Link to Come], to request responses from the LLM via the client in the host application, and then provide a final prompt with context to the user or even run all the prompts through the LLM, bypassing the typical user interaction model of MCP prompts. Some other general rules of thumb for prompt design are to avoid saying what not to do (this can confuse the models), use precise descriptions (“fairly long” vs. “2 paragraphs of 3-5 sentences”, for example), and to use separators to separate the context and instruction portions of your prompt.

Tip

Prompt engineering is a complex and nuanced topic, worthy of its own books, courses, and other educational materials. While this section covers the basics, you should also check out Prompt Engineering for Generative AI by James Phoenix and Mike Taylor (O’Reilly, 2024) and Prompt Engineering for LLMs by John Berryman and Albert Ziegler (O’Reilly, 2024) to become a prompt engineering expert.

As mentioned at the beginning of this section, prompts are typically split into system prompts and user prompts. In the major models (such as those from OpenAI and Anthropic), these are represented as messages with roles, system and user, respectively. System messages or system prompts specify the manner in which the model should respond to user messages or prompts, and can involve things like taking on a persona (“you are a helpful assistant”) or providing guidelines or rules for the model to follow (“always respond in a friendly and helpful manner”). User messages or prompts represent inputs or requests from a user (and not necessarily a human user!) to the model, and are often direct questions or requests, such as “What are the 50 most popular restaurants in New York City?” Additionally, these models also have a third role, assistant, which is used to represent the model’s response. When you’re crafting prompts for MCP servers, the SDK provides UserMessage and AssistantMessage classes to represent these messages, making it easy to ensure your prompt is used in the correct way and allowing you to craft multi-turn prompts. A notable omission in the SDK is the lack of a SystemMessage class, if you want to provide a system prompt, you will need to return a plain string and document that the prompt is intended to be used as a system prompt.

There are also some tricks you can use to improve your prompts that are model-specific, but they may not necessarily reduce performance when used on other models. Since MCP servers are model-agnostic, it is important not to rely too much on these tricks, but any concerns about performance can be alleviated with exhaustive testing. For OpenAI models, one of their major prompt engineering techniques (as detailed in the OpenAI documentation) is to fence off the context from the instruction portions of the prompt, using either \#\## or """. For example, the following prompt instructs the model to generate a list of 3 main ideas from user input added below it. The user input is fenced off from the instruction with \###:

Create a list of 3 main ideas from the following text:

###
{user_input}
###

In this example, the instruction block is “Create a list of 3 main ideas from the following text:”, and the user input is marked as context with the \### fences. In Anthropic’s models like the Claude family, XML tags are preferred for marking specific portions of the prompt. Using XML tags allows for more flexibility, as you can mark off anything that you might want to, give it a descriptive tag, and then refer back to that tag later. For a simple example, here is the same prompt using XML tags:

<instruction>
Create a list of 3 main ideas from the following text:
</instruction>

<text>
{user_input}
</text>

In this example, instead of using fences with a single meaning, we use more expressive XML tags to mark the instruction and text portions of the prompt. While this example is simple, you can imagine using multiple hierarchical XML tags to help better contextualize sections of the prompt, such as a document resource with a title, preamble, and body. You can even chain prompts, requesting that the response include tags, then your next prompt can refer back to those tags for the next prompt in the chain. Since we are starting to look at actual prompts, it is time to start thinking about how to use them in your MCP server. In the beginning of this section, you learned that prompts in MCP are provided by functions, much like tools are. Prompt functions, however, must return either a string, a dictionary with the keys role and message, a Message object (or one of its children), or a sequence (such as a list) of any of the listed types. Prompts are registered by decorating the prompt function with @mcp.prompt(). This decorator allows you to optionally specify the prompt’s name, title, and description, just like with the @mcp.tool decorator. In the following example, you will see 3 prompt functions: one that returns a static string, one that returns an f-string with user input, and one that returns a UserMessage object with XML tags.

Three simple prompt functions depicting the different ways to return a prompt.

Example 5-10.

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.prompts.base import UserMessage

# Initialize FastMCP server
mcp = FastMCP("simple-prompt-server")

@mcp.prompt()
async def simple_string_prompt() -> str:
    """A simple, static prompt that greets the user."""
    return "Say hello to the user."

@mcp.prompt()
async def simple_prompt_input(username: str) -> str:
    """A simple prompt that greets the user with their name."""
    return f"Say hello to the user using their name: {username}"

@mcp.prompt()
async def simple_example_prompt(user_text: str) -> UserMessage:
    """A simple prompt that summarizes the input text, using XML tags."""
    return UserMessage(
        content=f"""
<instruction>
Create a list of 3 main ideas from the following text:
</instruction>

<text>
{user_text}
</text>
    """
    )

if __name__ == "__main__":
    mcp.run()

The first prompt function, simple_string_prompt isn’t terribly interesting. It simple returns a static string that instructs the model to greet the user by saying “Hello.” The second one is slightly more complex. It takes a single argument, username, and returns a string that instructs the model to gree the user by name, according to the user input. The third one shows another way to return a prompt, and that’s by creating a UserMessage object and returning it. The string still has user input interpolated into it, but now uses XML tags to assist the model in understanding the prompt and encloses it in a UserMessage object.

What if simple_example_prompt() returned a list of UserMessage`s? This would represent a _multi-turn prompt_, which is a prompt that includes multiple `Message objects, representing a conversation between the model and the user. Each Message represents a “turn” in the conversation that is taken up by one of the participants. Each Message object has a role, user or assistant, which is used to show whether the user or the model is speaking in that turn. This is powerful because you can “preload” a conversation, which allows you to even more strongly guide the model’s response. The real power in this relies in leaving the assistant’s response partially filled in (a technique called prefilling), which allows you to exert more control over the model’s response to your prompt. On its own, prefilling the model’s response can be used to provide a structured output format or have the model maintain a role, depending on the user interaction model. In the next example, the prompt function returns a multi-turn prompt that prefills the assistant’s response with a list format.

A multi-turn prompt that prefills the assistant’s response with a numbered list format.

Example 5-11.

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.prompts.base import AssistantMessage, UserMessage

# Initialize FastMCP server
mcp = FastMCP("multiturn-prompt-server")

@mcp.prompt()
async def multiturn_prompt(
    main_idea_count: int, user_text: str
) -> list[UserMessage | AssistantMessage]:
    user_input = UserMessage(
        content=f"""
<instruction>
Create a list of {main_idea_count} main ideas from the following text:
</instruction>

<text>
{user_text}
</text>
    """
    )

    assistant_prefill_test = f"""
Here are {main_idea_count} main ideas from the text:
1.
"""
    assistant_prefill = AssistantMessage(content=assistant_prefill_test)
    return [user_input, assistant_prefill]

if __name__ == "__main__":
    mcp.run()

In this example, we have a prompt function multiturn_prompt(), which uses the same UserMessage as simple_example_prompt() in ???, but returns a list of UserMessage and AssistantMessage objects to represent a multi-turn conversation. The UserMessage is slightly modified from simple_example_prompt() to take a main_idea_count argument, and the AssistantMessage is prefilled with an introductory response and then the beginning of a numbered list. This will encourage the model to continue the list, filling it in with the requested information.

You can also use prompts to control the model’s behavior in other ways. For example, it is possible to encourage the model to prioritize a tool when responding to a user’s request. To do something like this, you can just take the user’s input and add a request to check for the applicability of a certain tool before any others. You can also be less subtle, and simply tell the model to use the tool no matter what the user request is. In the following example, the prompt function request_tool_use takes the user’s request and appends an additional instruction to it, telling the model to use a specific tool and to add the result of that call to the end of their response to the user. The force_tool_use() prompt function forces tool use by directly calling the tool and prefilling the assistant’s response with the result.

A prompt function that requests tool use by appending an additional instruction to the user’s request.

Example 5-12.

import random

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.prompts.base import AssistantMessage, UserMessage

# Initialize FastMCP server
mcp = FastMCP("tool-use-prompt-server")

@mcp.tool()
async def analyze_sentiment() -> str:
    """A tool that tells the truth."""
    return random.choice(["positive", "negative", "neutral"])

@mcp.prompt()
async def request_tool_use(user_request: str) -> UserMessage:
    """A prompt that forces the model to call a tool."""
    return UserMessage(
        content=f"""
<user_request>
{user_request}
</user_request>
<tool_instruction>
Use the analyze_sentiment tool if available to you to get the sentiment of the
user's request. Respond in such a way to move the user's sentiment to neutral.
</tool_instruction>
    """
    )

@mcp.prompt()
async def force_tool_use(
    user_request: str,
) -> list[UserMessage | AssistantMessage]:
    """Directly calls the tool and adds the result to the response."""
    user_request_message = UserMessage(content=user_request)
    tool_result = await analyze_sentiment()
    assistant_prefill = AssistantMessage(
        content=f"Your request was {tool_result}, let's "
    )
    return [user_request_message, assistant_prefill]

if __name__ == "__main__":
    mcp.run()

In this example, we have a tool called analyze_sentiment(), which returns a random sentiment rating to the user. The request_tool_use() prompt function takes the user’s request and adds an additional instruction for the model, telling it to use the function and to use those results to guide how it responds to the user. force_tool_use(), on the other hand, directly calls the tool function, and prefills the assistant’s response with the result and the beginning of a response to the user. This code is simple, but you can probably start to see how you can influence an application’s behavior with your prompts and combining them with tools. Resources, which we will cover in the next section, can also be used within MCP prompts. Resources are data sources provided by your MCP server that have a URI that is specified by the server developer (you). You can use this URI in your prompts to ensure that the model uses the resource in some way, such as using it as a springboard for analysis or facts for a response. The next example shows how you can refer to resources in your prompts by using a resource URI.

A prompt function that uses a resource URI to refer to a resource in the prompt.

Example 5-13.

from pathlib import Path

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.prompts.base import UserMessage
from mcp.types import ResourceLink
from mcp.server.fastmcp import FastMCP

# Initialize FastMCP server
mcp = FastMCP("basic-resource-server")

@mcp.resource("file://knowledge.txt")
async def knowledge_base() -> str:
    """A resource that loads a test-based knowledge base."""

    # Get the absolute path to knowledge.txt relative to this script
    knowledge_path = Path(__file__).parent / "knowledge.txt"

    with open(knowledge_path, "r") as f:
        return f.read()

@mcp.prompt()
async def knowledge_base_prompt(user_request: str) -> UserMessage:
    """A prompt that uses the knowledge base resource."""
    user_request_message = UserMessage(content=user_request)
    instruction_message = UserMessage(
        content="""
This prompt includes knowledge base from the resource URI: file://knowledge.txt,
please use this resource to answer the user's request. The resource follows
this message:
"""
    )
    resource_message = UserMessage(
        content=ResourceLink(
            name="knowledge_base", uri="file://knowledge.txt", type="resource_link"
        )
    )
    return [user_request_message, instruction_message, resource_message]

if __name__ == "__main__":
    mcp.run()

In this server, the function knowledge_base() is designated as a resource with the @mcp.resource() decorator, and the argument to it sets its URI to file://knowledge.txt. The function opens the file and returns its contents as a string. You’ll learn more about resources in “Serving Resources”. The knowledge_base_prompt() function is the prompt function, which is marked with the @mcp.prompt() decorator. It takes a user_request argument and builds a multi-turn prompt that includes the user’s original request, an instruction to use the resource with its URI, and a ResourceLink UserMessage that points to the resource’s URI. This prompt function adds the text resource to the user’s request and instructs the model to use the resource to help answer the user.

How Prompts Are Used

These examples show how to serve prompts to host applications, and the magic of MCP means that as a server developer, you don’t necessarily have to worry about how users (host applications) use your prompts. But just as with tools and as you will see with resources, it is still important to understand the intended user interaction model for prompts, how applications will typically use them, and how the implemented protocol transfers prompts between the server and host application. The MCP specification doesn’t necessarily force one particular way for users or applications to interact with prompts, but prompts were designed in the protocol to be user-controlled. This means that the user should be able to select which prompts to use and when to use them, which has been typically implemented as a menu selection or as slash commands in chat applications. Understanding how your prompts are likely to be used will help you design them in such a way that gives the user a seamless experience.

Understanding unpredictable ways applications may use your prompts can also help you provide a more flexible experience for your users. For instance, if you know that applications can technically use your prompts as user messages, assistant messages, or even system prompts, then you might be pushed to return a list of UserMessage objects instead of a simple string in order to give the application more information about how your prompt was designed to be used. Your server may be used with applications that do not allow the user to see or select prompts, as in Chapter 3 where prompts were chosen dynamically by the model instead of by the user. Your server may also be used by applications that put prompts into the system prompt, instead of presenting them as a user or assistant message. This is more complex to implement, but it does exist, so it’s worth documenting whether your prompts are meant to be used in this way or not, either via a README, docstrings, or by explicitly returning UserMessage and/or AssistantMessage objects.

The flow of messages between the server and client follow a similar overall pattern to that of tools and resources: there is an initial discovery phase that is followed by a use phase, and during the lifetime of the client-server connection, a set of messages sent back and forth when the server’s prompt list changes. The discovery phase happens automatically when the client connects to the server, with the client sending a prompts/list request to the server and the server responding with a list of prompts. This normally happens after the client and server announce that they support prompts and any other primitives. The usage phase is typically triggered by the user selecting a prompt from a list of prompts within the host application. This should cause the client to send a prompts/get request to the server, and the server will respond with the prompt itself. During the lifetime of the client-server connection, the server may send a prompts/list_changed notification to the client, notifying the client that the list of available prompts has changed. The client should then respond with a prompts/list request to the server and the server will respond with the updated list of prompts.

The following figure illustrates how these request and response messages flow back and forth between the client and the server:

The next and final MCP primitive is the resource. This section gave you a sneak peek at them, and in the next section you will go deep into resources, how they work, how to build them, and how you will see users using them in action.

Serving Resources

Resources are the final of the three MCP primitives. They provide read-only access to data sources that can then act as additional context for the host application’s language model, and these data sources can be just about anything:

log files

database schemas

images

PDFs

structured configurations (JSON, YAML, etc.)

Of course, you are not limited to these types of data sources. You can use resources to serve any type of data that would be useful to the host application’s language model. Resources are defined with an mcp.resource() decorator, which allows you to specify the resource’s URI, name, and more. The URI is required, as it is the unique identifier or key to access the resource itself. Descriptions are strongly recommended as well, because they can be passed to the host application’s language model by the client to assist it in choosing which resources, if any, to use, either via the method shown in Chapter 3 where the model is always prompted to choose a resource, or by including resource URIs and descriptions in a prompt provided by your server. Resource annotations are optional hints that you can provide to any clients using your server to help them understand how to use the resource. Resource templates allow you to parametrize resources by using URI templates, which is crucial for easily providing a broad range of different but related resources. You will do a deep dive into each of these topics in “Exposing Resources”.

Resources can be used in a variety of ways, with more yet to be discovered by the community, but these uses are all in service of the same goal: providing additional context to a language model to help it make better decisions and enrich its responses. In “How Resources Are Used”, you will learn about some of the ways production MCP servers are putting resources to use, such as for log analysis, providing documentation to enrich answers to coding queries, caching resources in prompt chains, and to discover which sub-resources are available. You will see how to suggest resources to be used with a tool, much like the ??? example in the previous section.

First, let’s examine how to actually expose resources to the MCP clients that will connect to your server.

Exposing Resources

So you now know that a resource is a data source that provides context to a language model. But what does that look like in practice? In the MCP Python SDK, resources are exposed with Python functions decorated by @mcp.resource(). In this decorator, you can specify the following properties:

uri

Required. A unique identifier for the resource that conforms to RFC 3986.

name

Optional. A name for the resource, defaults to the function name.

title

Optional. A human-readable name for the resource.

description

Optional. A description of the resource, defaults to the function’s docstring.

mime_type

Optional. The MIME type of the resource to be returned, defaults to text/plain.

icons

Optional. A list of Icon objects that can be used by the client to display the resource.

Note

The icons parameter is undocumented in the Python SDK, but it is available for use with resources but not resource templates. An Icon object has a src URI or URL, an optional mimeType, and optional sizes string.

While URIs just have to conform to RFC 3986, there are some standard URI schemes that the MCP specification defines: https://, file://, and resource://. https:// should be used for web-based resources that the client can access on its own with no assistance from the server. file:// should be used for local files and anything else that is file-like, including filesystems. git:// is also defined in the protocol, and should be used to denote git resources, such as a repository or commit. MCP also allows you to create your own custom URI schemes, as long as they conform to RFC 3986.

The body of the prompt function should access, read, and return the contents of the resource. It is called whenever the client requests the resource, so it should be kept light and efficient. It should return either a string for text resource content or bytes for binary blobs, and anything else (like a dictionary) will be converted by the SDK to JSON. In the following example, you will see a simple resource function that reads a file and returns its contents as a string.

A simple resource function that reads a file and returns its contents as a string.

Example 5-14.

from pathlib import Path

from mcp.server.fastmcp import FastMCP

# Initialize FastMCP server
mcp = FastMCP("basic-resource-server")

@mcp.resource("file://knowledge.txt")
async def knowledge_base() -> Resource:
    """A resource that loads a test-based knowledge base."""

    # Get the absolute path to knowledge.txt relative to this script
    knowledge_path = Path(__file__).parent / "knowledge.txt"

    with open(knowledge_path, "r") as f:
        return f.read()

if __name__ == "__main__":
    mcp.run()

This example is the same resource function that you encountered in ???, the URI is set to file://knowledge.txt, and the function returns the full path to the file as a string. The code building the MCP response that is called by the @mcp.resource() decorator will handle your path by opening and reading the file, returning the contents as a string. For the simplest resources, this can be all you need. Figure 5-4 shows the resource loaded into MCP Inspector, which you’ll learn more about in [Link to Come]. In this figure, you can see the list of resources the server provides in the left panel, which is just the knowledge_base resource, and in the right panel are the loaded contents of the resource. Notice how the uri and mimeType are displayed, and because this resource function returns a string, the contents are displayed under the text key.

Figure 5-1. MCP Inspector with the knowledge_base resource listed in the left panel and loaded in the right.

You are not limited to hardcoded URIs for resources. You can create resource templates, which are parametrized URIs that allow you to create a dynamic resource URI based on other information you might have. This is pretty similar to a parametrized GET endpoing in the REST API world. You can create a resource template the same way you create a resource function, but the URI must have variables enclosed in curly braces. In the next example, we have a server that provides a resource template, giving the client the power to choose whether it wants one file or the other.

A resource template that allows the client to choose between two files.

Example 5-15.

from pathlib import Path

from mcp.server.fastmcp import FastMCP

# Initialize FastMCP server
mcp = FastMCP("resource-template-server")

@mcp.resource("file:///{filename}")
async def resource_template(filename: str) -> str | bytes:
    """A resource that loads one of two files based on the filename parameter."""
    # Get the absolute path to the file relative to this script
    file_to_load = Path(__file__).parent / filename

    # Determine if file is binary based on extension
    if file_to_load.suffix.lower() == ".txt":
        with open(file_to_load, "r") as f:
            return f.read()
    else:
        with open(file_to_load, "rb") as f:
            return f.read()

if __name__ == "__main__":
    mcp.run()

In this example, we create a resource template with @mcp.resource() by enclosing the variable part of the resource URI with curly braces. Notice that there is an extra / after the file:// URI scheme. This is needed when the variable portion of the URI is at the beginning of the URI, otherwise you can use file:// on its own. Any variable parts of the URI are also made into parameters for the resource function, which can then be used in the function itself. In this example, we get the Path object representation of the full path to the file in question, then check the file ending: if .txt, we read the file as a string, otherwise we read it into memory as a binary blob. Either way, the result is returned to the client. While not necessary for a simple example, in production-ready code, your filetype checks should be more expansive and robust than what is shown here. Figure 5-6 shows the resource template loaded into MCP Inspector with a valid filename parameter.

Figure 5-2. MCP Inspector with the resource_template resource template in the middle panel and loaded with a parameter value of 2.png in the right panel.

The way this decorator works is that the server will take your resource function and turn it into a FunctionResource object, which is then added to the server’s internal cache, the resource manager. Creating the FunctionResource object allows you to store the resource description and function itself in an object that looks like a resource, so that when the server receives a list resources request, it doesn’t have to actually load the data in every resource. Instead, loading the resource via the resource function is deferred until the client requests the resource, improving your server’s performance, especially if it’s serving large resources. You may also directly expose resources with the FastMCP API. To do this, you forgo the mcp.resource() decorator and instead create a Resource object with the desired properties directly, and then you call add_resource() on the FastMCP server instance to add it to the resource manager. FastMCP provides a number of Resource-derived clases that can be used to directly create resources without using a decorated function which can provide additional flexibility in how you enrich resources that you expose to the client. In the following example, you will see two ways to do this: the first is with a resource template function (you can do the same with a resource function), where we return a FileResource with the file’s path for later reading. The second is by creating a FileResource object directly in the main body of the server code and then manually adding it to the resource manager with mcp.add_resource().

Creating Resource objects in resource template classes and as standalone objects.

Example 5-16.

from pathlib import Path

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.resources import FileResource

# Initialize FastMCP server
mcp = FastMCP("resource-object-server")

@mcp.resource("file:///{filename}")
async def resource_template(filename: str) -> FileResource:
    """A resource that loads one of two files based on the filename parameter."""
    # Get the absolute path to the file relative to this script
    file_to_load = Path(__file__).parent / filename
    if file_to_load.suffix.lower() == ".txt":
        binary_flag = False
    else:
        binary_flag = True
    return FileResource(
        uri=f"file:///{filename}", path=file_to_load, is_binary=binary_flag
    )

filename = "1.txt"
file_resource = FileResource(
    uri=f"file:///{filename}", path=Path(__file__).parent / filename
)
mcp.add_resource(file_resource)

if __name__ == "__main__":
    mcp.run()

In this example, we modify the previous example’s resource_template() function to return a FileResource object, rather than a string or bytes object. We keep the simple file extension check, but only to set a boolean flag, which will be used for the is_binary parameter of the FileResource constructor. We return an instance of the FileResource class, passing in the URI, path, and binary flag. This example also demonstrates how to create a FileResource object directly in the main body of the server code and then using add_resource() to add it to the FastMCP resource manager cache. Generally, you should just use the @mcp.resource() decorator to create resources, but if you need something more specialized, a Resource-derived class could be a good fit.

Tip

FastMCP provides several convenience classes for resources, including FileResource, HttpResource, FunctionResource, and more. You can find these Resource-derived classes in the mcp.server.fastmcp.resources module, or in the Python SDK’s Github repository.

You can also annotate your resources and resource templates, which provides additional information about the resource to the client. The protocol defines the accepted annotation keys as audience which is a JSON array of string with values user, assistant, or both, priority which is a range from 0.0 to 1.0 which indicate sthe importance of the resource, and lastModified which stores the last modified timestamp of the resource. Unfortunately, the version of FastMCP bundled with the MCP Python SDK does not support these annotations, so if you want to build them, you’ll either have to use FastMCP 2 (2.11 or later) and add annotations to as a parameter to the decorator or use the low-level server in the standard Python SDK, where you can return a Resource object with an annotations field.

In the two previous examples, you saw two different content types being returned: text and binary blobs. Text is what you’ll want to use for any kind of text-based resource, including, for example, configuration files, log files, and even JSON data. Binary blobs are typically used for images, audio files, PDFs, anything that isn’t directly represented as text and are returned as bytes objects. Anything that gets returned in a resource or resource template function that isn’t a string or bytes object will be converted to a JSON string and returned to the client.

Warning

Be wary of antipatterns when developing resources. Resources should be lightweight and avoid doing any heavy calculations in their functions. Similarly, resource functions should not have any side effects, and they shouldn’t be used to trigger other actions or operations. These are all possible because resource access is mediated by Python functions, so they can technically be abused to do more than they’re designed to do. Resist that temptation, and keep your resource functions lightweight and focused on bringing the resource to the client.

Under the hood, and directly in the low-level server, there are a few actions that the protocol defines for resources. The first is listing resources, which is triggered with a resources/list request to the server, to which the server responds with a list of resources that includes the URI, name, title, description, and mimeType of each resource that the server makes available. This is automatically handled by FastMCP much like the analogous responses for tools and prompts, but if using the low-level API you will need to write your own implementation. Similarly, there is a resources/templates/list request for listing resource templates and it works just like the resources/list request. Reading a resource is done with a resources/read request to the server, which includes a URI so that the server can return the proper resource. In the Python SDK, the resource function is called to retrieve the resource. Clients can also subscribe to individual resources, and get notifications when the resource changes.

How Resources Are Used

Resources can be used in a variety of ways, but the the overarching theme is that they provide additional context to the host application’s language model in order to help it make better decisions and more sense of its operating environment. This could be in the form of log analysis, where resource templates are used to select a subset of logs to provide to an analysis or helpdesk agent, or in coding documentation, where MCP servers like the AWS MCP servers and Context7, which both provide up-to-date code documentation to the model, improving the quality of responses by coding assistants.

There are other, more exotic uses of resources as well, one of which is using resources for entity discovery. This pattern is useful if you have a large number of resources that you need to discover and use, or if the available resources are dynamic and depend on the user or application accessing the server. In this pattern there is a resource that returns a dictionary of whatever entity you’re building the resource for (in the linked article, these are documentation knowledge bases), where the key is the entity’s internal ID, and the value is a dictionary of the entity’s properties that will help the model decide which entity to use. With a list of entities and descriptions, those could be added to the user’s prompt in a list of UserMessage objects, allowing the model to choose the proper entity, then that entity could be passed as a parameter to a query tool for querying the actual data.

Another interesting use of resources is to build a cache of resource references, preventing users from blowing up the prompt context with resources that are no longer necessary. While this is generally the domain of the client developer to implement, it’s worth noting how your server could be used to implement this pattern. In this pattern, the user is envisioned as doing a RAG-like operation, requiring the model to get resources from the server (or for the user to select them). The RAG tool would return a list of URIs, which the client developer would add to the prompt, e.g. with rich XML tags that include the URI and description, and for new items, include their text in the following user prompt. The full text is included for new items because those are likely what the user immediately wants to use as context for their query. The much compressed XML is also passed to the model, and if the model decides to use the resource, the application can parse the response for URIs and retrieve them from the cache. The tupac project, which was linkedd in Chapter 3, implements this pattern for caching resources in the prompt context.

Resources are application controlled, meaning that it is up to the host application to decide how to integrate resources into their model context. The message flow for resources is fairly simple and not too different from what you saw for prompts. Initially, the client will send a resources/list request to the server, and the server will respond with a list of resources that it has available. Then, if it’s time to use a resource, the client will send a resources/read request to the server with the appropriate URI, and the server will respond with the contents of the resource. Subscriptions have a similar 2-step flow: the client sends a requests/subscribe request to the server, which returns a confirmation response. The only action that is server-initiated is update notifications. When a resource is updated, the server will send a notifications/resources/updates notification to the client, which should respond with a resources/read request for the updated resource. The following figure illustrates the message flow for each of these actions.

This chapter only covered the most basic uses of the three MCP primitives: tools, prompts, and resources. You learned about what servers are used for in the MCP ecosystem, the different ways your server can connect to clients, and the two APIs available within the MCP Python SDK to build your own servers: FastMCP and the low-level API. The remainder of the chapter covered the primitives, and you learned how to best design and then use tools and prompts, and how to expose resources. We also covered the client and server interaction patterns for each primitive as well as both common and less common, but more creative uses of them. These can be used as inspiration for new use cases as you build your own MCP servers, or they may become dispensible tools in your toolbox as you build your own agents.

In the next chapter, we will go deeper into the details of server development. We will talk about using server utilities like completions, logging, notifications, and pagination, and then we will also learn how to user client-provided resources if they’re available, such as elicitations for getting user input, sampling to allow the server to query the host application’s language model, and roots to mark the where in the host filesystem the server can access.

About the Author

Kyle Stratis is the founder and chief consultant of Stratis Data Labs, where he provides startups and large firms alike with his expertise in AI engineering, computer vision, data engineering, and machine learning platform engineering garnered over a decade as a software engineer. In addition to designing data architecture, building AI platforms and agents, and migrating analytics workflows to the cloud for his clients, he writes about Python, LLMs, and personal knowledge management.