Agentic AI Engineering

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 fastmcp library to expose MCP tools, MCP resources, and MCP prompts
  • Use the fastmcp library to interact with an MCP server
  • Use both in-memory and stdio transports.
  • 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.

 
# Run the MCP client in-kernel
import sys
from research_agent_part_2.mcp_client.src.client import main as client_main
 
async def run_client():
    _argv_backup = sys.argv[:]
    sys.argv = ["client"]
    try:
        await client_main()
    finally:
        sys.argv = _argv_backup
 
 
# Start client with in-memory server
await run_client()

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:

INFO:     MCP Server version 0.1.0
INFO:     - 11 tools available.
INFO:     - 2 resources available.
INFO:     - 1 prompt available.
INFO:     - Type '/quit' to exit.
INFO:     - Type '/tools', '/resources', or '/prompts' to list available capabilities.
INFO:     - Type '/model-thinking-switch' to toggle model thinking.
INFO:     - Type '/prompt/' to load a prompt.
INFO:     - Type '/resource/' to view a resource.

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.

from fastmcp import FastMCP, Client
    
    # Create an MCP server
    mcp = FastMCP("Demo")
    
    # Add an addition tool
    @mcp.tool()
    def add(a: int, b: int) -> int:
        """Add two numbers"""
        return a + b
  1. Next, we can connect a client to the server and call the tool.
async def run_client():
        # Connect to the server using the in-memory transport
        async with Client(mcp) as client:
            # Call the "add" tool
            result = await client.call_tool("add", {"a": 5, "b": 3})
            print(result.content[0].text)
    
    # It outputs: 8

from ..tools.guidelines_tools import extract_guidelines_urls_tool
 
@mcp.tool(
    name="extract_guidelines_urls",
    description="Extracts URLs from research guidelines files.",
)
async def extract_guidelines_urls(
    research_directory: str,
) -> dict:
    """Extracts URLs from research guidelines files."""
    return extract_guidelines_urls_tool(
        research_directory=research_directory,
    )

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.

from research_agent_part_2.mcp_server.src.tools import extract_guidelines_urls_tool
 
research_folder = "/your/absolute/path/to/sample_research_folder"
extract_guidelines_urls_tool(research_folder=research_folder)

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:

## Global Context of the Lesson

...

## Lesson Outline

## Section 1: Introduction

...

## Section 2: Understanding why agents need tools

...

## Section N: Conclusion

...

## Article code

Links to code that will be used to support the article. Always prioritize this code over every other piece of code found in the sources:

