Lesson 16: Foundations of Agentic Systems with FastMCP
In Part 2A of this course, we designed our research agent, Nova. We decided to keep its orchestration layer thin and push the heavy lifting into a set of portable, reusable tools. For this, we chose the Model Context Protocol (MCP) as our interoperability layer and FastMCP as our Python implementation.
This lesson is the beginning of Part 2B, where we shift from design to hands-on implementation. We will turn our architectural diagrams into a running system. By the end of this lesson, you will run a complete MCP server and client, discover the capabilities they expose, and execute your first tool calls, all using the actual code from the research_agent_part_2 project.
You will learn how to:
- Create an MCP server using
fastmcp - Use the
fastmcplibrary to expose MCP tools, MCP resources, and MCP prompts - Use the
fastmcplibrary to interact with an MCP server - Use both
in-memoryandstdiotransports. - Understand the code layout for a scalable, MCP-based agent.
Why MCP for Our Research Agent
In our previous lessons, we explored the trade-offs between rigid LLM workflows and flexible, autonomous agents. For our research agent, Nova, we require a system that adapts at runtime, supports human steering, and enables us to reuse tools across various clients, such as IDEs or command-line applications. Our initial prototypes with static workflow graphs were clean, but struggled with the open-ended nature of research.
To solve this, we are decoupling the agent’s tools from its orchestration logic using the Model Context Protocol [1]. As discussed in Lessons 12-14, this architecture provides the loose coupling we need. We will expose Nova’s capabilities through a dedicated MCP server and manage the workflow using a lightweight MCP client. The entire research process is defined as a server-hosted prompt, making the workflow portable and available to any MCP-compliant client.
Quickstart in the Notebook (In-Memory)
The research agent is made of an MCP server and an MCP client. The MCP server is a fastmcp server that registers MCP tools, MCP resources, and MCP prompt via router modules. The MCP client is a fastmcp client that connects to the MCP server and allows you to interact with it, along with interacting with the LLM agent.
The fastest way to see our system in action is to run the client directly inside our course notebook. This setup uses the in-memory transport, where the MCP server is instantiated and run within the same Python kernel as the client. Make sure you have configured your environment and Gemini API key as described in the Course Admin lesson.
This text is the result of the client performing capability discovery , where it queries the server for all the tools, resources, and prompts it exposes. It outputs:
Once the client is running, you can type commands when prompted, such as:
/tools: list all available MCP tools with names and descriptions./resources: list all available MCP resources with their URLs./prompts: list all available MCP prompts by name and description./prompt/full_research_instructions_prompt: fetch the research workflow prompt and inject it into the conversation./resource/system://memory: read and print the server memory stats (an example of running an MCP resource)./model-thinking-switch: toggle model “thinking” traces on/off. By default, it is true, which means that you’ll see the agent’s thoughts in the conversation before each answer or tool call.- Any other text is treated as a normal user message for the agent, which may use the MCP server tools to respond.
/quit: terminate the client.
Now, you can interact with the agent. Try typing Hello! Who are you?. The agent will introduce itself. Next, use the /tools, /resources, and /prompts commands to see the lists of available capabilities. Then, try /resource/system://memory to read a resource and display memory usage statistics for the server process. Finally, type /quit to shut down the client.
- Next, we can connect a client to the server and call the tool.
We can run this tool programmatically as well. The following code calls the tool with a sample research folder. Remember to update the research_folder path to the absolute path on your machine.
In the research agent folder (lessons/research_agent_part_2), there’s a /data/sample_research_folder folder with an article_guideline.md file that you can use for testing.
Here is how it is structured:
Normally, an article_guideline.md file would contain detailed information about the article to write, including the outline, sections, sources, and code, as the research agent needs this information to identify the best content to include in the article. In this sample file, we have a simplified version of an article guideline.
After the tool runs successfully, you can verify its output. Check the data/sample_research_folder directory. You should find a new .nova folder containing a guidelines_filenames.json file with the URLs extracted from the guidelines.
You could also run the code cell to rerun the research agent MCP client (see the associated notebook or Colab), and give it the following command. Make sure to replace the folder path with your actual absolute folder path; otherwise, the tool will not find the file.
- Command to give to the client:
Run the “extract_guidelines_urls” tool with the “data/sample_research_folder” directory as research folder and stop after the tool has finished running..
This command will make the agent use the extract_guidelines_urls tool to extract the URLs from the article guideline file. If everything ran correctly, you’ll see the text “Tool execution successful”. If so, notice that there is a new folder named .nova in the research directory, with a file guidelines_filenames.json inside. This file contains the URLs and local references extracted from the article guideline.
Its content should be like this:
So, the tool has extracted those URLs from the article_guideline.md file and categorized them into the groups you see above.
Resource Registration
In mcp_server/src/routers/resources.py, we register the system://memory resource. The @mcp.resource() decorator makes the function’s return value available at the specified URL. Unlike a tool, a resource is supposed to be read-only and should not have side effects.
It’s very similar to how tools are registered, except that the @mcp.resource() decorator is used instead of the @mcp.tool() decorator.
See the associated notebook and Colab to see how to test this MCP resource.
Prompt Registration and Loading
In mcp_server/src/routers/prompts.py, we register our main workflow prompt. The @mcp.prompt() decorator exposes a string containing the detailed, multi-step instructions for conducting research.
Here is the registration code.
By hosting the prompt on the server, we ensure that any MCP client can initiate the same research workflow, as we visualized in the Mermaid diagram in Lesson 12. To see this in action, run the client and use the command /prompt/full_research_instructions_prompt. The client will fetch the prompt from the server and inject its content directly into the conversation, preparing the agent to begin the research task.
In practice, MCP prompts are triggered by users from an MCP client, not by the agent LLM. When a user triggers an MCP prompt, the MCP client retrieves the prompt and loads it to instruct the LLM to run the available tools in sequence (and sometimes in parallel) according to the workflow described in the prompt.
The client in client.py supports two run modes, controlled by the --transport command-line argument:
--transport in-memory(default): The client imports thecreate_mcp_serverfunction from the server code and passes the server instance directly to thefastmcp.Client.--transport stdio: The client launches the server as a separate process (uv run -m src.server --transport stdio) and communicates with it over standard I/O. This is how external clients, such as IDEs, connect [8], [9].
Upon startup, the client calls get_capabilities_from_mcp_client(), which uses the client.list_tools(), client.list_resources(), and the client.list_prompts() methods to query the server. The results are then printed in the startup banner.
The parse_user_input() function routes user input. If the input starts with a /, it is treated as a command (like /tools or /quit). Otherwise, it is passed to the agent loop for processing by the LLM.
Let’s see more about how the MCP clients manage inputs and commands.
Parsing Input and Commands
The client supports a small command language. Input can be either a command (starting with /) or a freeform user message.
Possible commands are:
/tools,/resources,/prompts/prompt/(e.g.,/prompt/full_research_instructions_prompt)/resource/(e.g.,/resource/system://status)/model-thinking-switch/quit
The parse_user_input function simply classifies the input (no side effects), and returns a ProcessedInput with metadata. Here are some examples:
These processed inputs are then used to dispatch the correct handling.
Handling User Messages
The handle_user_message function orchestrates the conversation, calling the appropriate helper for the parsed command, or appending a normal message and running the agent loop.
Here are some examples. Let’s first create the MCP server and client, and get the server capabilities (available tools, resources, and prompts).
Now, let’s parse the user input and handle the user message with the handle_user_message function. Here is an example with commands (i.e., messages starting with /):
The handle_user_message function (from mcp_client/src/utils/command_utils.py) is basically a router that calls the appropriate helper for the parsed message. It is defined in the handle_message_utils.py file, you can read it to learn more about it.
As previously explained, the tools object contains the list of tools registered in the MCP server, retrieved by the list_tools method. If the input is of type COMMAND_INFO_TOOLS, the handle_command function is called.
This function retrieves the name and description from each tool and prints them in a pretty format. All tools are managed similarly.
If the input message is of type NORMAL_MESSAGE, the handle_agent_loop function (from mcp_client/src/utils/handle_agent_loop_utils.py ) is called instead, which manages the agent loop for tool execution. Let’s see how it works:
This function is the main loop that manages the agent loop for tool execution. It initializes the LLM client, builds the LLM configuration with the tools, and then enters the agent loop.
The loop is structured as follows:
- Call the LLM with the current conversation history.
- Extract and display thoughts as a separate message (only if enabled).
- Check for function calls.
- If there is a function call, check if it is a tool call.
- If it is a tool call, execute the tool via the MCP server.
- Add the tool result to the conversation history.
The LLMClient class is simply a wrapper class that allows generating content (or a function call) with an LLM, independent of the specific LLM provider. Right now, it only implements Google Gemini as a model, but it can be easily extended to other models. It is defined in the llm_utils.py file.
The build_llm_config_with_tools function builds the LLM configuration with the tools; it is only implemented for Gemini for now. It is defined in the llm_utils.py file as well. Here’s its code:
The code above basically instructs the LLM to use thinking (if enabled) with the specified thinking budget (i.e., the maximum number of tokens the LLM can use to think) and use the available tools from the MCP server.
The other functions from the handle_agent_loop function, like extract_thought_summary and extract_final_answer are used to extract thoughts and the final answer from the LLM response. It’s boilerplate code that works for Gemini and can be copy-pasted for other projects.
The execute_tool function is used to execute the tool via the MCP server. It is defined in the handle_agent_loop_utils.py file. Here’s its code.
It uses the call_tool method of the Client object to execute the tool.
Try The Agent in Your Terminal: Commands & Two Run Modes
If you’re interested in running the research agent locally from your terminal, run the complete client-server application as follows:
- First, move into the
mcp_clientfolder and then run the MCP client using the in-memory transport. This is the default.
You can also specify it explicitly:
You should see the same startup banner as in the notebook, listing the available tools, resources, and prompts.
- Next, run it using the stdio transport. This will launch a separate server process in the background.
The behavior will be identical from your perspective, but you are now communicating between two separate processes.
- In either mode, try out the following commands:
/tools: Lists all 11 tools available for research tasks./resources: Shows the available resources, includingsystem://memory./prompts: Describes the one available workflow prompt,full_research_instructions_prompt./resource/system://memory: Fetches and displays the server’s memory statistics./model-thinking-switch: This toggles the agent’s “thinking” traces on and off. As we discussed in Lesson 14, this is a practical application of managing reasoning budgets.
Handling Common Errors
Robust systems handle failure gracefully. Let’s see what happens when we provide invalid input. Run the client and ask it to execute the extract_guidelines_urls tool with a nonexistent directory path.
Run the “extract_guidelines_urls” tool with the “path/to/nonexistent/folder” directory.
The tool will fail, but the system will not crash. The MCP server will return a structured error, and the agent, guided by its instructions, will clearly report the failure to you, explaining that the path does not exist and requesting a valid one. This demonstrates the critical failure policy we designed, ensuring the agent can recover from errors and continue its task with human guidance.
Conclusion
You have now created a complete MCP-based agent system. You ran an MCP server exposing tools, resources, and prompts, and used an MCP client to discover and execute its capabilities using both in-memory and stdio transports. This foundation is essential for the upcoming lessons.
- In Lesson 17 , we will use the server-hosted prompt to initiate the entire research workflow and begin implementing the tools for ingesting guidelines and processing URLs.
- In Lessons 18 and 19 , we will implement the remaining tools, including web scraping, data curation, and handling human-in-the-loop decision points.
- Later, in Part 3 , we will return to this system to add observability with Opik and discuss security hardening for production environments.
References
- Model Context Protocol. (n.d.). Model Context Protocol. modelcontextprotocol.io
- MCP Concepts: Tools. (n.d.). Model Context Protocol. modelcontextprotocol.io/docs/concepts/tools
- MCP Concepts: Resources. (n.d.). Model Context Protocol. modelcontextprotocol.io/docs/concepts/resources
- MCP Concepts: Prompts. (n.d.). Model Context Protocol. modelcontextprotocol.io/docs/concepts/prompts
- MCP Transports. (n.d.). Model Context Protocol. modelcontextprotocol.io/docs/concepts/transports
- jlowin/fastmcp. (n.d.). GitHub. github.com/jlowin/fastmcp
- Lowin, J. (n.d.). FastMCP. gofastmcp.com
- Model Context Protocol (MCP). (n.d.). Cursor. docs.cursor.com/context/model-context-protocol
- Connect Claude Code to tools via MCP. (n.d.). Anthropic. docs.anthropic.com/en/docs/claude-code/mcp