Courses / GenAI and Agents Fast Track

Session 6 of 8

Multi-agent systems and MCP

Overview

Chapter 6 — Multi-Agent Systems and MCP

Course: GenAI and Agents Fast Track Prerequisites: the agent loop in plain Python (a model picks a tool, your code runs it, the model reads the result, repeat until done or a step limit is reached); basic Python (functions, dictionaries, classes) and running tests with pytest.

What the reader can do after this unit:

  • Split a research job into roles, and write the hand-off message that passes one role’s findings to the next
  • Rebuild the agent loop as a graph of steps with a step limit, using a framework
  • Write a small MCP server with a tool, a resource and a prompt, test it without a model, and connect an agent to it

Core question this unit answers: When is one agent not enough, and how does MCP let several agents use the same tools through one standard connector?

Connections:

  • Builds on: the hand-built agent loop with tools, memory and a step limit
  • Leads into: adding a feature to a real repository with a coding agent, working from a plan, small steps and a review of every change

Notes

Student Notes — Chapter 6: Multi-Agent Systems and MCP

GenAI and Agents Fast Track · Chapter 6

Before you start

You need Python 3.11 or newer, a text editor, and the repository you have been building (its app/ folder holds the earlier code, including the agent loop). Install two packages: pip install "mcp[cli]" for the Model Context Protocol (MCP) toolkit and pip install -U langgraph for the graph framework, then pip install pytest anyio if they are not already there. The examples in these notes run offline: the tests use a scripted stand-in for the model, so no account, key or paid service is needed to follow them. The optional last task of the project is the one that talks to a real model, and it says so before you start.

Summary

One agent with a pile of tools and a long task can lose its way, so this chapter shows how to split the work between agents that each have one role, and how one agent hands its findings to the next in a written brief. You then rebuild the agent loop from the previous chapter as a graph of steps in a framework, which gives the loop a visible shape and a step limit. The second half teaches the Model Context Protocol, an open standard that lets any agent application use tools written by someone else through one connector: a host application, a client inside it and a server that offers tools, resources and prompts. You write a small MCP server, test it without a model, and connect an agent to it. By the end you can say when one agent is enough, when a team is worth its extra cost, and how a tool reaches an agent through MCP.

Key concepts

Term Definition
Multi-agent system Several agents working together on one job, each with its own instructions and its own tools, instead of one agent doing everything.
Role The one job given to one agent in a team, written as its instructions plus the short list of tools it may use, such as “researcher” or “writer”.
Hand-off One agent passing its work to the next as a written message (a brief) that holds everything the next agent needs.
Brief The message in a hand-off. A useful one states the objective, the output format, the tools and sources to use, and the task boundaries.
Graph of steps A way to write a program as boxes joined by arrows: the boxes do work, the arrows say which box runs next.
State The shared record that travels through a graph: the messages so far and a step counter, for example.
Node One box in a graph: a Python function that receives the state and returns an update to it.
Edge One arrow in a graph. A normal edge goes to a fixed next node; a conditional edge calls a small function that looks at the state and picks the next node.
Framework A ready-made library that supplies the structure of a program, here the graph, so you write only the steps.
MCP (Model Context Protocol) An open standard for connecting AI applications to tools and data, so that a tool written once can be used by many applications.
Host, client, server In MCP, the host is the AI application; it creates one client for each server; a server is a program that offers tools, resources and prompts.
Tool, resource, prompt The three things an MCP server can offer: a function the model may call, data the application loads as context, and a message template a person picks.
stdio transport A way to carry MCP messages in which the client starts the server as a child process and the two exchange messages through the server’s standard input and standard output.
Tool error A failed tool call reported as a normal result flagged as an error, with a message the model can read and learn from.

Explanations

1. Recap: the loop you already have

