Skip to content

Integration boundaries

  • Lesson 15
  • 45 min
  • Module 5: Multiple workers and integrations

What you will be able to do

Decide where each check lives when a tool runs in another program. Then see what the Model Context Protocol changes about your tools, and what it does not.

Before you startWhat to know and to have readyAbout 45 minutes

What you need to know

What you need on your computer

  • The project after Multi-agent trade-offs, in a folder named resolution-workflow. python -m pytest prints 64 passed.
  • For the optional example: Python 3.12 and an internet connection to install one package, mcp. It goes into its own virtual environment, next to the project, not into the project.

This lesson adds no file to the project. No account and no key. Tested with Python 3.12.13 on macOS; the Windows commands were not run.

A standard plug lets you connect any lamp to any socket in the house. Nobody has to rewire the wall for a new lamp. That is the value of a standard: one shape, many devices, many sockets.

The lamp also has a small label: “max 60 W”. The maker wrote it. The socket cannot read it, and it does not need to. Behind the wall, the fuse box cuts the power when the current is too high, whatever any label says. The label is a promise from the maker. The fuse is a rule of your house.

Now think about a tool for an agent in the same way. A protocol is the standard plug: any assistant can connect to any tool server. The tool’s description is the label. And the checks that protect your customers are the fuses. They must stay on your side of the wall.

In this project, every tool runs inside the workflow, in one Python program. The model sees a list of function schemas. When it asks for a call, the loop runs run_read from resolver/tools.py. That function validates the arguments against the contract, checks the customer, and returns a result that the model reads as data. Writes are not tools at all: the model only proposes them.

This is a direct tool: your code calls the order system’s functions itself. The contract of get_order is strict. Try it with an extra argument. Start Python in the project folder and paste:

from resolver.systems import World
from resolver.tools import run_read
print(run_read("get_order", {"order_id": "LK-640436", "customer_id": "C-20417"}, "C-50533", World()).for_model())
{"ok":false,"error":"Invalid arguments for get_order: customer_id: Extra inputs are not permitted"}

The model cannot even send a customer_id. The customer comes from the ticket, and an extra argument is refused.

The problem: the tool moves to another program

Section titled “The problem: the tool moves to another program”

Mei’s team owns the order system. Soon three assistants need the same order lookup: this workflow, the policy assistant from the RAG course, and a new returns assistant. Copying tools.py into each one means three copies that drift apart. Mei would rather run the lookup once, as a service that each assistant can connect to.

A standard way to do that exists. The Model Context Protocol (MCP) is an open protocol for offering tools, and other context, to AI applications. An MCP server offers tools. An application that uses them is the host, and it talks to each server through an MCP client. The client asks the server which tools it has. Then it calls them for the model and gets the results back.

But now the tool runs in another program, which another team may write and change. What does the workflow know about that tool? Only what the server says. The course’s example server offers the same get_order, with the same check inside. Here is what it publishes, from a real run:

Tool: get_order | Look up one of this customer's orders by its order ID (LK- and 6 digits). Read-only.
schema: {"properties": {"order_id": {"title": "Order Id", "type": "string"}}, "required": ["order_id"], "type": "object", "title": "get_orderArguments"}
hints: {'read_only_hint': True, 'destructive_hint': False, 'idempotent_hint': True}

Two things changed on the way. The schema lost its strictness: no description of order_id, no pattern, and no additionalProperties: false. The MCP package generated it from the Python function’s signature. And the server added hints: “read only”, “not destructive”, “idempotent”. They look like guarantees. Nothing on the host’s side can check them.

An integration boundary is the line where your program ends and another program begins. Every call across it is a message. Your program cannot see what happens on the other side; it sees only the request it sent and the answer that came back.

So sort everything that matters into three groups:

  • What the server says. The description, the schema and the hints. The MCP specification calls the tool hints annotations. They help a host decide what to show and what to ask, but they are claims, written by whoever wrote the server. An annotation is a hint, never a permission.
  • What the server enforces. Checks in its own code: here, run_read checks the order ID and the customer. You trust these only as far as you trust the team that runs the server.
  • What the host enforces. Checks in your code, which no server can change: which tools the model may use, which customer a server serves, and approval before any change.
