Nitin Kumar SinghSolutions Architect

Type to search. to move, Enter to open.

    move open esc close
    Deep DiveAI EngineeringPart 10 of 29

    MAF v1 — MCP Tools (Python + .NET)

    Stand up a Python MCP server and consume it from Python and .NET MAF agents, including discovery and AITool wiring from one server.

    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: it 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

    The concept

    An MCP deployment has three moving parts:

    1. 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/sdk is the TypeScript one; ModelContextProtocol is Microsoft’s .NET one.
    2. 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.
    3. Client: the consuming side. MAF wraps the protocol so you never touch JSON-RPC directly: in Python MCPStdioTool wraps the client; in .NET McpClient + StdioClientTransport does the same.

    Every MCP session opens with a short handshake. The client sends initialize, the server returns its capabilities, and then tools/list fetches the manifest of tool names, descriptions, and parameter schemas that the agent’s LLM will see as tool definitions.

    From there each tools/call carries a name and arguments; the server executes and returns a result, and MAF feeds it back into the tool-calling loop just like an inline @tool. The actual JSON-RPC frames for all three steps are in the wire walkthrough below.

    MAF clientsJSON-RPC over stdioMCP serverSame three calls, either languageThen the model callSubprocessPython agentMCPStdioTool.NET agentMcpClient + StdioClientTransportLLMsees only the manifestFinal answer1. initializehandshake2. tools/listreturns the tool manifest3. tools/callreturns the resultFastMCPweather_mcp_server.pyget_weatherthe actual function
    Same server, two clients. The protocol sits between them — initializetools/listtools/call — and Python and .NET walk the exact same three steps. The model 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, since 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 an id. 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. Import from mcp.server.fastmcp import FastMCP, decorate Python functions with @server.tool(), and server.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 the ModelContextProtocol NuGet package. await using disposable.
    • StdioClientTransport (.NET): transport adapter for McpClient. Spawns a subprocess given a command and arguments.
    • Tool discovery: the tools/list step 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. Set approval_mode="always_require" on MCPStdioTool and 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.py1.

    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 about this server:

    • 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 publishes get_weather in the tools/list manifest.
    • 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.cs2. 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 to notice on the .NET side:

    • McpClient.CreateAsync(transport) does the initialize handshake. It returns a live client with the session established; the server subprocess is already running and waiting. await using guarantees the transport (and the subprocess) is torn down.
    • ListToolsAsync() is an explicit call. Unlike Python’s implicit-on-entry discovery, .NET makes the tools/list step visible in your code. Each returned McpClientTool implements AITool directly, with no adapter and no wrapper. Cast to AITool and hand the array to AsAIAgent(tools: …) the same way you would for an AIFunctionFactory.Create(...) result.
    • StdioClientTransport wants an absolute Command path. The dotnet run CWD is the built output directory, not the project root, so relative paths to Python don’t resolve. The chapter uses PYTHON_BIN (env var) with a fallback to the repo’s shared venv, the same mechanism the capstone tests use.

    Run it:

    Terminal window
    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.py3. 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.text

    Three things to notice on the Python side:

    • MCPStdioTool is an async context manager. Entering async with mcp spawns the subprocess, sends initialize, sends tools/list, and caches the returned manifest. Exiting closes the transport and terminates the subprocess, leaving no orphans. Never construct an MCPStdioTool without async with; you’ll leak a child process.
    • tools=[mcp] passes the whole tool bundle, not a single function. MAF sees the MCPStdioTool object, 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.executable makes the subprocess use the same Python interpreter (and therefore the same venv) that’s running the client. If the server needs packages the client doesn’t have, point command= at a different interpreter. This is the mechanism the capstone’s PYTHON_BIN env var toggles.

    Run it:

    Terminal window
    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:

    1. initialize: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{…}}}. Server responds with its capabilities and a serverInfo block. This is where protocol version negotiation happens.
    2. 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, and the LLM sees it at the top of every turn like any other tool.
    3. tools/call: {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"Paris"}}}. Server dispatches to get_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

    AspectPython.NET
    Server librarymcp.server.fastmcp.FastMCPModelContextProtocol.Server.McpServer (not shown; this chapter’s server is Python)
    Client libraryagent_framework._mcp.MCPStdioToolModelContextProtocol.Client.McpClient + StdioClientTransport
    DiscoveryImplicit on async with entryExplicit await mcpClient.ListToolsAsync()
    Lifecycleasync with mcp (handshake on enter, teardown on exit)await using var mcpClient (teardown on dispose)
    Tool shape at the agentMCPStdioTool passed as a single item in tools=[…]; MAF expands internallyMcpClientTool[] cast to AITool[] and spread into tools:
    Subprocess commandsys.executable typicalAbsolute path required (PYTHON_BIN env var convention in this chapter)
    Approval gateMCPStdioTool(approval_mode="always_require")McpClientOptions with per-tool approval callbacks
    Debug loggingMCP_LOG_LEVEL=debug env varMCP_LOG_LEVEL=debug env var
    Package sourcemcp (PyPI) + agent-frameworkModelContextProtocol (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

    • command= needs an absolute path in .NET. dotnet run executes from bin/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 have mcp installed, the subprocess crashes immediately and the client sees a broken pipe. Use the same interpreter that installed FastMCP (sys.executable in Python, PYTHON_BIN pointed at the venv in .NET).
    • Tool names can collide across multiple MCP servers. If you connect MCPStdioTool(name="weather-mcp") and MCPStdioTool(name="forecast-mcp") and both expose get_weather, the LLM sees two identical tool names and the behaviour is undefined. Pass tool_name_prefix="weather_" on the Python client to namespace the manifest. .NET offers a similar option via McpClientOptions.
    • HTTP transports are stateful in one direction only. Stdio pairs a client with exactly one server for the lifetime of the async with block. 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_mode is the knob for sensitive tools. Set it to "always_require" and every tools/call pauses for a human approve/reject decision 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-21 or 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

    Terminal window
    # 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.csproj

    All 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: packages/mcp-inventory is a working inventory + shipping MCP server, and a standalone publishable package in a uv workspace rather than a module inside the app. It serves three tools (check_stock, get_warehouses, estimate_shipping) over the MCP streamable HTTP transport via FastMCP, mounted at /mcp, and hits Postgres through asyncpg for real data. A sibling package, packages/mcp-product, does the same for the product domain.
    • .NET mirror: agents/dotnet/src/ECommerceAgents.Mcp/Program.cs and McpEndpoints.cs port 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 with MapGet/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). That split is finished: MCP_ENABLED=true makes product-discovery and inventory-fulfillment swap their direct-asyncpg @tool set for MCPStreamableHTTPTool with no behaviour change, so the same agent runs either way.
    • Approval gates in the capstone: destructive MCP tools (initiate refund, cancel order) flow through the same FunctionMiddleware approval gate described in Chapter 06: Middleware. MCP tools are ordinary AITools once MAF has discovered them, so the approval middleware doesn’t care whether a tool came from @tool or from MCPStdioTool.

    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

    What’s next

    References

    1. python/weather_mcp_server.py — github.com

    2. dotnet/Program.cs — github.com

    3. python/main.py — github.com

    4. Model Context Protocol specification — modelcontextprotocol.io

    5. ModelContextProtocol (.NET preview) — nuget.org

    Comments

    Comments are GitHub discussions. Sign in with GitHub to post; reactions need no account.