Chapter 08 — MCP Tools
Why this chapter
MCP (Model Context Protocol) is the tool-level equivalent of USB: one server exposes capabilities that any MCP-speaking client can consume, regardless of which agent framework wrote the client. You implement a tool once and MAF, LangChain, Claude Desktop, or Cursor can all call it the same way.
This chapter stands up a tiny Python MCP server with a single get_weather tool, then calls it from both a Python MAF agent and a .NET MAF agent — same server, two clients, one wire protocol. It’s deliberately minimal so the protocol mechanics are visible; the capstone app uses the same pattern at production scale (see below).
Prerequisites
- Completed Chapter 07 — Observability
- Repo-root
.envwith one LLM provider configured (OPENAI_API_KEY, or theAZURE_OPENAI_*triplet) uv sync --project tutorials(pulls in themcppackage alongsideagent-framework-core)
The concept
MCP defines a JSON-RPC protocol over a few transports: stdio (spawn a subprocess, talk over its stdin/stdout), HTTP/SSE, and Streamable HTTP. This chapter uses stdio — the simplest transport and the right choice for a tool that only the calling process needs. The client launches the server as a child process, performs the MCP handshake, and lists the tools it exposes.
MAF hides the wire protocol behind a small client object per language:
- Python:
MCPStdioTool(name, command, args=[...])is an async context manager; entering it spawns the subprocess and runs the handshake. Pass the object straight intoAgent(..., tools=[mcp]). - .NET:
McpClient.CreateAsync(StdioClientTransport)connects, thenListToolsAsync()returnsMcpClientTool[]— each one already implementsAITool, so it goes straight into.AsAIAgent(tools: ...).
Both flavors auto-discover tools at connection time. Your agent code never hard-codes the tool list — it just asks the server what it can do.
%%{init: {'theme':'base', 'themeVariables': {
'primaryColor': '#2563eb','primaryTextColor': '#ffffff','primaryBorderColor': '#1e40af',
'lineColor': '#64748b','secondaryColor': '#f59e0b','tertiaryColor': '#10b981',
'background': 'transparent'}}}%%
flowchart LR
accTitle: The concept
classDef core fill:#2563eb,stroke:#1e40af,color:#ffffff
classDef external fill:#f59e0b,stroke:#b45309,color:#000000
classDef success fill:#10b981,stroke:#047857,color:#ffffff
user([User question])
pyAgent[Python Agent]
dotnetAgent[.NET Agent]
server[[weather_mcp_server.py]]
answer([Final answer])
user --> pyAgent
user --> dotnetAgent
pyAgent -- "stdio: list_tools, call_tool" --> server
dotnetAgent -- "stdio: list_tools, call_tool" --> server
server -- "canned forecast" --> pyAgent
server -- "canned forecast" --> dotnetAgent
pyAgent --> answer
dotnetAgent --> answer
class pyAgent core
class dotnetAgent core
class server external
class answer success
The same subprocess-spawned server answers both clients — MCP doesn’t care what language wrote the tool or what language calls it.
Python
Run from the repo root using the shared tutorials/ uv project (one uv sync covers every chapter):
uv sync --project tutorials
uv run --project tutorials python tutorials/08-mcp-tools/python/main.py
The server, python/weather_mcp_server.py, is a dozen lines of FastMCP:
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()
The client, python/main.py, spawns it and hands it to the agent:
def build_mcp_tool() -> MCPStdioTool:
"""Spawns the weather MCP server as a subprocess and exposes its tools to the agent."""
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.text
The async with block spawns the subprocess, performs the MCP handshake, and lists tools; when the block exits, the subprocess is terminated. main.py also supports LLM_PROVIDER=replay for fixture-backed testing — see Tests.
.NET
cd tutorials/08-mcp-tools/dotnet
dotnet run
dotnet/Program.cs reuses the exact same Python server over stdio — no .NET-side MCP server needed:
public static async Task<McpClient> BuildMcpClientAsync()
{
var pythonBin = Environment.GetEnvironmentVariable("PYTHON_BIN")
?? FirstExisting(
Path.Combine(FindRepoRoot(), "tutorials", ".venv", "bin", "python"),
Path.Combine(FindRepoRoot(), "agents", "python", ".venv", "bin", "python"))
?? "python3";
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Name = "weather-mcp",
Command = pythonBin,
Arguments = new[] { ServerScript },
});
return await McpClient.CreateAsync(transport);
}
Run() then lists tools and hands them to the agent directly, since McpClientTool already implements AITool:
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);
ServerScript walks up from the running binary to find tutorials/08-mcp-tools/python/weather_mcp_server.py, and PYTHON_BIN defaults to the tutorials venv (tutorials/.venv, what uv sync --project tutorials creates), falling back to agents/python/.venv — set it explicitly if you keep yours somewhere else.
Side-by-side differences
| Aspect | Python | .NET |
|---|---|---|
| Package | mcp (server) + agent_framework._mcp | ModelContextProtocol + ModelContextProtocol.Core |
| Client class | MCPStdioTool (async context manager) | McpClient + StdioClientTransport |
| Tool discovery | Implicit on entering async with | Explicit ListToolsAsync() call |
| Lifecycle | async with build_mcp_tool() as mcp: | await using var mcpClient = ... |
| Tool-to-agent handoff | tools=[mcp] (whole client object) | tools: tools (array of AITool, one per discovered tool) |
Both honor the same MCP spec, so either client works against either-language servers — the .NET client here talks to the Python server with no adapter code.
Gotchas
- The subprocess needs a Python interpreter that has
mcpinstalled. The .NET test’sPYTHON_BINenv var controls which interpreter gets spawned; it defaults to the tutorials venv, not whateverpython3resolves to on yourPATH. This default was wrong for a long time — it pointed atagents/.venv, a path that stopped existing when the Python packages moved underagents/python/, so all three .NET tests here failed with a bare “No such file or directory”. No CI job built or ran any tutorial .NET project until #20, so nothing noticed. - Long-running MCP servers stay alive between calls. Always scope them with
async with(Python) orawait using(.NET) so a crashed or forgotten test doesn’t leave an orphan subprocess. - Tool name collisions across multiple MCP servers attached to one agent are a real failure mode in this repo, not a hypothetical:
agents/python/product_discovery/agent.pyexplicitly does not register a localget_price_historytool alongside the MCP server’s version of it, because MAF raises “Duplicate tool name” at agent-construction time if it does. tutorials/_shared/maf_bootstrap.pystill carries anagent_framework/__init__.pypatch step for a packaging bug inagent-framework-core==1.0.0(empty__init__.py). Bothtutorials/pyproject.tomlandagents/python/pyproject.tomlnow pinagent-framework-core==1.14.0, where the bug is fixed upstream, sobootstrap()’s patch is a no-op on a current install — it only writes when the installed__init__.pyis empty. It’s left in defensively rather than removed.
Tests
uv sync --project tutorials
uv run --project tutorials pytest tutorials/08-mcp-tools/python/tests -v
cd tutorials/08-mcp-tools/dotnet && dotnet test
Python (python/tests/test_mcp.py) covers, in order:
- Replay integration (
test_replay_calls_mcp_weather_tool) — the MCP server subprocess runs for real, but the LLM call is replayed from a committed fixture (tests/fixtures/replay/), so it needs no credentials and is safe for CI. - Unit tests on the tool function — canned-data lookup and case-insensitivity, exercised directly via
get_weather.fn(FastMCP wraps the function;.fnreaches the original). test_build_mcp_tool_configures_subprocess— asserts theMCPStdioToolis named correctly without spawning it.@pytest.mark.integrationtests (test_real_llm_calls_mcp_weather_tool,test_real_llm_skips_mcp_tool_for_unrelated_question) — these hit a live LLM and are skipped automatically (pytest.mark.skipif) unless real credentials are present in.env; they are not required for a normal test run to pass.
.NET (dotnet/tests/McpToolsTests.cs) mirrors this: three [Trait("Category", "Integration")] facts that each check for LLM credentials at the top and no-op (print [skip]) if absent, rather than using a build-time skip attribute.
How this shows up in the capstone
This isn’t a toy pattern confined to the tutorial — two real MCP servers back the capstone app:
agents/python/packages/mcp-product/src/ecommerce_mcp_product/server.pyandagents/python/packages/mcp-inventory/src/ecommerce_mcp_inventory/server.pyareFastMCPservers exposing product search/details/pricing and inventory/warehouse data over Streamable HTTP (not stdio — these run as standalone services,mcp-producton port 9000 andmcp-inventoryon port 9001, seedocker-compose.yml’smcpprofile).agents/python/product_discovery/agent.py:69builds anMCPStreamableHTTPToolpointed atsettings.MCP_PRODUCT_SERVER_URLwhensettings.MCP_ENABLEDis true, and passes it into the agent’stoolslist alongside locally-defined tools likesemantic_searchandcheck_stock— the same “hand the MCP tool object straight to the agent” pattern this chapter’s Python client uses, just over HTTP instead of stdio and with OAuth 2.1 resource-server auth optionally layered on (settings.MCP_AUTH_ENABLED,shared/oauth/service_client.py).agents/python/inventory_fulfillment/agent.pyfollows the identical MCP-vs-direct-tools branch for the inventory domain.
What’s next
- Next chapter: Chapter 09 — Workflow Executors and Edges
- Full source:
python/·dotnet/ - MAF docs — Hosted MCP Tools
Source: tutorials/08-mcp-tools/README.md — this page is generated from the repository.