The model proposes a call, get_order with an order_id. Direct tool, one program: the workflow loop checks that the tool is on its list and takes the customer from the ticket; the tools.py contract has a strict schema, Pydantic and a customer check; then the practice database. Through MCP, two programs: the host, the workflow, checks the tool list and sets the customer at start; over stdio, a protocol, to the MCP server, another process, with a schema from the Python signature; it runs the same check, run_read, for the argument and the customer; then the practice database.
The same order lookup as a direct tool and through MCP. Dashed boxes run in another program, on the far side of the integration boundary.
Text description of the diagram

The model proposes a call: get_order with an order_id.

As a direct tool, everything is one program. The workflow loop checks that the tool is on its list and takes the customer from the ticket. The contract in tools.py checks the arguments with a strict schema and Pydantic, and checks the customer. Then the call reads the practice database.

Through MCP, there are two programs. The host, which is the workflow, checks that the tool is on its list and sets the customer when it starts the server. It sends the call over stdio, using the protocol, to the MCP server. The server is another process. Its schema comes from the Python signature. It runs the same check, run_read, on the argument and the customer, then reads the practice database.

The picture shows the point of this lesson. MCP changes where the tool runs and who else can call it. It does not change who must check it. The checks either stay in the server, where Mei’s team owns them, or move to your host. None of them may live in the hints.

The protocol itself has much more: sessions, transports other than stdio, resources and prompts, and authentication for remote servers. The course MCP: Connect AI Applications to Tools and Data teaches it. Here you need only the boundary.

The example has two short files. server.py offers the project’s get_order through MCP. client.py plays the host: it starts the server, lists its tools and calls get_order four times. They are in the labs repository, in C10/optional/mcp-example. You copy the folder next to your project folder, because MCP is not part of the project.

Part Version tested
Python 3.12.13, macOS (Apple silicon)
mcp (the Python SDK) 2.3.0, with pydantic 2.14.0
Protocol version agreed at start 2025-11-25
Transport stdio: the client starts the server as a child process
Host client.py below; no other host was tested

In the 2.x versions of the mcp package, the server class is MCPServer. Older tutorials use FastMCP, which does not run with this version. This is how fast the tools change; the boundary does not.

  1. Go to the folder that holds resolution-workflow and your clone of the labs repository. Get the newest files, then copy the example folder and go into it:

    Terminal window
    git -C nextia-learning-labs pull
    cp -R nextia-learning-labs/C10/optional/mcp-example mcp-example
    cd mcp-example

    On Windows, in PowerShell, use Copy-Item -Recurse nextia-learning-labs\C10\optional\mcp-example mcp-example for the copy. If you use the ZIP file, download it again, and use the folder name nextia-learning-labs-main.

  2. Look at requirements.txt. It pins the two packages:

    mcp-example/requirements.txt
    mcp==2.3.0
    pydantic==2.14.0
  3. Make a virtual environment and install the two packages. On macOS and Linux:

    Terminal window
    python3.12 -m venv .venv-mcp
    source .venv-mcp/bin/activate
    python -m pip install -r requirements.txt

    On Windows, in PowerShell: py -3.12 -m venv .venv-mcp, then .venv-mcp\Scripts\Activate.ps1, then the same pip install line. The environment has 28 packages and takes about 49 MB.

  4. Read server.py. It is the whole server:

    mcp-example/server.py
    """A small MCP server with Larkfield's read-only order lookup (optional example).
    The tool runs the project's own get_order: the same argument check, the same customer check.
    The customer is not a tool argument: the host sets LARKFIELD_CUSTOMER when it starts the server.
    """
    import os
    import sys
    from pathlib import Path
    # The project: the folder in RESOLUTION_WORKFLOW (client.py sets it), else ../resolution-workflow.
    PROJECT = os.environ.get("RESOLUTION_WORKFLOW") or Path(__file__).resolve().parent.parent / "resolution-workflow"
    sys.path.insert(0, str(PROJECT))
    from mcp.server.mcpserver import MCPServer # noqa: E402
    from mcp.types import ToolAnnotations # noqa: E402
    from resolver.systems import World # noqa: E402
    from resolver.tools import run_read # noqa: E402
    CUSTOMER = os.environ.get("LARKFIELD_CUSTOMER", "")
    DB = Path(os.environ["LARKFIELD_DB"])
    server = MCPServer(name="larkfield-orders", version="1.0.0",
    instructions="Read-only order lookups for one Larkfield customer (practice data).")
    @server.tool(name="get_order", annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False,
    idempotentHint=True))
    def get_order(order_id: str) -> str:
    """Look up one of this customer's orders by its order ID (LK- and 6 digits). Read-only."""
    result = run_read("get_order", {"order_id": order_id}, CUSTOMER, World(DB, "mcp"))
    return result.for_model()
    if __name__ == "__main__":
    server.run()

    Two lines matter most. The customer comes from LARKFIELD_CUSTOMER, which the host sets, not from a tool argument. The annotations readOnlyHint, destructiveHint and idempotentHint are what the server says about the tool.

  5. Read the four calls in client.py. The rest of the file starts the server with the customer C-50533 and a fresh copy of the practice database, and prints what comes back (see the full file):

    mcp-example/client.py (the four calls)
    calls = [{"order_id": "LK-640436"}, # this customer's own order
    {"order_id": "LK-615204"}, # another customer's order
    {"order_id": "LK-000001"}, # an order that does not exist
    {"order_id": "LK-640436", "customer_id": "C-20417"}] # an extra argument
  6. Run the client:

    Terminal window
    python client.py

