openai client. You will build the bridge that turns MCP tools into the OpenAI tool_calls your model already speaks. Everything runs offline.
M07: MCP & Rich Output
In M05 and M06 you hand-wrote tool definitions and dispatch logic inside your agent. That works — until you want the same tools in three agents, or someone else's tools in yours. The Model Context Protocol (MCP) is the open standard that turns tools, data, and prompts into reusable servers any agent can plug into. This module builds an MCP server, bridges it into a local Mistral agent, and uses it to produce rich output — including generated charts, not just text.
Learning Objectives
- Explain the N×M integration problem and how MCP collapses it to N+M
- Describe MCP's client–server architecture and the three things a server exposes: Resources, Tools, and Prompts
- Choose between the stdio and HTTP+SSE transports, and explain the JSON-RPC messages underneath
- Build a working MCP server with FastMCP (Python) exposing a tool, a resource, and a web-search capability
- Write the bridge that maps MCP tools into OpenAI
tool_callsso a local Mistral agent can use them - Produce rich, visual output — have a tool render a chart and return it as an image artifact
- Connect a filesystem + chart MCP server to a Mistral agent end to end
The N×M Problem: Why MCP Exists
BEFORE: Think about electronics before USB. A camera had one proprietary cable, your phone another, the printer a third, each laptop a different port. Every device maker had to support every computer maker, and every computer maker had to support every device. A drawer full of incompatible cables was the physical proof of the mess.
PAIN: Agent tooling started in exactly that drawer. You have N agents (a triage bot, a coding agent, a research assistant) and M capabilities (a database, GitHub, a filesystem, a search API). With hand-wired integrations, connecting them is N×M bespoke adapters — and worse, each is written in that agent's private dialect. Add one new capability and you re-integrate it into every agent; add one new agent and you re-wire it to every capability. The work grows like a multiplication table, and none of it is reusable.
MAPPING: USB-C fixed devices by agreeing on one connector: any device with the port works with any computer with the port. MCP is USB-C for agents. A capability is wrapped once as an MCP server that speaks the standard protocol; any agent that speaks MCP (the client) plugs into it with no custom code. Now adding a capability is +1 server that every agent can use, and adding an agent is +1 client that reaches every server. N×M bespoke adapters collapse into N+M standard plugs.
The animation makes the arithmetic visceral. On the left, every agent wires directly to every capability — the links multiply. On the right, each side connects once to the MCP standard, and the tangle becomes a hub.
standard
The N+M math is not just tidier — it unlocks a marketplace. Because an MCP server is agent-agnostic, the GitHub team can publish one server and every MCP host on earth gains GitHub access for free. Today there are hundreds of community MCP servers (Postgres, Slack, Puppeteer, filesystem, web search) you can plug into your Mistral agent without writing a line of integration code. With hand-wired tools, you would re-implement each one against your specific agent. MCP turns "integrate this capability" from a coding project into a config line.
MCP Architecture: Client, Server, and Three Primitives
An MCP server wraps a capability (a database, a folder, an API) and exposes it over the protocol. An MCP client is the connector inside your application that talks to one server. The host is the application that owns the agent and spins up clients — in this course, your Python/Node program driving Mistral. One host can run many clients, each connected to a different server, so a single agent can reach a filesystem server, a database server, and a web-search server at once.
A server exposes its capability through three distinct primitives. Knowing which to use is most of designing a good server:
| Primitive | What it is | Who controls it | Example |
|---|---|---|---|
| Resource | Read-only data the agent can load into context, addressed by a URI | App / agent decides when to read | file:///ucc/filing-123.txt |
| Tool | An action the model can invoke, with typed arguments and a result | The model decides when to call | search_filings(query) |
| Prompt | A reusable, parameterized prompt template the server offers | User picks it (e.g. a slash command) | /summarize-filing |
The line is data vs action. A resource is something you read — it has no side effects and the application decides when to pull it into context (like opening a file). A tool is something the model does — it may have side effects, takes arguments, and the model chooses to call it during reasoning (like running a query). "Get the contents of filing 123" is a resource; "search all filings for the ones mentioning bankruptcy" is a tool. When in doubt: if the model needs to decide to invoke it with arguments, it is a tool.
Open models like Mistral support tool calling natively (you used it in M05). Resources and prompts, though, are MCP-host features — there is no "resources" field in the OpenAI chat API. So your bridge will map MCP tools directly onto tool_calls, and surface resources by reading them and injecting their text into the conversation yourself. That asymmetry is the main thing that makes an open-source MCP host different from Claude Desktop, and you will see it concretely in the bridge code.
Transports & JSON-RPC
Under the hood, every MCP message is JSON-RPC 2.0A lightweight standard for remote procedure calls encoded as JSON. Each request has a method (e.g. "tools/call"), params, and an id; the response carries a matching id and either a result or an error. MCP uses it for every client–server exchange. — a tiny, well-understood request/response format. What changes is the pipe those JSON messages flow through. MCP defines two transports:
| Transport | How it works | Use when |
|---|---|---|
| stdio | The host launches the server as a subprocess; messages flow over its stdin/stdout | The server runs locally — a filesystem, a local DB, a script. Default for development. |
| HTTP + SSE | The server is a web service; requests go over HTTP, streamed responses come back via Server-Sent Events | The server is remote or shared — a hosted API, a multi-user service, a different machine. |
The animation traces a single tool call over stdio: the client asks for the tool list, the server answers, the client calls a tool, and the result comes back — four JSON-RPC messages over the subprocess pipe.
You saw the entire MCP interaction model in four messages: discover what a server offers (tools/list), then invoke one (tools/call). The transport (stdio here) is just the pipe; the conversation is always JSON-RPC. The good news for you as a builder: a framework like FastMCP hides every byte of this. You write a normal Python function, decorate it, and the protocol marshalling is handled. Let's build one.
Build an MCP Server
The fastest path in Python is FastMCPThe high-level server API in the official `mcp` Python package. You decorate plain functions with @mcp.tool() or @mcp.resource(); FastMCP infers the JSON schema from type hints and handles all JSON-RPC plumbing., the high-level API in the official mcp package. You write ordinary functions; decorators turn them into protocol-correct tools and resources, inferring the argument schema from your type hints. Here is a server exposing a search tool, a web-search tool, and a file resource.
WHY: Decorators + type hints become the JSON-RPC tool schema — you write functions, not protocol
GOTCHA: Tool docstrings and type hints ARE the model's documentation — vague hints produce bad tool calls
# ucc_server.py — an MCP server. Install: pip install "mcp[cli]" httpx
# WHAT: Exposes tools (search_filings, web_search) and a resource (filing files)
# WHY: FastMCP turns typed functions into protocol-correct MCP primitives
# GOTCHA: Run over stdio for local dev; the host launches this file as a subprocess
import json, pathlib, httpx
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("ucc-tools") # the server's name, shown to clients
FILINGS = pathlib.Path("filings") # a folder of mock UCC filing .txt files
@mcp.tool()
def search_filings(query: str, limit: int = 5) -> list[dict]:
"""Search local UCC filings for a keyword. Returns matching filings with a snippet.
Args:
query: keyword to search for (case-insensitive)
limit: max number of results to return
"""
results = []
for path in sorted(FILINGS.glob("*.txt")):
text = path.read_text(encoding="utf-8")
if query.lower() in text.lower():
idx = text.lower().index(query.lower())
results.append({"file": path.name, "snippet": text[max(0, idx-40):idx+60]})
if len(results) >= limit:
break
return results
@mcp.tool()
def web_search(query: str, k: int = 3) -> list[dict]:
"""Search the public web for up-to-date information the model can't know.
Args:
query: the search query
k: number of results to return
"""
# AI-native web search returns clean, agent-ready snippets — not a page of HTML.
# Swap this for any provider (Brave, Tavily, SearXNG self-hosted, etc.).
try:
resp = httpx.get("https://api.search.example/q",
params={"q": query, "k": k}, timeout=10)
resp.raise_for_status()
return resp.json()["results"] # [{title, url, snippet}, ...]
except httpx.HTTPError as e:
return [{"error": f"web_search unavailable: {e}"}]
@mcp.resource("filing://{name}")
def read_filing(name: str) -> str:
"""Return the full text of one filing by file name (a READ-ONLY resource)."""
path = FILINGS / name
if not path.is_file():
raise FileNotFoundError(f"no filing named {name!r}")
return path.read_text(encoding="utf-8")
if __name__ == "__main__":
mcp.run() # default transport: stdio
// ucc_server.js — an MCP server. Install: npm i @modelcontextprotocol/sdk zod
// WHAT: Exposes a search_filings tool and a filing resource over stdio
// WHY: The official SDK gives the same primitives as FastMCP, with zod schemas
// GOTCHA: Schemas are explicit here (zod) rather than inferred from type hints
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "node:fs";
import path from "node:path";
const server = new McpServer({ name: "ucc-tools", version: "1.0.0" });
const FILINGS = "filings";
server.tool(
"search_filings",
"Search local UCC filings for a keyword; returns matches with a snippet.",
{ query: z.string(), limit: z.number().default(5) },
async ({ query, limit }) => {
const results = [];
for (const name of fs.readdirSync(FILINGS).sort()) {
const text = fs.readFileSync(path.join(FILINGS, name), "utf8");
const i = text.toLowerCase().indexOf(query.toLowerCase());
if (i !== -1) results.push({ file: name, snippet: text.slice(Math.max(0, i - 40), i + 60) });
if (results.length >= limit) break;
}
return { content: [{ type: "text", text: JSON.stringify(results) }] };
}
);
server.resource(
"filing", "filing://{name}",
async (uri, { name }) => {
const text = fs.readFileSync(path.join(FILINGS, name), "utf8");
return { contents: [{ uri: uri.href, text }] };
}
);
await server.connect(new StdioServerTransport()); // serve over stdio
Read the Python server in three moves — this is the whole pattern:
- One object, three decorators.
FastMCP("ucc-tools")is the server.@mcp.tool()turns a function into a callable tool;@mcp.resource("filing://{name}")turns one into a URI-addressed resource. You never touch JSON-RPC. - Type hints become the schema.
query: str, limit: int = 5is automatically published as the tool's argument schema, and the docstring becomes its description. This is not boilerplate — it is the only documentation the model gets, so precise hints and docstrings directly determine whether the model calls the tool correctly. mcp.run()speaks stdio. With no arguments it serves over stdin/stdout, ready for a host to launch it as a subprocess. The same file can serve HTTP by changing the transport — the tool code does not change.
Notice web_search is just another MCP tool. That is the open-source answer to "how does my offline Mistral agent reach today's web?": you do not need a model with a built-in browser — you expose search as a tool. An AI-native search API returns clean snippets (title, url, summary) instead of a wall of HTML, so the model gets agent-ready context. Self-host SearXNG for a fully offline setup, or point at Brave/Tavily. Either way the agent's interface is identical: call a tool, get back facts.
tool_calls, and routes the model's calls back to the server.Connect It to a Local Agent: the MCP→Ollama Bridge
This is the part Claude Desktop hides and you get to build. The bridge does three jobs: (1) connect to the MCP server and list its tools, (2) translate each MCP tool schema into the OpenAI tool format Mistral expects, and (3) run the normal tool-calling loop — but dispatch each tool call to the MCP server instead of a local function.
WHY: This adapter is what lets any open model use any MCP server — the glue Claude Desktop hides
GOTCHA: MCP tool schemas map to OpenAI's
{"type":"function","function":{...}} shape; the reply role must be "tool"# bridge.py — let a local Mistral agent use MCP tools. pip install "mcp[cli]" openai
# WHAT: Discover MCP tools, expose them to Mistral, dispatch tool_calls back to MCP
# WHY: Open models speak OpenAI tool_calls, not MCP — this is the translation layer
# GOTCHA: Tool results go back as role:"tool" messages keyed by tool_call_id
import asyncio, json
from openai import OpenAI
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
llm = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
def mcp_tool_to_openai(t) -> dict:
# Map an MCP tool's schema onto the OpenAI tool-calling format Mistral expects
return {"type": "function", "function": {
"name": t.name,
"description": t.description or "",
"parameters": t.inputSchema, # MCP already gives JSON Schema
}}
async def run(user_msg: str) -> str:
# Launch the MCP server as a subprocess and open a session over stdio
params = StdioServerParameters(command="python", args=["ucc_server.py"])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize() # MCP handshake
tools = (await session.list_tools()).tools # tools/list
openai_tools = [mcp_tool_to_openai(t) for t in tools]
messages = [{"role": "user", "content": user_msg}]
for _ in range(6): # bounded agent loop
resp = llm.chat.completions.create(
model="mistral", messages=messages, tools=openai_tools)
msg = resp.choices[0].message
if not msg.tool_calls: # model is done
return msg.content
messages.append(msg)
for call in msg.tool_calls: # dispatch each call to MCP
args = json.loads(call.function.arguments or "{}")
result = await session.call_tool(call.function.name, args) # tools/call
text = "".join(c.text for c in result.content if c.type == "text")
messages.append({"role": "tool", "tool_call_id": call.id,
"content": text}) # reply keyed by id
return "stopped: hit the tool-call iteration cap"
if __name__ == "__main__":
print(asyncio.run(run("Find filings that mention bankruptcy and summarize them.")))
// Node.js: the bridge is structurally identical, using the same two SDKs:
// import { Client } from "@modelcontextprotocol/sdk/client/index.js";
// import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
// import OpenAI from "openai";
//
// 1) const transport = new StdioClientTransport({ command: "node", args: ["ucc_server.js"] });
// const mcp = new Client({ name: "bridge", version: "1.0.0" });
// await mcp.connect(transport);
//
// 2) const { tools } = await mcp.listTools();
// const openaiTools = tools.map(t => ({ type: "function", function: {
// name: t.name, description: t.description ?? "", parameters: t.inputSchema } }));
//
// 3) Run the same bounded loop you used in M06: on each msg.tool_calls,
// const res = await mcp.callTool({ name: call.function.name,
// arguments: JSON.parse(call.function.arguments) });
// then push { role: "tool", tool_call_id: call.id,
// content: res.content.map(c => c.text).join("") }.
//
// The async MCP plumbing is heavier in Node; this module ships the full runnable
// path in Python and the structural map here. The OpenAI tool_calls loop itself is
// exactly the one from M06 — only the dispatch target (MCP, not a local fn) changes.
The bridge is short because it reuses the loop you already know. Three things are worth pinning down:
- Schema translation is one function.
mcp_tool_to_openaiwraps the MCP tool in OpenAI's{"type":"function", ...}envelope. The crucial detail:t.inputSchemais already JSON Schema, so it drops straight intoparameters. No hand-writing argument definitions — the server's type hints flow all the way to the model. - The loop is the M06 loop. Call the model; if it wants no tools, return; otherwise execute the calls and append the results. The only change from M06 is the dispatch line: instead of calling a local Python function, you call
session.call_tool(name, args)— a JSON-RPC round trip to the server. - The reply contract is exact. Each tool result is appended as a
role:"tool"message carrying the matchingtool_call_id. Get that id wrong and the model loses track of which result answers which call — the most common bridge bug.
Your offline Mistral agent just used a tool it never knew about at code-time. You did not hand-write a tool definition or a dispatcher — you pointed the bridge at a server and the tools appeared. Swap in the official GitHub MCP server and the same agent gains GitHub powers with zero code changes. That is the payoff of the standard: capabilities become plug-ins. Now let's make a tool return something richer than text.
Rich & Visual Output
Visual output generation is an agent producing a visual artifact — a chart, a diagram, a rendered image — as the result of its work, not just describing one in prose. The model itself does not draw pixels; instead it calls a tool that renders the visual (here, matplotlib making a PNG) and returns it. MCP supports this directly: a tool result can be an image content block, not only text. So "plot last quarter's filings by month" ends with an actual chart, generated deterministically by code the model orchestrated.
This matters because a number in a sentence and a trend line tell a human very different stories. An agent that can answer "are UCC filings rising?" with a rendered line chart is far more useful than one that can only say "yes, somewhat." The key insight: keep the rendering in deterministic code and let the model decide when and with what data to call it. Here is a chart tool added to our server.
WHY: The model orchestrates when to chart and with what data; deterministic code draws the pixels
GOTCHA: Return an MCP Image (base64 PNG), not a file path — the host may not share your filesystem
# add to ucc_server.py — a tool that returns a CHART, not text. pip install matplotlib
# WHAT: Render a bar chart from labelled values and return it as an MCP image
# WHY: Lets the agent answer "show the trend" with an actual visual artifact
# GOTCHA: Use the non-interactive 'Agg' backend — there is no display on a server
import io, base64
import matplotlib
matplotlib.use("Agg") # headless: render to a buffer, no GUI
import matplotlib.pyplot as plt
from mcp.server.fastmcp import Image
@mcp.tool()
def make_bar_chart(labels: list[str], values: list[float], title: str = "") -> Image:
"""Render a bar chart and return it as a PNG image.
Args:
labels: x-axis category labels (e.g. months)
values: the bar heights, one per label
title: chart title
"""
if len(labels) != len(values):
raise ValueError("labels and values must be the same length")
fig, ax = plt.subplots(figsize=(6, 3.5))
ax.bar(labels, values, color="#F97316")
ax.set_title(title)
fig.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=120) # render to memory, not disk
plt.close(fig) # always free the figure
# MCP Image carries the bytes; the host renders it inline. No file path leaks.
return Image(data=buf.getvalue(), format="png")
// Node.js: render with a headless canvas, return an MCP image content block.
// npm i @modelcontextprotocol/sdk chartjs-node-canvas chart.js
//
// import { ChartJSNodeCanvas } from "chartjs-node-canvas";
// const canvas = new ChartJSNodeCanvas({ width: 600, height: 350 });
//
// server.tool("make_bar_chart", "Render a bar chart, return a PNG image.",
// { labels: z.array(z.string()), values: z.array(z.number()), title: z.string().default("") },
// async ({ labels, values, title }) => {
// const png = await canvas.renderToBuffer({
// type: "bar",
// data: { labels, datasets: [{ data: values, backgroundColor: "#F97316" }] },
// options: { plugins: { title: { display: !!title, text: title } } },
// });
// return { content: [{ type: "image",
// data: png.toString("base64"), mimeType: "image/png" }] };
// });
//
// Same idea as Python: deterministic rendering code, an image content block back.
// The model only decides WHEN to call it and with WHAT data.
The animation shows the full path: the user asks a question, the Mistral agent reasons that it needs data and a picture, calls the search tool, then the chart tool, and returns a rendered image as its answer.
It is tempting to ask a model to "draw" things by emitting SVG or ASCII art directly. Don't — a 7B model will produce wonky, unreliable visuals and waste tokens doing it. The robust pattern is exactly the one here: the model supplies structured data and intent, and battle-tested rendering code (matplotlib, Chart.js) produces a correct, consistent image every time. The agent's job is to decide what to chart; the tool's job is to draw it right. This separation is why a small local model can still ship polished visual output — the pixels never depend on the model's drawing ability.
Lab: A Filesystem + Chart MCP Server With a Mistral Agent
What you'll build: an MCP server exposing filing search and a chart tool, plus a bridge that lets a local Mistral agent answer "search the filings about X and chart the results." Time: ~45 min. Prereqs: Ollama with mistral pulled; Python 3.10+.
mkdir mcp-lab && cd mcp-lab
pip install "mcp[cli]" openai matplotlib httpx
ollama pull mistral
mkdir filings
printf 'Debtor: Acme LLC\nSecured party: First Bank\nNote: bankruptcy risk flagged Q2.\n' > filings/f-101.txt
printf 'Debtor: Globex Inc\nSecured party: Credit Union\nNote: clean standing.\n' > filings/f-102.txt
printf 'Debtor: Initech\nSecured party: First Bank\nNote: prior bankruptcy on record.\n' > filings/f-103.txt
Create ucc_server.py with search_filings, the read_filing resource, and make_bar_chart from this module. Sanity-check it standalone: mcp dev ucc_server.py opens the MCP Inspector so you can call each tool by hand before any agent is involved.
Create bridge.py from the bridge section. It launches ucc_server.py as a subprocess, lists its tools, translates them to OpenAI format, and runs the bounded Mistral tool-calling loop.
Run python bridge.py with the prompt "Which filings mention bankruptcy?" Confirm the agent calls search_filings("bankruptcy") via MCP and answers with f-101 and f-103 — tools it never saw at code-time.
Change the prompt to "Count filings per secured party and chart it." The agent should call search_filings to gather data, then make_bar_chart(labels=["First Bank","Credit Union"], values=[2,1]), and return a PNG. Save the image bytes and open it — visual output from a local model.
Install the community filesystem MCP server and connect it alongside yours in the bridge (two sessions, merged tool lists). Confirm the same agent now reaches both servers — the N+M ecosystem effect, demonstrated.
Your Mistral agent answers a text question by calling an MCP tool it discovered at runtime; it answers a "show me" question with a rendered PNG chart it orchestrated but did not draw; and you can articulate why this is N+M, not N×M — the server would plug into Claude Desktop or any other host unchanged. That is a real, standard, reusable capability layer for an open-source agent.
Knowledge Check
Test your understanding of the N×M problem, MCP architecture, transports, the bridge, and rich output.
1. What core problem does MCP solve?
2. What protocol do MCP client and server use to talk?
3. When should you use the stdio transport rather than HTTP+SSE?
4. What is the difference between an MCP Resource and a Tool?
5. In the open-source bridge, how do MCP tools reach the Mistral model?
tool_calls format, then dispatches the model's calls back via session.call_tool