Think of a cook working alone with a recipe. The cook decides what to do, uses one tool (a knife, then a pan), looks at the result, and decides again, until dinner is ready or the cook gives up. The agent loop from the previous chapter works the same way: the model reads the conversation and either asks for a tool or gives a final answer; your code runs the tool and adds the result to the conversation; and a step limit stops the loop if it never finishes. The loop lives in app/agent/loop.py, the tools in app/agent/tools.py and the memory in app/agent/memory.py. This chapter keeps that loop and changes what surrounds it: who runs it (several agents), how it is drawn (a graph) and where its tools come from (an MCP server).

2. When one agent is not enough: roles and hand-offs

First, a failure (an invented example). A staff member asks an assistant: “How many days a week can I work from home, and do I need a receipt for a claim of 40 units?” One agent holds six tools and the whole question. It searches, reads two notes, mixes up which fact came from which note, and replies: “Up to three days; receipts are needed above 40 units.” The first fact is right. The second is wrong: the note says a receipt is needed above 25 units. By the time the agent wrote its answer, its context held search results, raw notes and its own half-formed sentences all together, and nothing marked which fact belonged to which source.

A team of agents is one answer. A multi-agent system is several agents working together on one job. Two pictures help:

  • A restaurant kitchen. The head chef writes a ticket and hands it to the grill station; the grill station hands plated food to the pass. Each station does one job with its own equipment and does not need to know everything about the other stations.
  • A newsroom. A reporter gathers facts and notes where each came from; an editor turns those notes into the story. The editor never phones the sources, and the reporter never writes the headline.

In software, each member of the team has a role: one set of instructions and a short list of tools. For the research helper you build in this chapter there are two roles. The researcher may search and read notes, and it returns facts with the id of the note each came from. The writer has no tools at all; it turns the facts it is given into a short answer and cites the ids. Because the writer has no search tool, it has no way to fetch a fresh source; because the researcher does not write the answer, its context stays full of evidence rather than prose.

The hand-off. The researcher and writer meet at one point: the message that carries the findings. This is a hand-off. Think of a nurse at shift change. A hand-off that says “keep an eye on bed 4” leaves the next nurse guessing; a good one says who the patient is, what was done, what to watch and what to do if it changes. Think also of a builder given the note “fix the wall”: which wall, fixed how, with which materials, and what must not be touched? Engineers at one vendor, describing a research system built from several agents, say that each delegated task should state what to achieve, what shape the result should take, which tools and sources to use, and where the task stops; vague task descriptions, they report, led to duplicated work, gaps and missed information. That gives a four-part brief:

  1. Objective. What the next agent must find out or produce.
  2. Output format. The shape of the result, such as “one line per fact, starting with the note id”.
  3. Tools and sources. What it may use.
  4. Boundaries. What it must not do, such as “do not answer the question yourself”.

Here is a hand-off in code, from app/agent/team.py. A role is a small record, and the hand-off is a function that builds the brief:

from dataclasses import dataclass, field


@dataclass
class Role:
    name: str
    instructions: str
    tool_names: list[str] = field(default_factory=list)


RESEARCHER = Role(
    "researcher",
    "Find facts in the notes. Return one line per fact, starting with the note id. "
    "Do not write the final answer.",
    ["search_notes", "read_note"],
)
WRITER = Role(
    "writer",
    "Write a two-sentence answer using only the facts you are given. "
    "Cite note ids in square brackets. If the facts do not answer the question, say so.",
)


def hand_off(question: str, findings: str) -> str:
    return f"Question: {question}\nFacts found (with note ids):\n{findings}\nWrite the answer."


def run_team(question, run_role):
    findings = run_role(RESEARCHER, question)
    return run_role(WRITER, hand_off(question, findings))

Notice how the four parts show up. The writer’s instructions carry the output format and the boundary; its empty tool list says it uses only the facts handed over; the hand-off message carries the objective and the evidence. run_team takes run_role as a parameter, a function that runs one role on one message, so you can test the team with a fake and no model at all.