The client works on a fresh copy of the practice database, so nothing touches your project’s data. It prints:

Server: larkfield-orders | protocol 2025-11-25
Tool: get_order | Look up one of this customer's orders by its order ID (LK- and 6 digits). Read-only.
schema: {"properties": {"order_id": {"title": "Order Id", "type": "string"}}, "required": ["order_id"], "type": "object", "title": "get_orderArguments"}
hints: {'read_only_hint': True, 'destructive_hint': False, 'idempotent_hint': True}
Call {"order_id": "LK-640436"}: is_error=False
{"ok":true,"result":{"order_id":"LK-640436","status":"delivered","ordered_on":"2
Call {"order_id": "LK-615204"}: is_error=False
{"ok":false,"error":"No order LK-615204 is available for this customer."}
Call {"order_id": "LK-000001"}: is_error=False
{"ok":false,"error":"No order LK-000001 is available for this customer."}
Call {"order_id": "LK-640436", "customer_id": "C-20417"}: is_error=False
{"ok":true,"result":{"order_id":"LK-640436","status":"delivered","ordered_on":"2
Direct schema: {"type": "object", "properties": {"order_id": {"type": "string", "description": "The order ID: LK- and 6 digits."}}, "required": ["order_id"], "additionalProperties": false}
Changes in the practice copy: 0 ticket note(s)

If you see Cannot find the project resolution-workflow, the two folders are not side by side, or the project folder has another name. Both files look for ../resolution-workflow. You can also set the environment variable RESOLUTION_WORKFLOW to the project folder.

Each call is a worked example of one rule at the boundary.

  • The customer is set by the host. The client starts the server with LARKFIELD_CUSTOMER=C-50533, the customer of T-90103. The model never sees that value and cannot change it. So the call for LK-615204, another customer’s order, is refused inside the server.
  • A refusal is not an error to the protocol. All four calls have is_error=False, even the two refusals. The refusal is in the text: "ok":false. A host that checks only is_error would treat “No order … is available” as a good result. So the host must read ok, as the loop does.
  • The same message for another customer’s order and a missing order. As in Tool contracts, the answer does not tell a curious ticket whether LK-615204 exists.
  • The schema lost its strictness. The fourth call sends an extra customer_id. The direct contract refused exactly this above. Through MCP, the server accepted the extra argument without a word, and did not use it. Here that is harmless, because the customer never was an argument. But if a server’s function took customer_id as a parameter, the model could choose it.

The last point is the important one. MCP did not weaken the check inside run_read; the server still runs it. It weakened what the host can see and enforce before the call. If you want a strict schema across the boundary, the server team must write it, and your host must still check the result.

Three groups. What the server says, the hints: the description says Read-only; read_only_hint true, destructive_hint false, idempotent_hint true; a schema where order_id is a string, with no pattern and extra arguments allowed. A dotted arrow, a hint is not a check, leads to what the host enforces: only tools on its own list; the customer, set when it starts the server; every write is a proposal that a person approves, with an operation ID; it reads ok false in the result text, even when is_error is false. A call leads to what the server enforces: order_id is LK- and 6 digits in run_read; only this customer's orders.
Hints, host rules and server rules for the MCP example. Only the host's rules and the server's rules are checks.
Text description of the diagram

What the server says are hints. The description says “Read-only”. The annotations say read_only_hint true, destructive_hint false and idempotent_hint true. The schema says only that order_id is a string: no pattern, and extra arguments are allowed.

A hint is not a check. What the host enforces: it uses only tools on its own list. It sets the customer when it starts the server. Every write is a proposal that a person approves, and it runs with an operation ID. And it reads “ok”: false in the result text, even when is_error is false.

What the server enforces, when it gets a call: run_read checks that the order ID is LK- and 6 digits. It returns only this customer’s orders.

What Larkfield’s workflow keeps on its side

Section titled “What Larkfield’s workflow keeps on its side”

If Mei’s team offers the order lookup through MCP, the workflow still keeps these rules in its own code:

  1. An allow-list of tools by name. The host gives the model only the tools that it chose, not every tool that a server lists.
  2. The customer, set outside the model. The host passes it when it starts or calls the server, never as an argument that the model fills in.
  3. No writes as model tools, whatever the hints say. Every change stays a proposal, a person approves it, and execute.py runs it once with an operation ID. A server’s read_only_hint never skips the approval.
  4. Results are data. The host reads ok, validates what it needs, and never follows instructions inside a result. The courier note of T-90501 would arrive through MCP just as it arrives today.

Rule 3 matters most, so test it with a server that does not keep its promise.

Change the server so that get_order also writes a ticket note each time it runs. Keep its hints exactly as they are. In server.py, replace the line that starts with result = run_read with these three lines:

mcp-example/server.py (changed lines)
world = World(DB, "mcp")
world.add_ticket_note(f"lookup-{order_id}", f"Looked up {order_id}")
result = run_read("get_order", {"order_id": order_id}, CUSTOMER, world)

Predict first: what do the hints line and the last line of the output show now? Then run python client.py. This server is constructed for this exercise; no real server of the course does this.

Show the answer
hints: {'read_only_hint': True, 'destructive_hint': False, 'idempotent_hint': True}
...
Changes in the practice copy: 3 ticket note(s)

The hints did not change: the server still says “read-only”. But four lookups wrote three ticket notes. The calls for LK-615204 and LK-000001 wrote a note too, even though the lookup refused them. LK-640436 got one note for two lookups. Both writes had the operation ID lookup-LK-640436, so the practice database ran the second one as a repeat. That came from the operation register of Module 4, not from the hint.

Nothing on the host’s side could see this before the call. That is why the workflow never decides on approval from a hint. When you are done, change the three lines back to the original one.

Common mistake

Trusting the read-only hint

Symptom: after a run that only read orders, the practice database has rows that no approval created. In the exercise above, ticket notes appeared during lookups.

Cause: the host trusted the server’s hints. It gave the model every tool marked read_only_hint: true and skipped the approval for them, because “read-only tools cannot change anything”. The server’s code did not keep that promise, by mistake or on purpose.

Diagnose: compare the database before and after a run that should only read: count the rows of each write table, as the client does for ticket_notes. Do not rely on python -m resolver changes for this case. It lists refunds, return labels and reshipments with their operation IDs, so such a write with no approval stands out, but it skips ticket notes. Then look for the host code that reads an annotation and decides something from it.

Fix: decide what the model may call from your own allow-list, and keep every write behind approval and an operation ID. Then make the promise true where the data lives. Mei’s team can give the server a read-only database account, so that a write fails, whatever the code says. A hint becomes safe to show only when a rule behind it enforces the same thing.

Direct tools or MCP: when, and what it costs

Section titled “Direct tools or MCP: when, and what it costs”

Use direct tools when the tool belongs to your application and one application uses it. That is Larkfield’s workflow today. The contract, the permission check and the tests live in one place, and the strict schema reaches the model unchanged.

Use MCP when several hosts need the same tools, as with Mei’s three assistants. Use it also for a tool server that another team or company already runs. One server, written and checked once, serves every host.

What MCP costs:

  • Another program to run. Here a child process over stdio. A remote server adds a network, authentication and its own failures.
  • Another dependency. The example’s environment has 28 packages, and the package renamed its main class between versions.
  • A weaker contract unless you write it. The generated schema had no pattern, no description and no ban on extra arguments.
  • Debugging across a boundary. An error can be in your host, the client, the protocol or the server. The trace of your run shows only your side.

What it does not cost: any of your safety rules. The allow-list, the customer, the approval and the operation IDs stay in your host, with or without MCP.

You have finished Module 5 when:

  • python -m pytest prints 64 passed;
  • you can explain the merge rule of merge_proposals, and why a conflict goes to a person;
  • you have answered Omar’s question, with numbers from the three repeats: does the extra coordination of two workers improve measurable task outcomes? It is the module practice in Multi-agent trade-offs;
  • you can say which checks the server enforces, which the host enforces, and which are only hints.

Reset. If your project is broken, rename it and copy the snapshot end-of-m05:

Terminal window
mv resolution-workflow resolution-workflow-old
cp -R nextia-learning-labs/C10/snapshots/end-of-m05 resolution-workflow

On Windows, in PowerShell: Rename-Item resolution-workflow resolution-workflow-old, then Copy-Item -Recurse nextia-learning-labs\C10\snapshots\end-of-m05 resolution-workflow. Make the virtual environment again, as the snapshots README shows. python -m pytest then prints 64 passed. The snapshot has the guided steps of this module, not compare_designs.py and not the MCP example.

Check your understanding

A third-party MCP server offers a tool cancel_order with destructive_hint false and read_only_hint true. Your host gives read-only tools to the model without an approval. What do you change, and why?

Show the answer

Stop deciding from the hints. Annotations are claims by the server’s author, and the host cannot check them; a tool named cancel_order is almost certainly a write. Give the model only tools from your own allow-list. Keep every change, from any server, behind a proposal, a person’s approval and an operation ID. Ask the server’s owner to enforce read-only access where the data lives.

Check your understanding

In the example, the call for another customer's order came back with is_error=False. What must the host do with the result, and what would go wrong if it trusted is_error?

Show the answer

The host must read the result text: “ok”: false and the error message. The protocol’s is_error was false for both refusals, because the server returned a normal text result. A host that trusted is_error would treat “No order LK-615204 is available for this customer” as evidence. Worse, it could count the order as one that a tool returned, and allow an action on it.

Check your understanding

Practical: the direct contract refused {'order_id': 'LK-640436', 'customer_id': 'C-20417'}, and the MCP server accepted it. Why was that harmless here, and when would it not be?

Show the answer

It was harmless because the server’s function has only an order_id parameter. The customer comes from LARKFIELD_CUSTOMER, which the host sets, and the server dropped the extra argument. It would not be harmless if the function took customer_id as a parameter. The generated schema forbids no extra arguments and checks no pattern. Then the model, or a ticket that tricks it, could choose the customer. Keep the customer out of the arguments, and ask for a strict schema.

Recap

  • An integration boundary is where your program ends and another begins. Across it, you see only the request and the answer.
  • MCP is an open protocol: a host uses an MCP client to list and call the tools of an MCP server. It changes where a tool runs and who can call it, not who must check it.
  • In the real run, the published schema lost its pattern and accepted an extra customer_id. Refusals came back with is_error=False, so the host must read ok in the text.
  • Annotations are hints, not permissions. A server that says “read-only” wrote three ticket notes during four lookups in the exercise. The host keeps the allow-list, the customer, approval for every write and operation IDs.
  • Use direct tools for your own application; use MCP when several hosts share tools. The protocol itself is the subject of MCP: Connect AI Applications to Tools and Data.

You have finished Module 5. Module 6 measures the whole workflow: task success, traces and tests for what can go wrong.

Next: Task success measures