- [Notebook 1](https://github.com/path/to/notebook.ipynb)

## Sources

- [Function calling with the Gemini API](https://ai.google.dev/gemini-api/docs/function-calling)
- [Function calling with OpenAI's API](https://platform.openai.com/docs/guides/function-calling)
- [Tool Calling Agent From Scratch](https://www.youtube.com/watch?v=ApoDzZP8_ck)
- [Efficient Tool Use with Chain-of-Abstraction Reasoning](https://arxiv.org/pdf/2401.17464v3)
- [Building AI Agents from scratch - Part 1: Tool use](https://www.newsletter.swirlai.com/p/building-ai-agents-from-scratch-part)
- [What is Tool Calling? Connecting LLMs to Your Data](https://www.youtube.com/watch?v=h8gMhXYAv1k)
- [ReAct vs Plan-and-Execute: A Practical Comparison of LLM Agent Patterns](https://dev.to/jamesli/react-vs-plan-and-execute-a-practical-comparison-of-llm-agent-patterns-4gh9)
- [Agentic Design Patterns Part 3, Tool Use](https://www.deeplearning.ai/the-batch/agentic-design-patterns-part-3-tool-use/)

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:

{
  "github_urls": [
    "https://github.com/path/to/notebook.ipynb"
  ],
  "youtube_videos_urls": [
    "https://www.youtube.com/watch?v=ApoDzZP8_ck",
    "https://www.youtube.com/watch?v=h8gMhXYAv1k"
  ],
  "other_urls": [
    "https://ai.google.dev/gemini-api/docs/function-calling",
    "https://platform.openai.com/docs/guides/function-calling",
    "https://arxiv.org/pdf/2401.17464v3",
    "https://www.newsletter.swirlai.com/p/building-ai-agents-from-scratch-part",
    "https://dev.to/jamesli/react-vs-plan-and-execute-a-practical-comparison-of-llm-agent-patterns-4gh9",
    "https://www.deeplearning.ai/the-batch/agentic-design-patterns-part-3-tool-use/"
  ],
  "local_file_paths": []
}

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.

from ..resources.system_resources import get_memory_resource
 
@mcp.resource("system://memory")
async def system_memory() -> dict:
    """Returns memory usage statistics."""
    return await get_memory_resource()

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.

from ..prompts.research_instructions_prompt import full_research_instructions_prompt as _get_research_instructions
 
@mcp.prompt("full_research_instructions_prompt")
def full_research_instructions_prompt() -> str:
    """The main prompt that drives the research agent's workflow."""
    return _get_research_instructions()

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.

if args.transport == "in-memory":
    ...
    from mcp_server.src.server import create_mcp_server
    mcp_server = create_mcp_server()
    mcp_client = Client(mcp_server)

elif args.transport == "stdio":
    config = {
        "mcpServers": {
            "research-agent": {
                "transport": "stdio",
                "command": "uv",
                "args": [
                    "--directory", str(settings.server_main_path),
                    "run", "-m", "src.server",
                    "--transport", "stdio",
                ],
            }
        }
    }
    mcp_client = Client(config)

# At startup
tools, resources, prompts = await get_capabilities_from_mcp_client(mcp_client)
print_startup_info(tools, resources, prompts)

async with mcp_client:
    while True:
        # Get user input
        user_input = input("👤 You: ").strip()
        ...

        # Parse input
        parsed_input = parse_user_input(user_input)
        ...

        # Dispatch handling
        await handle_user_message(parsed_input=parsed_input, ...)
        ...

The client in client.py supports two run modes, controlled by the --transport command-line argument:

  1. --transport in-memory (default): The client imports the create_mcp_server function from the server code and passes the server instance directly to the fastmcp.Client.
  2. --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:

from research_agent_part_2.mcp_client.src.utils.parse_message_utils import (
    parse_user_input,
)
 
processed_input = parse_user_input("/tools")
print(processed_input.input_type)
# InputType.COMMAND_INFO_TOOLS
 
processed_input = parse_user_input("/resources")
print(processed_input.input_type)
# InputType.COMMAND_INFO_RESOURCES
 
processed_input = parse_user_input("/prompt/full_research_instructions_prompt")
print(processed_input.input_type, processed_input.prompt_name)
# InputType.COMMAND_PROMPT full_research_instructions_prompt
 
processed_input = parse_user_input("Hello, how are you?")
print(processed_input.input_type)
# InputType.NORMAL_MESSAGE

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).

from fastmcp import Client
from research_agent_part_2.mcp_client.src.utils.handle_message_utils import (
    handle_user_message,
)
from research_agent_part_2.mcp_client.src.utils.mcp_startup_utils import (
    get_capabilities_from_mcp_client,
)
from research_agent_part_2.mcp_server.src.server import create_mcp_server
 
# Create the MCP server and client
mcp_server = create_mcp_server()
mcp_client = Client(mcp_server)
 
# Get the MCP server capabilities
tools, resources, prompts = await get_capabilities_from_mcp_client(mcp_client)

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 /):

# Parse the user input
processed_input = parse_user_input("/resources")
conversation_history = []
response = await handle_user_message(
    processed_input,
    tools,
    resources,
    prompts,
    conversation_history,
    mcp_client,
    thinking_enabled=True,
)
# ============================================================
# 📚 Available Resources
# ============================================================
#
# 1. system://status
#    Get system status and health information.
#
# 2. system://memory
#    Monitor memory usage of the server.

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.

def handle_command(processed_input: ProcessedInput, tools: List, resources: List, prompts: List):
    """Handle informational commands.
 
    This function only handles informational commands (COMMAND_INFO_* types).
    """
    if processed_input.input_type == InputType.COMMAND_INFO_TOOLS:
        print_header("🛠️  Available Tools")
        for i, tool in enumerate(tools, 1):
            print_item(tool.name, tool.description, i, Color.BRIGHT_WHITE, Color.YELLOW)
    ...

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:

async def handle_agent_loop(
    conversation_history: List[types.Content],
    tools: List,
    client: Client,
    thinking_enabled: bool,
):
    """Handle the agent loop for tool execution."""
    # Initialize LLM client
    llm_config = build_llm_config_with_tools(tools, thinking_enabled)
    llm_client = LLMClient(settings.model_id, llm_config)
 
    while True:
        print()
        # Call LLM with current conversation history
        response = await llm_client.generate_content(conversation_history)
 
        # Extract and display thoughts as separate message (only if enabled)
        if thinking_enabled:
            thoughts = extract_thought_summary(response)
            ...
 
        # Check for function calls
        function_call_info = extract_first_function_call(response)
        if function_call_info:
            name, args = function_call_info
 
            # Check if this is a tool call
            is_tool = any(tool.name == name for tool in tools)
 
            if is_tool:
                ...
 
                # Execute the tool via MCP server
                tool_result = await execute_tool(name, args, client)
                # Add tool result to conversation history
                tool_response = f"Tool '{name}' executed successfully. Result: {tool_result}"
                conversation_history.append(types.Content(role="user", parts=[types.Part(text=tool_response)]))
                ...
        else:
            # Extract final text response - this ends the ReAct loop
            final_text = extract_final_answer(response)
            conversation_history.append(response.candidates[0].content)
            ...
            break  # Exit the agent loop

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:

  1. Call the LLM with the current conversation history.
  2. Extract and display thoughts as a separate message (only if enabled).
  3. Check for function calls.
  4. If there is a function call, check if it is a tool call.
  5. If it is a tool call, execute the tool via the MCP server.
  6. 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:

def build_llm_config_with_tools(mcp_tools: List, thinking_enabled: bool = True) -> types.GenerateContentConfig:
    """Build Gemini config with all MCP tools converted to Gemini format."""
    gemini_tools = []
 
    for tool in mcp_tools:
        gemini_tool = types.Tool(
            function_declarations=[
                types.FunctionDeclaration(
                    name=tool.name,
                    description=tool.description,
                    parameters=tool.inputSchema,
                )
            ]
        )
        gemini_tools.append(gemini_tool)
 
    # Create thinking config dynamically based on current state
    thinking_config = types.ThinkingConfig(
        include_thoughts=thinking_enabled,
        thinking_budget=settings.thinking_budget,
    )
 
    return types.GenerateContentConfig(
        tools=gemini_tools,
        thinking_config=thinking_config,
        automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
    )

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.

async def execute_tool(name: str, args: dict, client: Client):
    """Execute a tool and return the result."""
    ...
    tool_result = await client.call_tool(name, args)
    return tool_result

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:

  1. First, move into the mcp_client folder and then run the MCP client using the in-memory transport. This is the default.
uv run -m src.client

You can also specify it explicitly:

uv run -m src.client --transport in-memory

You should see the same startup banner as in the notebook, listing the available tools, resources, and prompts.

  1. Next, run it using the stdio transport. This will launch a separate server process in the background.
uv run -m src.client --transport stdio

The behavior will be identical from your perspective, but you are now communicating between two separate processes.

  1. In either mode, try out the following commands:
    • /tools: Lists all 11 tools available for research tasks.
    • /resources: Shows the available resources, including system://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

  1. Model Context Protocol. (n.d.). Model Context Protocol. modelcontextprotocol.io
  2. MCP Concepts: Tools. (n.d.). Model Context Protocol. modelcontextprotocol.io/docs/concepts/tools
  3. MCP Concepts: Resources. (n.d.). Model Context Protocol. modelcontextprotocol.io/docs/concepts/resources
  4. MCP Concepts: Prompts. (n.d.). Model Context Protocol. modelcontextprotocol.io/docs/concepts/prompts
  5. MCP Transports. (n.d.). Model Context Protocol. modelcontextprotocol.io/docs/concepts/transports
  6. jlowin/fastmcp. (n.d.). GitHub. github.com/jlowin/fastmcp
  7. Lowin, J. (n.d.). FastMCP. gofastmcp.com
  8. Model Context Protocol (MCP). (n.d.). Cursor. docs.cursor.com/context/model-context-protocol
  9. Connect Claude Code to tools via MCP. (n.d.). Anthropic. docs.anthropic.com/en/docs/claude-code/mcp