Why a team costs more. A team is not free. The same engineering article reports that agents typically use about four times as many tokens as chat interactions, and multi-agent systems about fifteen times as many, as that team measured its own system; your numbers will differ. The article also says multi-agent systems suit valuable tasks that involve heavy parallel work, information that does not fit in one context window and many complex tools, and that domains where all agents must share the same context, or where agents depend heavily on each other, are not a good fit today. A small invented sum shows the shape of the cost. Suppose the researcher uses 3,000 tokens and the writer 1,200 per question. That is 4,200 tokens per question, and for 50 questions 4,200 x 50 = 210,000 tokens. If one agent could do the same job in 3,500 tokens, 50 questions would cost 175,000. The team is worth it only if its answers are enough better, or the work fits a single window badly enough, to pay for the difference. In our view, start with one agent and split only when you can name the reason: a different set of tools, a context that is overflowing, or work that can truly run in parallel.

Three real situations to sort before you move on, each with the answer after the dash:

  1. A bug fix that touches one function and needs everything the agent has read so far. All the work depends on one shared context, so keep one agent.
  2. A report that needs the same question researched in five regions at once and then merged. Independent work in parallel, so give each region to a researcher and one writer merges.
  3. A helper whose single agent has forty tools and keeps picking the wrong one. Split by role, so each agent sees only the handful of tools its job needs.

3. The same loop as a graph of steps

Picture a metro map. Stations are places where something happens; lines say which station comes next; at an interchange you pick a line. A board game works the same way: squares are where you act, arrows say where you go, and some squares say “if you roll a six, go here, otherwise go there”. A graph of steps is a program drawn like that. The agent loop is a good candidate, because it already is a small map: a station where the model thinks, a station where tools run, and an interchange after thinking that sends you to the tools station or to the end.

A graph has three parts, and the framework named LangGraph uses these words:

  • State is the shared record that travels through the graph: here the list of messages so far and a counter of model calls.
  • Nodes are functions that receive the current state, do some work and return an update.
  • Edges say what runs next. A normal edge goes from one node to a fixed next node. A conditional edge calls a function that looks at the state and returns the name of the next node. The special names START and END mark where the graph begins and stops.

A framework supplies this structure so you write only the nodes and the routing. Here is the agent loop as a graph, from app/agent/graph.py. The first half defines the state, the two nodes and the routing function:

from typing import TypedDict

from langgraph.graph import END, START, StateGraph


class AgentState(TypedDict):
    messages: list[dict]
    steps: int


def build_graph(call_model, run_tool, max_steps=6):
    def think(state: AgentState) -> dict:
        reply = call_model(state["messages"])
        return {"messages": state["messages"] + [reply], "steps": state["steps"] + 1}

    def act(state: AgentState) -> dict:
        calls = state["messages"][-1].get("tool_calls", [])
        results = [run_tool(call) for call in calls]
        return {"messages": state["messages"] + results}

    def route(state: AgentState) -> str:
        wants_tool = bool(state["messages"][-1].get("tool_calls"))
        return "act" if wants_tool and state["steps"] < max_steps else END

The second half wires the nodes together and compiles the graph so it can be run:

    builder = StateGraph(AgentState)
    builder.add_node("think", think)
    builder.add_node("act", act)
    builder.add_edge(START, "think")
    builder.add_conditional_edges("think", route)
    builder.add_edge("act", "think")
    return builder.compile()


# graph = build_graph(call_model, run_tool)
# final = graph.invoke({"messages": start_messages, "steps": 0})

Read the routing function as the interchange. After think, if the model’s last message asks for a tool and fewer than max_steps model calls have been made, go to act; otherwise go to END. A node may return just the part of the state it changes, and an update to a key replaces the old value unless you say otherwise, which is why each node returns the whole new messages list. The model call and the tool runner are passed in as parameters, so the graph does not care where tools come from: that is the door MCP will use later in this chapter.

A run makes the shape visible. Take max_steps of 3 and a model that asks for a tool twice and then answers. The model call number 1 asks for a search, so the route goes to act; call 2 asks for a read, so the route goes to act again; call 3 gives an answer with no tool request, so the route goes to END. That run makes 3 model calls and 2 tool runs. Now take a model that never stops asking for tools. Call 1 and call 2 each go to act. After call 3 it still wants a tool, but 3 is not less than 3, so the route goes to END: again 3 model calls, and 2 tool runs. The step limit is a property of the edge, not of the model, which is exactly where you want a safety rule.

