Series note — Part of MAF v1: Python and .NET. Supersedes the Python-only Part 10 — MCP Integration. That article explains why MCP exists at length; this chapter is the minimum code on both sides, with the protocol handshake drawn on the wire rather than hand-waved.
Repo — Full runnable code for this chapter is at https://github.com/nitin27may/e-commerce-agents/tree/main/tutorials/08-mcp-tools. Clone the repo,
cd tutorials/08-mcp-tools, and follow the README.
Why this chapter#
A @tool-decorated function lives inside your agent’s process. It’s great until you want three other teams, written in three other languages, to call the same tool. Or until you want to deploy the tool independently, version it independently, and expose it to Claude Desktop or Cursor at the same time you expose it to MAF.
MCP — the Model Context Protocol — solves that. It’s a JSON-RPC protocol for advertising and invoking tools. You write the tool once, wrap it in an MCP server, and every MCP-speaking client (MAF Python, MAF .NET, LangChain, Claude Desktop, Cursor) can discover and call it. The tool implementation is a black box on the other side of the wire — could be Python, Node, Go, Rust, anything that can speak JSON-RPC on stdio or HTTP.
In this chapter we stand up a tiny Python MCP server with one get_weather tool, then call it from both a Python MAF agent and a .NET MAF agent. Same server, two clients, one protocol.
Prerequisites#
- Completed Chapter 07 — Observability with OpenTelemetry.
.envat the repo root withOPENAI_API_KEYor the Azure OpenAI trio.uv syncinsideagents/to install themcpPython package (FastMCP).- Read-first (optional): Agents — Local MCP Tools, Agents — Hosted MCP Tools, MCP protocol spec.
The concept#
An MCP deployment has three moving parts:
- Server — a process that owns the tool implementations. It advertises a list of tools (names, descriptions, JSON schemas for parameters) and handles invocation requests. FastMCP is the reference Python library;
@modelcontextprotocol/sdkis the TypeScript one;ModelContextProtocolis Microsoft’s .NET one. - Transport — JSON-RPC 2.0 frames travelling over either stdio (client spawns the server as a subprocess and talks over stdin/stdout) or HTTP/SSE (client POSTs JSON-RPC frames and receives streamed responses). Streamable HTTP is the newer transport; older implementations use plain HTTP + SSE. This chapter uses stdio because it requires zero deployment — the client spawns the server, consumes it, and tears it down.
- Client — the consuming side. MAF wraps the protocol so you never touch JSON-RPC directly: in Python
MCPStdioToolwraps the client; in .NETMcpClient+StdioClientTransportdoes the same.
Every MCP session starts with a two-phase handshake. The client sends an initialize request (protocol version, client capabilities), the server responds with its own capabilities, then the client issues tools/list and the server returns a manifest — tool names, descriptions, and parameter schemas. That manifest is what the agent’s LLM sees as tool definitions. Later, each tools/call request carries a tool name and arguments; the server executes the tool and returns the result. MAF routes the call result back into the tool-calling loop just like it does for any inline @tool.
MCPStdioTool"] net[".NET agent
McpClient + StdioClientTransport"] end subgraph transport["Transport — JSON-RPC over stdio"] direction TB rpc1["1. initialize handshake"] rpc2["2. tools/list (discovery)"] rpc3["3. tools/call (invocation)"] end subgraph server["MCP server (subprocess)"] fast["FastMCP
weather_mcp_server.py"] wtool[[get_weather]] end llm[(LLM)] answer([Final answer]) py --> rpc1 net --> rpc1 rpc1 --> rpc2 rpc2 --> fast fast -. "tool manifest" .-> rpc2 py --> rpc3 net --> rpc3 rpc3 --> fast fast --> wtool wtool --> fast fast -. "result" .-> rpc3 py --> llm net --> llm llm --> answer class py,net,fast,wtool core class llm external class answer success class rpc1,rpc2,rpc3 infra
Same server, two clients. The protocol sits between them — initialize → tools/list → tools/call — and both Python and .NET walk the exact same three steps. The LLM only ever sees the manifest returned in step 2.
MCP vs inline @tool — when to use which#
The inline @tool pattern from Chapter 02 is the right default for tools your agent owns. MCP earns its complexity when one of four things is true:
- Interoperability — you want Claude Desktop, Cursor, a LangChain agent, and a MAF agent to share the same tool. Write it once as an MCP server; every client speaks the same protocol.
- Separate deployment — the tool has its own lifecycle (independent version, independent scaling, different security boundary, runs on a GPU host). MCP lets you version and deploy the tool server independently of every agent that consumes it.
- Language-agnostic implementations — the tool is already written in Rust / Go / Python / TypeScript and you don’t want to port it. Wrap it in an MCP server in its native language and every agent can call it.
- Open catalog — you want a stable catalog of tools that non-developers (or other teams) can point their agents at without code changes. MCP servers advertise via a discovery manifest; clients connect and enumerate.
If none of those apply, skip MCP. Inline @tool is simpler, faster, and has zero subprocess orchestration overhead.
MCP vs A2A — two different protocols with non-overlapping jobs. MCP is about tools: stateless, single-call, schema-discovered at connection time. A2A is about agents: stateful conversations, session history, specialist routing. They compose cleanly — a specialist agent can be an A2A endpoint that internally consumes MCP tools.
Jargon recap#
- MCP (Model Context Protocol) — open protocol for advertising and invoking tools over JSON-RPC. modelcontextprotocol.io is the spec; MAF ships client implementations on both sides.
- JSON-RPC — lightweight RPC protocol where every request is a JSON object with
method,params, and anid. MCP uses JSON-RPC 2.0 framed over a transport. - stdio transport — client spawns the server as a subprocess and exchanges JSON-RPC frames over the child’s stdin/stdout. Zero network setup, scoped to the client’s lifetime. This chapter’s transport.
- HTTP/SSE transport — client POSTs JSON-RPC requests to a long-lived HTTP endpoint; server streams responses via Server-Sent Events. Used for long-running or remote MCP servers.
- FastMCP — the reference Python library for writing MCP servers.
from mcp.server.fastmcp import FastMCP— decorate Python functions with@server.tool()andserver.run()handles the protocol wire. MCPStdioTool(Python) — MAF’s stdio MCP client. Async context manager; spawns the subprocess, handshakes, lists tools, exposes them as callable tool objects, then terminates the subprocess on exit.McpClient(.NET) — MAF’s MCP client type, part of theModelContextProtocolNuGet package.await usingdisposable.StdioClientTransport(.NET) — transport adapter forMcpClient. Spawns a subprocess given a command and arguments.- Tool discovery — the
tools/liststep of the MCP handshake. The client asks the server “what tools do you expose?” and receives a manifest; MAF forwards that manifest to the LLM as tool definitions. approval_mode— MCP-specific knob for gating invocation. Setapproval_mode="always_require"onMCPStdioTooland every tool call pauses for human confirmation before execution. Covered end-to-end in Chapter 17 — Human in the Loop.
The server — FastMCP in twelve lines#
Source: python/weather_mcp_server.py.
from mcp.server.fastmcp import FastMCP
server = FastMCP("maf-v1-ch08-weather")
@server.tool()
def get_weather(city: str) -> str:
"""Look up the current weather for a city (canned data)."""
canned = {
"paris": "Sunny, 18°C.",
"london": "Overcast, 12°C.",
"tokyo": "Rain, 15°C.",
}
return canned.get(city.lower(), f"No weather data for {city}.")
if __name__ == "__main__":
server.run()Three things worth staring at:
FastMCP("maf-v1-ch08-weather")is the whole server instance. It owns the tool registry, the JSON-RPC dispatcher, and the stdio transport loop. No framework, no ASGI app, no port.@server.tool()registers the function. FastMCP introspects the signature, builds a JSON schema from the type hints, and publishesget_weatherin thetools/listmanifest.server.run()blocks on stdin, reads JSON-RPC frames, dispatches to the registered function, writes responses back on stdout. If the parent process exits, stdin closes and the server terminates naturally.
Code walkthrough#
Source: dotnet/Program.cs. Key lines:
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
public const string Instructions =
"You are a helpful assistant. "
+ "When the user asks about weather in a city, call the get_weather tool. "
+ "Keep answers to one short sentence.";
public static async Task<McpClient> BuildMcpClientAsync()
{
var pythonBin = Environment.GetEnvironmentVariable("PYTHON_BIN")
?? Path.Combine(FindRepoRoot(), "agents", ".venv", "bin", "python");
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Name = "weather-mcp",
Command = pythonBin,
Arguments = new[] { ServerScript },
});
return await McpClient.CreateAsync(transport);
}
public static async Task<string> Run(string question)
{
await using var mcpClient = await BuildMcpClientAsync();
var tools = (await mcpClient.ListToolsAsync())
.Select(t => (AITool)t)
.ToArray();
var chatClient = BuildChatClient();
var agent = chatClient.AsAIAgent(
instructions: Instructions,
name: "mcp-agent",
tools: tools);
var response = await agent.RunAsync(question);
return response.Text;
}Three things worth staring at:
McpClient.CreateAsync(transport)does theinitializehandshake. It returns a live client with the session established; the server subprocess is already running and waiting.await usingguarantees the transport (and the subprocess) is torn down.ListToolsAsync()is an explicit call. Unlike Python’s implicit-on-entry discovery, .NET makes thetools/liststep visible in your code. Each returnedMcpClientToolimplementsAITooldirectly — no adapter, no wrapper. Cast toAITooland hand the array toAsAIAgent(tools: …)the same way you would for anAIFunctionFactory.Create(...)result.StdioClientTransportwants an absoluteCommandpath. Thedotnet runCWD is the built output directory, not the project root, so relative paths to Python don’t resolve. The chapter usesPYTHON_BIN(env var) with a fallback to the repo’s shared venv — the same mechanism the capstone tests use.
Run it:
cd tutorials/08-mcp-tools/dotnet
dotnet run -- "What's the weather in Paris?"
# Q: What's the weather in Paris?
# A: The weather in Paris is sunny, 18°C.Source: python/main.py. Key lines:
from agent_framework import Agent
from agent_framework._mcp import MCPStdioTool
from agent_framework.openai import OpenAIChatClient
INSTRUCTIONS = (
"You are a helpful assistant. "
"When the user asks about weather in a city, call the get_weather tool. "
"Keep answers to one short sentence."
)
SERVER_SCRIPT = str(pathlib.Path(__file__).resolve().parent / "weather_mcp_server.py")
def build_mcp_tool() -> MCPStdioTool:
"""Spawns the weather MCP server as a subprocess."""
return MCPStdioTool(
name="weather-mcp",
command=sys.executable,
args=[SERVER_SCRIPT],
)
async def run(question: str) -> str:
async with build_mcp_tool() as mcp:
agent = Agent(
_default_client(),
instructions=INSTRUCTIONS,
name="mcp-agent",
tools=[mcp],
)
response = await agent.run(question)
return response.textThree things worth staring at:
MCPStdioToolis an async context manager. Enteringasync with mcpspawns the subprocess, sendsinitialize, sendstools/list, and caches the returned manifest. Exiting closes the transport and terminates the subprocess — no orphans. Never construct anMCPStdioToolwithoutasync with; you’ll leak a child process.tools=[mcp]passes the whole tool bundle, not a single function. MAF sees theMCPStdioToolobject, expands it into the manifest entries it discovered, and advertises each as a tool to the LLM. The LLM doesn’t know any of them are MCP — it sees normal tool definitions.command=sys.executableensures the subprocess uses the same Python interpreter (and therefore the same venv) that’s running the client. If the server needs packages the client doesn’t have, pointcommand=at a different interpreter. This is the mechanism the capstone’sPYTHON_BINenv var toggles.
Run it:
cd tutorials/08-mcp-tools/python
uv sync
uv run python main.py "What's the weather in Paris?"
# Q: What's the weather in Paris?
# A: The weather in Paris is sunny, 18°C.The handshake on the wire#
Both clients do the same thing in three JSON-RPC round trips:
initialize—{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{…}}}. Server responds with its capabilities and aserverInfoblock. This is where protocol version negotiation happens.tools/list—{"jsonrpc":"2.0","id":2,"method":"tools/list"}. Server returns{"tools":[{"name":"get_weather","description":"…","inputSchema":{…}}]}. MAF feeds that manifest into the agent’s tool list — the LLM sees it at the top of every turn like any other tool.tools/call—{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"Paris"}}}. Server dispatches toget_weather("Paris"), returns{"content":[{"type":"text","text":"Sunny, 18°C."}]}. MAF unwraps the content and feeds it back into the tool-calling loop as the tool result.
You never write those frames yourself. Both client libraries serialize and deserialize them transparently. But knowing the wire protocol makes debugging much easier — set MCP_LOG_LEVEL=debug on either side and you’ll see every frame flow through stdin/stdout.
Side-by-side — Python vs .NET#
| Aspect | Python | .NET |
|---|---|---|
| Server library | mcp.server.fastmcp.FastMCP | ModelContextProtocol.Server.McpServer (not shown; this chapter’s server is Python) |
| Client library | agent_framework._mcp.MCPStdioTool | ModelContextProtocol.Client.McpClient + StdioClientTransport |
| Discovery | Implicit on async with entry | Explicit await mcpClient.ListToolsAsync() |
| Lifecycle | async with mcp — handshake on enter, teardown on exit | await using var mcpClient — teardown on dispose |
| Tool shape at the agent | MCPStdioTool passed as a single item in tools=[…]; MAF expands internally | McpClientTool[] cast to AITool[] and spread into tools: |
| Subprocess command | sys.executable typical | Absolute path required (PYTHON_BIN env var convention in this chapter) |
| Approval gate | MCPStdioTool(approval_mode="always_require") | McpClientOptions with per-tool approval callbacks |
| Debug logging | MCP_LOG_LEVEL=debug env var | MCP_LOG_LEVEL=debug env var |
| Package source | mcp (PyPI) + agent-framework | ModelContextProtocol (NuGet, preview) |
Both speak the same MCP spec, so either client works against either-language servers. The chapter proves it: one Python server, two clients in two languages, identical behaviour.
Gotchas#
async with/await usingis non-optional. The subprocess is a real OS child process. Forget the context manager and you leak one on every run — fine for a demo, a production incident on a long-running service. Tests that build a client per test and never dispose it will run out of file descriptors in minutes.command=needs an absolute path in .NET.dotnet runexecutes frombin/Debug/net10.0/, not the project root, so"python"on PATH and relative paths fail unpredictably across machines. The chapter resolves the repo root by walking up to.env.example. The capstone’s agent factory does the same.- The subprocess needs the right environment. The Python MCP server imports
mcp.server.fastmcp— if the interpreter you launch doesn’t havemcpinstalled, the subprocess crashes immediately and the client sees a broken pipe. Use the same interpreter that installed FastMCP (sys.executablein Python,PYTHON_BINpointed at the venv in .NET). - Tool names can collide across multiple MCP servers. If you connect
MCPStdioTool(name="weather-mcp")andMCPStdioTool(name="forecast-mcp")and both exposeget_weather, the LLM sees two identical tool names and the behaviour is undefined. Passtool_name_prefix="weather_"on the Python client to namespace the manifest. .NET offers a similar option viaMcpClientOptions. - HTTP transports are stateful in one direction only. Stdio pairs a client with exactly one server for the lifetime of the
async withblock. HTTP/SSE lets many clients share one server — which means the server owns no per-client state between calls. If your MCP tool needs state across calls, store it in the args or in a backend, not in a module global. approval_modeis the knob for sensitive tools. Set it to"always_require"and everytools/callpauses for a humanapprove/rejectdecision before the tool body runs. The user-facing half (UI, resume path) is Chapter 17 — Human in the Loop.- Azure OpenAI API version still matters. MCP doesn’t change the tool-call wire format the LLM produces; older Azure API versions silently drop tool-call parts regardless of whether the tool is inline or MCP-sourced. Use
2024-10-21or newer. - Debug by tailing stderr. FastMCP sends its own logs to stderr, not stdout (stdout is reserved for JSON-RPC). If the subprocess is crashing, run the server standalone once and watch stderr — the Python traceback will tell you what’s wrong before you start chasing the client.
Tests#
# Python — 3 unit tests + 2 real-LLM integration
source agents/.venv/bin/activate
python -m pytest tutorials/08-mcp-tools/python/tests/ -v
# .NET — 3 real-LLM integration (uses the Python server over stdio)
cd tutorials/08-mcp-tools/dotnet
dotnet test tests/McpTools.Tests.csprojAll 8 tests green. The interesting .NET assertion is Mcp_Client_Discovers_Weather_Tool:
await using var mcp = await Program.BuildMcpClientAsync();
var tools = await mcp.ListToolsAsync();
tools.Select(t => t.Name).Should().Contain("get_weather");That test only does the handshake — initialize and tools/list — without involving the LLM. It proves the subprocess spawns, the handshake succeeds, and the manifest round-trips. When the Python server is broken, this is the test that fails first and tells you the culprit is below the agent layer.
How this shows up in the capstone#
- Real MCP server —
agents/python/mcp/inventory_server.pyis a working inventory + shipping MCP server. It advertises three tools (check_stock,get_warehouses,estimate_shipping) via a/.well-known/mcp.jsonmanifest at lines 42–65, dispatches invocations throughPOST /mcp/tools/{tool_name}at lines 88–108, and hits Postgres viaasyncpgfor real data. The capstone currently consumes it over a direct HTTP handshake; Phase 7 of the refactor wires it in via MAF’s MCP client so tools are auto-discovered the same way this chapter does. - .NET mirror —
agents/dotnet/src/ECommerceAgents.Mcp/Program.csandMcpEndpoints.csport the Python inventory server 1:1 to ASP.NET Core Minimal APIs, same three tools, same manifest shape. Proof that the MCP server-side contract is a JSON shape, not a language choice: the .NET port doesn’t change a single field, just replaces FastAPI routing withMapGet/MapPost. - MCP vs A2A in the capstone — the orchestrator routes via A2A (stateful specialists that own their own sessions and conversation history). Specialists internally call MCP tools (stateless, cross-language, schema-discovered). Phase 7 finalises that split so every specialist can mount any MCP server at startup without code changes.
- Approval gates in the capstone — destructive MCP tools (initiate refund, cancel order) flow through the same
FunctionMiddlewareapproval gate described in Chapter 06 — Middleware. MCP tools are ordinaryAITools once MAF has discovered them, so the approval middleware doesn’t care whether a tool came from@toolor fromMCPStdioTool.
Production MCP usage layers auth (the server validates an Authorization header), rate limits (per-client, per-tool), tool allowlists (the client only exposes a subset of discovered tools to the LLM), and telemetry (each tools/call is an OTel span) on top of this chapter’s 12-line server and 20-line client. Nothing in the protocol shape changes.
Further reading#
- Canonical README: tutorials/08-mcp-tools
- Agents — Local MCP Tools
- Agents — Hosted MCP Tools
- Model Context Protocol specification
- FastMCP (Python server library)
- ModelContextProtocol (.NET preview)
What’s next#
- Next chapter: Chapter 09 — Workflow Executors and Edges — MCP tools remain useful there too; workflow executors can invoke them exactly the way agents do because the
AIToolabstraction is shared between both surfaces. - Full source:
python/·dotnet/

