Here's the thing nobody tells you when they demo MCP: the model never actually calls a tool. It asks for one. The MCP client is the runtime that turns those requests into real calls against real servers and hands the results back — and building that loop well is what separates "it worked in the notebook" from "it drives production agents."
This is Part 4 of a 15-part deep dive on Model Context Protocol (MCP). Part 3 built the mattrx-analytics server; now we build its consumer — the client inside the Mattrx host that discovers tools, runs the agent loop, and routes calls across all three servers.
TL;DR
| Concern | Bespoke agent (before) | MCP client (after) |
|---|---|---|
| Tool list | hardcoded, drifts | discovered at runtime |
| Tool schema | hand-maintained | MCP JSON Schema → model format |
| Loop | one-shot | model asks → client executes → repeat |
| Many backends | N adapters / if-else | one manager routes by tool name |
| Connection drop | fatal | reconnect + retry + timeout |
| Server callbacks | ignored | sampling / roots handled (governed) |
- The client connects, runs
initialize, and discovers tools/resources/prompts — no hardcoded lists. - MCP tool definitions are JSON Schema → a thin translation into the model's tool-calling format.
- The agent loop is the client: the model requests tool calls, the client executes them and feeds results back until done.
- One client manager routes each call to its owning server; namespace on collision.
- Reconnect + per-call timeout + retry so a server redeploy doesn't kill an agent run.
The one mental shift: the model never calls a tool — it asks for one. The client is the runtime that turns those requests into real calls, routes them to the right server, and hands the results back. Build the loop, the router, and the reconnect, and any model can drive any tool.
1. Connect and initialize
Create a client, pick a transport, and run the initialize handshake. The client declares its own capabilities (what the server may call back for), and reads the server's.
// Connect over Streamable HTTP + SSE (prod) or stdio (dev). Bearer token -> Part 6.
var client = await McpClientFactory.CreateAsync(
new HttpClientTransport(new()
{
Endpoint = new Uri("https://mcp.mattrx.internal/analytics/mcp"),
}),
new McpClientOptions
{
ClientInfo = new() { Name = "mattrx-insights", Version = "3.1.0" },
Capabilities = new() { Sampling = new() }, // we'll answer server sampling requests (section 6)
},
ct);
// Handshake complete; branch on what the server actually advertised.
if (client.ServerCapabilities.Supports(ServerCapability.Resources))
await PreloadResourcesAsync(client, ct);
The client half of the handshake is where you declare what the server is allowed to ask you for. Advertise sampling and a tool can call back into your model — so only advertise what you're prepared to honor.
2. Discover tools and translate them for the model
Discover tools via tools/list and translate the MCP schema — which is already JSON Schema — straight into the model's tool-calling format. No hand-mapping.
// Discover, then translate MCP tools into the model's tool format. Done once per session.
var mcpTools = await client.ListToolsAsync(ct);
var modelTools = mcpTools.Select(t => new ChatTool(
name: t.Name,
description: t.Description,
parameters: t.InputSchema)).ToList(); // MCP already gives JSON Schema — the model wants exactly that
This is the quiet reason MCP composes with every model. MCP tool definitions are JSON Schema, and every model's tool-calling API consumes JSON Schema. The client's job is a thin, mechanical translation — not a duplicated, drifting registry.
3. The agent loop
The model returns either a final answer or a set of tool-call requests; the client executes them, feeds the results back, and repeats until the model stops asking. Cap the turns so it can't spin forever.
public async Task<string> RunAsync(string goal, CancellationToken ct)
{
var messages = new List<ChatMessage> { ChatMessage.User(goal) };
var tools = await DiscoverToolsAsync(ct);
for (var turn = 0; turn < MaxTurns; turn++) // bound the loop
{
var reply = await model.ChatAsync(messages, tools, ct);
messages.Add(reply);
if (reply.ToolCalls.Count == 0)
return reply.Text; // model is done -> final answer
// Execute every requested call (in parallel) and feed the results back.
var results = await Task.WhenAll(reply.ToolCalls.Select(tc => manager.InvokeAsync(tc, ct)));
messages.AddRange(results.Select(ChatMessage.ToolResult));
}
return "Reached the step limit before finishing."; // safety valve
}
The loop is the client. The model has no hands — it emits tool-call requests as structured output; the client is what actually reaches out, executes against the right server, and returns the result for the model to reason over. A disciplined loop with a step cap is part of why agentic p95 dropped 4.2s → 1.8s.
4. Route across many servers
A client manager holds all the connections and routes each tool call to the server that owns it — a name→client map built once at startup. On a name collision, namespace by server.
public sealed class McpClientManager(IReadOnlyList<IMcpClient> clients)
{
// tool-name -> the client that owns it, built from each server's discovered tools.
private readonly Dictionary<string, IMcpClient> _routes = BuildRoutes(clients);
public Task<ToolResult> InvokeAsync(ToolCall call, CancellationToken ct)
{
if (!_routes.TryGetValue(call.Name, out var client))
return Task.FromResult(ToolResult.Error($"unknown tool '{call.Name}'"));
return client.CallToolAsync(call.Name, call.Arguments, ct); // to the owning server
}
// If two servers expose the same tool name, prefix with the server: "analytics.get_campaign_kpis".
private static Dictionary<string, IMcpClient> BuildRoutes(IReadOnlyList<IMcpClient> clients) => /* ... */;
}
With three servers exposing a dozen tools, the client is fundamentally a router. Get the name→owner map wrong and a call for create_report lands on the analytics server; namespace on collision so it never silently routes to the wrong place.
5. Resilience — servers come and go
Bound every call with a timeout, reconnect with backoff on transport failure, retry, and re-discover on tools/list_changed.
public async Task<ToolResult> InvokeResilientAsync(ToolCall call, CancellationToken ct)
{
for (var attempt = 0; ; attempt++)
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30)); // per-call timeout
return await _routes[call.Name].CallToolAsync(call.Name, call.Arguments, cts.Token);
}
catch (McpTransportException) when (attempt < MaxRetries)
{
await ReconnectAsync(call.Name, ct); // server bounced -> reconnect
await Task.Delay(Backoff(attempt), ct); // then retry
}
}
}
Servers deploy, scale, and restart — that's the point of making them independent. A client that treats a dropped connection as fatal throws that benefit away. Reconnect + retry makes an agent run survive a rolling deploy underneath it, keeping the tool-call error rate at 0.8%.
6. Client-side capabilities: sampling and roots
If you advertise sampling, a server (a tool) can call back to ask your model to do sub-reasoning. That's powerful — and it means the server can spend your tokens — so route it through the same AI gateway (budgets, redaction, audit) as every other model call.
// We advertised `sampling`, so the SERVER may ask US to run a completion.
client.OnSamplingRequest = async (request, ct) =>
{
var result = await gateway.SendAsync(new AiGatewayContext
{
TenantId = principal.TenantId, Feature = "mcp-sampling",
TokenBudget = budgets.PerSampling,
}, request.ToModelRequest(), ct);
return result.ToSamplingResponse(); // the server gets a governed completion
};
Sampling flips the arrow — a tool can ask your model to think. Never wire it to a raw model call; a server you connect to could quietly burn your token budget. Every model call goes through one governed gateway, even the ones a server initiates.
The numbers, in one place
| Metric | Bespoke agent (before) | MCP client (after) |
|---|---|---|
| Integrations | 14 bespoke adapters | 1 client, 3 connections |
| Tool list | hardcoded, drifts | discovered per session |
| Tool onboarding | ~3 days (redeploy agents) | ~2 hours (auto-discovered) |
| Agentic p95 | 4.2s | 1.8s |
| Tool-call error rate | 6% | 0.8% |
| Server redeploy | kills the agent run | reconnect + retry |
| Server model callbacks | impossible | sampling, governed |
The model to carry forward
The model asks; the client acts. An MCP client is three things — a discoverer (it learns what tools exist), a loop (it turns tool-call requests into results and back), and a router (it sends each call to the server that owns it) — plus the resilience to survive servers that come and go.
- Discover, never hardcode. The server is the source of truth for its own tools.
- Bound every call and cap every loop. Timeouts stop hangs; turn caps stop spins.
- Route by owner, namespace on collision. With many servers, the client is a router first.
Originally published on PrepStack. Building an MCP client or agent loop and want a second pair of eyes on the routing or resilience? Reach me at randhir.jassal[at]gmail.com.
Top comments (0)