Should you use a framework at all? The hand-written loop of the previous chapter and this graph do the same job. A framework gives you the drawn shape, a place to add steps later and tools to inspect a run. It also adds a layer. One widely read engineering article warns that frameworks often add extra layers of abstraction that can obscure the underlying prompts and responses, and advises starting with the model’s API directly, because many patterns need only a little code. In our view, write the loop by hand once, as you did, so you know what the framework is hiding, then use the framework when the shape of the program grows.

4. MCP: a standard way to offer tools, resources and prompts

Think of the charging cable problem. Before one common port, many phones, cameras and speakers had their own plugs, and a travel bag held a tangle of chargers. A shared port means any device can use any charger. The official introduction to the Model Context Protocol (MCP) uses the same picture: it calls MCP an open-source standard that links AI applications to outside systems, and compares it to a USB-C port. Without it, each agent application tends to write its own custom code for each tool: ten applications and ten tools could mean a hundred separate connections. With MCP, a tool is written once as a server, and any application that speaks MCP can use it.

The specification describes three roles. Picture a company with a front desk:

  • The host is the AI application itself: the one the person uses, which runs the model. It is like the front desk of an office building.
  • A client is a connector inside the host. The host creates one client for each server, and each client talks to exactly one server. These are the desk’s separate phone lines.
  • A server is a program that offers capabilities: it might wrap a folder of notes, a database or a web API. These are the departments the desk can ring.

The messages between client and server use a standard format called JSON-RPC, so any client can talk to any server. A server can offer three kinds of thing, and the difference is who decides when each is used:

Offer What it is Who decides to use it
Tool A function the model can run, such as search_notes The model: tools are designed to be model-controlled
Resource Data the application can load as context, addressed by a URI such as notes://n1 The application: resources are application-driven
Prompt A message template with arguments, such as “summarise note n1” The user: prompts are user-controlled, often a slash command or menu item

A tool is like a power tool the cook picks up when needed; a resource is like a reference book the host puts on the cook’s counter; a prompt is like a favourite order a customer picks from a menu. This chapter’s agents mostly use tools, and the researcher role from section 2 will use two.

What the job looks like without and with MCP. Suppose the research helper needs the company notes. Without MCP, you write the code that searches the notes inside the agent application, describe each tool in the format that one model API expects, and repeat all of it when a second application wants the same notes. With MCP, you write the notes server once. The host asks the server for its list of tools, shows them to the model, and forwards the model’s tool calls to the server. People and systems still matter: someone must keep the notes correct, and a person should be able to refuse a risky tool call. The official introduction says MCP reduces development time and complexity when building or integrating an AI application; the part that should get easier is connecting a new application to tools that already exist.

What a tool looks like on the wire. The client asks the server tools/list and gets back a list in which each tool has a name, a description and an input schema (a JSON Schema that says which arguments are allowed). To run a tool the client sends tools/call with the tool name and arguments; the reply holds content for the model and an isError flag. A call looks like this:

{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
 "params": {"name": "search_notes", "arguments": {"query": "receipt"}}}

Keep a person in the loop. Tools run code, so the specification is careful about trust. It says a person should always be kept in the loop and able to refuse a tool call; that hosts must get explicit user consent before invoking a tool; and that descriptions of tool behaviour should be considered untrusted unless they come from a trusted server. In practice: show the user which tools are connected, show a call before it runs when it matters, and connect servers you trust. The specification also says servers must validate all tool inputs.

One more detail about the current revision. The revision of the specification used in this course is dated 2026-07-28. In it, every request carries its own protocol version and capabilities, and no opening handshake is needed; earlier revisions began each connection with an initialize handshake. You rarely have to care, because the Python toolkit’s client works out which kind of server it is talking to.

5. Write a small MCP server and connect an agent to it

The server. Here is a complete server in about thirty lines, app/mcp_server/server.py. It holds three invented notes and offers two tools, one resource and one prompt:

import logging

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

log = logging.getLogger("research-notes")  # logging writes to stderr, never to stdout

mcp = MCPServer("research-notes")

NOTES = {
    "n1": "Remote work: staff may work from home up to three days a week.",
    "n2": "Travel: book economy class for flights shorter than six hours.",
    "n3": "Expenses: a receipt is needed for any claim above 25 units.",
}


@mcp.tool()
def search_notes(query: str) -> str:
    """Find notes containing every word of the query. One 'id: text' line per note."""
    log.info("search_notes %r", query)
    words = query.lower().split()
    hits = [f"{i}: {t}" for i, t in NOTES.items() if all(w in t.lower() for w in words)]
    return "\n".join(hits) or "No notes match."


@mcp.tool()
def read_note(note_id: str) -> str:
    """Return the full text of one note by its id."""
    if note_id not in NOTES:
        raise ToolError(f"No note with id {note_id!r}. Ids look like n1, n2, n3.")
    return NOTES[note_id]


@mcp.resource("notes://{note_id}")
def note_resource(note_id: str) -> str:
    """One note, addressed by its URI."""
    return NOTES[note_id]


@mcp.prompt()
def summarise_note(note_id: str) -> str:
    """Ask for a two-sentence summary of one note."""
    return f"Summarise note {note_id} in two sentences for a new employee."


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

Notice what is not there: no schema and no protocol code. The toolkit reads the function name as the tool name, the docstring as the description the model sees, and the type hints as the allowed arguments; it builds the input schema from them and rejects a call whose arguments do not fit. The docstring therefore matters: it is what the model reads when it decides whether to use the tool.

stdio. When you call mcp.run() with no arguments, the server uses the stdio transport. A transport is the way messages travel. With stdio, the client starts your file as a child process, writes requests to its standard input and reads replies from its standard output, one message per line. Think of two people passing notes under a door: the door has exactly one gap for each direction. The specification says the server must not write anything to its standard output that is not a valid MCP message. A stray print("starting") is therefore a bug: the client reads it as a broken message. Log to standard error instead, which the specification allows for any logging, and which the logging module does by default.

Testing without a model. An MCP server is ordinary code that you can test. The toolkit’s Client can connect to your server object in memory, with no subprocess and no network. This is tests/test_mcp_server.py:

import pytest
from mcp import Client

from app.mcp_server.server import mcp


@pytest.fixture
def anyio_backend():
    return "asyncio"


@pytest.mark.anyio
async def test_search_finds_the_expense_note():
    async with Client(mcp, raise_exceptions=True) as client:
        result = await client.call_tool("search_notes", {"query": "receipt"})
    assert result.is_error is False
    assert "n3" in result.content[0].text


@pytest.mark.anyio
async def test_unknown_note_is_a_tool_error():
    async with Client(mcp, raise_exceptions=True) as client:
        result = await client.call_tool("read_note", {"note_id": "n99"})
    assert result.is_error is True
    assert "n99" in result.content[0].text

When a tool fails. Look at read_note. For an unknown id it raises ToolError instead of returning a sentence such as “not found”. The toolkit turns the raised error into a normal result with is_error set to true and your message in the content, which is where the model reads. The model sees “No note with id ‘n99’” and can try again with a better id. If you returned the same sentence as ordinary text, the flag would say the call worked, and the sentence would look like the answer: the researcher could carry “not found” into its findings as if it were a fact. The toolkit’s documentation gives the same advice: never return an error message from a tool, raise it. A failed call does not raise in the client either; it comes back as a result whose is_error is true, so check that flag before you trust the content.

Connecting the agent. The client side lives in app/agent/mcp_client.py. It launches the server as a child process, asks for the tool list, converts it to the format the chat API expects, and turns a tool call from the model into a call on the server:

import asyncio
import json
import sys

from mcp import Client, StdioServerParameters

SERVER = StdioServerParameters(command=sys.executable, args=["-m", "app.mcp_server.server"])


def list_mcp_tools() -> list[dict]:
    async def go() -> list[dict]:
        async with Client(SERVER) as client:
            listed = await client.list_tools()
        return [
            {"type": "function",
             "function": {"name": t.name, "description": t.description or "",
                          "parameters": t.input_schema}}
            for t in listed.tools
        ]

    return asyncio.run(go())


def call_mcp_tool(call: dict) -> dict:
    args = json.loads(call["function"]["arguments"])

    async def go() -> str:
        async with Client(SERVER) as client:
            result = await client.call_tool(call["function"]["name"], args)
        text = "\n".join(b.text for b in result.content if b.type == "text")
        return f"ERROR: {text}" if result.is_error else text

    return {"role": "tool", "tool_call_id": call["id"], "content": asyncio.run(go())}

Entering the async with block starts the server process, and leaving it shuts the process down. Starting a process for every call is simple and fine for a lab; a real host keeps one connection open. The child process does not inherit your environment variables except a short list, so the server must not depend on a secret held in your shell.

Now the pieces meet. call_mcp_tool is the run_tool the graph expects, and list_mcp_tools supplies the tools. In app/agent/team.py a helper gives each role only its own tools and runs the graph for it:

from app.agent.graph import build_graph


def make_run_role(call_model, run_tool, all_tools):
    def run_role(role, message):
        tools = [t for t in all_tools if t["function"]["name"] in role.tool_names]
        graph = build_graph(lambda msgs: call_model(msgs, tools), run_tool)
        start = [{"role": "system", "content": role.instructions},
                 {"role": "user", "content": message}]
        final = graph.invoke({"messages": start, "steps": 0})
        return final["messages"][-1].get("content") or "Stopped: step limit reached."

    return run_role

The writer’s tool_names list is empty, so the filter gives it no tools, and the researcher gets exactly search_notes and read_note. If the researcher hits the step limit with a tool request still pending, its last message has no text, and the helper returns a plain “Stopped” message so the writer is not handed an empty brief.

6. Putting the research helper together

The full helper has three layers. At the bottom, the MCP server offers the notes tools. In the middle, each role is a graph run that sees only its own tools. On top, run_team sends the question to the researcher, passes the findings to the writer in a brief, and returns the writer’s answer. The failure from the start of section 2 has a fix at each layer: the researcher returns facts tagged with note ids, so the 25-unit fact stays attached to n3; the writer is told to use only those facts and has no tools to look anything else up, which makes an invented threshold less likely, though a model can still make one up, so you check the answer against the findings; and a test with a scripted model checks the hand-off and the step limit without calling any real model.

Flashcards

Say the answer aloud before revealing it. Speaking is a different skill from reading; practise both.

  1. What is a multi-agent system?

    • Several agents working together on one job, each with its own role: its own instructions and its own short list of tools. It is a team instead of one agent doing everything.
  2. When is a team of agents a poor fit?

    • When all the agents must share the same context, or depend heavily on each other. One vendor’s engineers say such domains are not a good fit today, and a team also costs more tokens than a single agent.
  3. What are the four parts of a good hand-off brief?

    • The objective, the output format, the tools and sources to use, and the task boundaries. Without them the next agent may duplicate work, leave gaps or miss what it needs.
  4. In the research helper, why does the writer have no tools?

    • It is meant to work from the facts the researcher handed over, and an empty tool list gives it no way to fetch more. That enforces the rule in code.
  5. What are state, nodes and edges in a graph of steps?

    • State is the shared record that travels through the graph; nodes are functions that take the state and return an update; edges say which node runs next, either always the same one or chosen by a function that reads the state.
  6. In the graph version of the agent loop, where does the step limit live?

    • In the routing function on the conditional edge after the model node. It sends the state to the tools node only while a tool is requested and the model-call count is below the limit; otherwise it goes to END.
  7. What is MCP, in one sentence?

    • An open standard for connecting AI applications to tools and data, so a tool written once as a server can be used by any application that speaks the protocol.
  8. Name the three roles in MCP and say what each does.

    • The host is the AI application; a client is a connector the host creates for each server, talking to exactly one; a server is a program that offers tools, resources and prompts.
  9. Who decides when a tool, a resource and a prompt are used?

    • The model decides on tools, the application decides on resources, and the user decides on prompts, which they pick from a menu or a slash command.
  10. What is the stdio transport, and what must a stdio server not do?

    • The client starts the server as a child process and they exchange one message per line over standard input and output. The server must not write anything but valid MCP messages to standard output, so log to standard error.
  11. Why should a failing tool raise an error instead of returning an error sentence?

    • A raised error reaches the model as a result flagged as an error, so it knows the call failed and can retry. A returned sentence looks like a successful answer and may be copied into the findings as a fact.
  12. Why keep a person in the loop when agents use MCP tools?

    • Tools run real code. The specification says there should be a human with the ability to deny tool calls, hosts must get consent before invoking a tool, and tool descriptions are untrusted unless they come from a trusted server.

Model answer

The core question this unit answers: “When is one agent not enough, and how does MCP let several agents use the same tools through one standard connector?”

Five beats a good answer covers; hit all five, in this order:

  1. Need — one agent is enough until context, tools or parallel work outgrow it
  2. Roles — each agent gets one job, its own instructions and its own tools
  3. Hand-off — the findings pass in a written brief
  4. MCP — host, client and server share tools through one standard
  5. Caution — a team costs more and tool calls need a person in the loop

The full answer, said aloud:

(Need) “I start with one agent. It stops being enough when its context overflows, when it holds so many tools that it picks the wrong one, or when the work can run in parallel.

(Roles) Then I split the job into roles: each agent gets one set of instructions and only the tools its job needs, such as a researcher with search tools and a writer with none.

(Hand-off) The agents meet at a hand-off, a written brief with the objective, the output format, the tools and sources to use, and the boundaries, so the next agent does not guess.

(MCP) For the tools, I use MCP: the host application creates a client for each server, and a server offers tools, resources and prompts through one standard, so a tool written once works in any application that speaks it.

(Caution) And I stay careful: a team uses more tokens than one agent, shared-context work belongs to a single agent, and tool calls need a person who can say no.“

Skip any of these five and the answer is incomplete. An interviewer or reviewer who hears only “agents talk to each other and MCP connects tools” will ask when you would not use a team and who approves a tool call. Practise until all five beats come out in order without notes.

Why this matters

On the job: when someone proposes “a multi-agent system”, you can ask what each agent’s role is, what it is handed and what it costs. Those three questions keep a design honest, and sometimes they end with one agent and a better prompt.

In interviews and reviews: “when would you use several agents instead of one, and what is MCP?” is a fair follow-up to any agent question. The five-beat answer above is built for it.

As a builder: MCP servers are ordinary code you can test without a model, and the same server can serve the next application you build. The graph form of the loop is where later steps, such as an approval step, would be added.

After this unit

Hands-on project. Three tasks: (1) write an MCP server with two tools over a small set of invented sales figures and test it in memory; (2) run the agent loop as a graph against that server with a scripted model and show the step limit working; (3) optionally, run a researcher and a writer on a real model and compare the answer with your own calculation. The full brief is in the project file.

What’s next. The next chapter moves from building agents to working with one: a coding agent that you give a plan, small steps and tests, and whose every change you review. The repository it works on is the one you have just built, so keep your server, graph and team files and their tests.

Project

Hands-on Project — Chapter 6: A Sales-Figures Research Helper over MCP

Objective

Build the pieces of a small multi-agent research helper: an MCP server that offers invented sales figures as tools, the agent loop drawn as a graph with a step limit, and (optionally) a researcher and a writer that answer a question about the figures.

What you need: Python 3.11 or newer, with pip install "mcp[cli]", pip install -U langgraph, and pytest and anyio installed. Tasks 1 and 2 use only those packages and a scripted stand-in for the model: no account, no API key and no paid service. Task 3 is optional and needs a model behind an OpenAI-compatible endpoint, configured through the LLM_BASE_URL, LLM_API_KEY and LLM_MODEL settings you used in earlier chapters. A hosted endpoint may charge for its use.

You write every line yourself. This brief gives the facts and the checks; the finished programs are not in it. Work through the tasks in order and check each output before moving on.

The data. Every figure here is invented. Quarterly sales, in thousands of units:

Region Q1 Q2 Q3 Q4
north 120 135 150 171
south 200 190 205 210
east 90 99 120 126
west 150 150 148 160

Percentage growth from Q1 to Q4 is (Q4 - Q1) / Q1 x 100.


Task 1 — A sales MCP server, tested without a model

Create sales_server.py. It holds the table above in a dictionary and offers exactly two tools: list_regions, which takes no arguments and returns the region names, and get_sales, which takes a region name and returns that region’s four quarterly figures as one line of text. Each tool needs a docstring that tells a model when to use it. A region name that is not in the table must come back to the caller as a tool error, and its message must name the region that was asked for and list the valid region names.

Then create test_sales_server.py, using the in-memory Client from the notes, with three tests: one that lists the tools, one that asks for a known region, and one that asks for an unknown region.

Scope: two tools and three tests, not more. Stop once the checks hold.

Checks:

  • pytest reports 3 tests passed.
  • The tool listing contains 2 tools and each has a non-empty description.
  • For the unknown region, the result has its error flag set and the message contains the name you asked for.
  • sales_server.py contains no print( call.

Output: sales_server.py, test_sales_server.py and the pytest output saved as task1-output.txt.


Task 2 — The agent loop as a graph, with a step limit

Create sales_graph.py. Build the loop as a graph with a framework, the way the notes draw it: a state holding the messages and a step counter, a node that calls the model, a node that runs the tool calls, and a conditional edge after the model node. The step limit is a parameter of the function that builds the graph. Tool calls must run against your Task 1 server in memory (open a Client on its server object inside asyncio.run), not through a copy of the data.

The model is a scripted stand-in: a Python function that receives the messages and returns the next assistant message, with no real model behind it. Write two of them:

  • Script A asks for get_sales on north, then on east, and then replies with plain text and no tool request.
  • Script B asks for get_sales on north every time it is called, and never replies with plain text.

Run Script A with a limit of 6 and Script B with a limit of 4. For each run, print one line per message in the final transcript (its role, and its text or the name of the tool it asked for), then print how many times the model was called and how many tool messages the transcript holds.

Scope: two scripts and two runs. Stop once the checks hold.

Checks:

  • Both runs finish without an exception.
  • In each run, every tool message follows an assistant message that asked for that tool.
  • Script A’s transcript ends with an assistant message that holds text and no tool request.
  • Script B’s run stops on its own, and the number of model calls is not more than its limit.
  • The printed counts match what you can count in the transcript yourself.

Output: sales_graph.py and task2-output.txt holding both transcripts and both pairs of counts.


Task 3 — A researcher and a writer on a real model (optional)

Create sales_team.py with two roles built like the ones in the notes: a researcher that may use both tools, and a writer with an empty tool list. Write the hand-off function that builds the writer’s brief from the question and the researcher’s findings. Run the team on this question, using your graph, your Task 1 server and a real model:

Which region grew most from Q1 to Q4, and by what percentage?

Before you run it, work out the answer yourself from the table and save the growth of all four regions, to one decimal place, in expected.txt. Then compare the team’s answer with your file.

Scope: one question and one run. If the model returns something unusable, run it once more, then stop and describe what happened.

Checks:

  • expected.txt has 4 lines, one per region, each with a percentage to one decimal place.
  • The writer’s role has an empty tool list, and the hand-off brief contains all four parts named in the notes.
  • The team’s final answer names a region and a percentage.
  • Two sentences in task3-notes.txt say whether the answer matched your file, and, if it did not, which role’s brief you would change first and why.

Output: sales_team.py, expected.txt, team-run.txt (the final answer) and task3-notes.txt.

Keep sales_server.py, sales_graph.py, sales_team.py and their tests in the repository you have been building: the next chapter starts by giving a coding agent that repository to work in, and it will ask the agent to add a feature next to this code.

TechEazy Consulting training material — not affiliated with or endorsed by OpenAI, Anthropic or any other company named in this course.