MCP Deep Dive, Part 4: Build an MCP Client That Connects to Any Tool (and Any Model)
The server exposes tools; the client drives them. Here's a production MCP client — discovery, the agent loop, multi-server routing, and reconnects.
- Author
- Randhir Jassal
- Published
- Reading time
- 15 min read
- Views
- 25 views
Here is 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. Auth (Part 6), security (Part 8), and streaming (Part 9) get their own parts; here we build the client itself.
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 (3 servers, ~a dozen tools); namespace on collision.
- Reconnect + per-call timeout + retry so a server redeploy doesn't kill an agent run.
- Handle
tools/list_changedto re-discover new capabilities mid-session. - Advertise sampling and the server can call back to your model — route it through the gateway (budget/redact/audit).
- One MCP client replaced the 14 bespoke adapters from Part 1.
- Agentic p95 4.2s → 1.8s — the loop plans short paths over discovered tools.
- Tool-call error rate 0.8% — retries absorb transient transport failures.
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.
The running example: the Mattrx host
In Mattrx, the host is the Insights/Help runtime (plus a Python FastAPI AI service). Each host holds one MCP client per server — connections to mattrx-analytics, mattrx-reports, and mattrx-admin. The client discovers ~a dozen tools across those three, exposes them to whatever model is driving, executes the model's requested calls, and survives the servers deploying underneath it. Let's build that, concern by concern.
The client, in one picture
HOST (Mattrx Insights runtime / Python AI service)
+-----------------------------------------------------------------+
| Agent loop: model <-> tool calls |
| | |
| McpClientManager (tool-name -> owning client) |
| | | | |
| Client A Client B Client C |
+-----------------------------------------------------------------+
| | |
v v v
mattrx-analytics mattrx-reports mattrx-admin
get_campaign_kpis create_report set_flag
query_events report_status export_audience
1. Connect and initialize
Before
The bespoke agent assumed a server and hardcoded what it could do — the N×M antipattern from Part 1.
After
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 (Part 2).
if (client.ServerCapabilities.Supports(ServerCapability.Resources))
await PreloadResourcesAsync(client, ct);
Diagnostic: 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 (section 6).
Mattrx metric: one client library, three connections, replaced the 14 bespoke integrations from Part 1 — the connection code is now identical for every server.
2. Discover tools and translate them for the model
Before
The agent shipped with a hand-maintained list of tool schemas that drifted out of sync with the servers.
After
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
Diagnostic: 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.
Mattrx metric: because tools are discovered and translated at runtime, shipping a new server tool reaches every agent on its next session — no client redeploy, part of the 3-days-to-2-hours onboarding win from Part 1.
3. The agent loop
Before
A one-shot call: ask the model, return the text. It could never actually use a tool.
After
The loop that makes it an agent. 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
}
Diagnostic: 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.
Mattrx metric: a disciplined loop with a step cap is part of why agentic p95 dropped 4.2s → 1.8s — the model plans over the discovered tools and finishes in two or three turns instead of thrashing.
4. Route across many servers
Before
One client to one server, or a tangle of if (tool == "x") serverA else ... that broke every time a server changed.
After
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) => /* ... */;
}
Diagnostic: 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.
Mattrx metric: one manager fronts all three servers, so the agent sees a single flat toolset while each call is dispatched to the correct, independently-deployed server.
5. Resilience — servers come and go
Before
A dropped SSE connection threw, and the whole agent run died — the agent was as reliable as the flakiest server.
After
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
}
}
}
// The server can announce new capabilities mid-session; re-discover instead of reconnecting.
client.OnToolsChanged += async () => await RefreshToolsAsync(client, ct);
Diagnostic: 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.
Mattrx metric: transport retries absorb transient failures during server redeploys, keeping the tool-call error rate at 0.8% — a server restarting mid-run is a reconnect, not a failed agent task.
6. Client-side capabilities: sampling and roots
Before
The client advertised nothing back, so servers couldn't leverage the host's model or filesystem boundaries.
After
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.
// Governed exactly like any Mattrx model call (from the AI-Native / Security posts).
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
};
Diagnostic: 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. The rule from Part 1 holds: every model call goes through one governed gateway, even the ones a server initiates. (roots — exposing filesystem/URI boundaries the server may operate within — follows the same "only advertise what you'll honor" discipline.)
Mattrx metric: sampling requests are budgeted and audited exactly like first-party calls, so a connected server can leverage our model without becoming an ungoverned cost or data-leak path.
The agent loop, end to end
User goal: "Why did campaign 4821's CTR drop last week?"
|
v
Client discovers tools from all 3 servers (tools/list) -> translate to model format
|
v
+--> model.chat(messages, tools)
| |
| +-- no tool calls -> FINAL ANSWER --> return
| |
| +-- tool calls [get_campaign_kpis, query_events]
| |
| v
| manager routes each to its owning server -> execute (parallel, timeout, retry)
| |
| v
| feed tool results back into messages
|_______________|
(repeat until the model stops asking, or MaxTurns)
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 |
Client checklist
- Connect +
initialize; declare only the client capabilities you'll honor. - Discover tools/resources/prompts; never hardcode them.
- Translate MCP JSON Schema straight into the model's tool format.
- Run the agent loop with a turn cap and a token budget.
- Front many servers with a manager that routes by tool name; namespace on collision.
- Timeout every tool call; reconnect + retry on transport failure.
- Handle
tools/list_changedto re-discover mid-session. - Route sampling (and any client capability) through the governed gateway.
- Validate tool results — a buggy or hostile server can return anything (Part 8).
The honest stuff: when the full client is overkill
- One server, one model. The SDK's high-level client is enough; you don't need a manager or router for a single connection.
- Hardcoding tools "for speed." You throw away discovery, which is the whole point. Let the server advertise; translate at runtime.
- Skipping the per-call timeout. A hung server hangs your agent. Bound every call, always.
- Silent name collisions. Two servers, same tool name, and you route to the wrong one. Namespace by server.
- Advertising capabilities you won't honor. Advertise
samplingbut ignore the callback and you break servers that rely on it. Only advertise what you implement. - An unbounded loop. A stubborn model with no turn cap spins forever and burns budget. Cap turns and tokens.
- Trusting tool results blindly. A compromised or buggy server can return anything into your model's context. Keep the security layer (Part 8) — the client is not a trust boundary you can drop.
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. Get those right and the payoff is the one from Part 1 made real: any model can drive any tool, and adding a tool is a server change, not a client one.
Three habits that keep a client production-grade:
- Discover, never hardcode. The server is the source of truth for its own tools; a hardcoded list is a bug waiting to happen.
- Bound every call and cap every loop. Timeouts stop hangs; turn caps stop spins. An agent without limits is an outage without a cause.
- Route by owner, namespace on collision. With many servers, the client is a router first — send each call to the right place, every time.
In Part 5 we go deeper on the tools themselves: designing custom MCP tools an agent can actually use well — naming, granularity, schemas, and the difference between a tool that helps and one that confuses.
Continue the series — MCP Deep Dive
- Why Model Context Protocol Kills Integration Glue Code for Good
- Inside the MCP Architecture: Hosts, Clients, and Servers
- Build a Production-Grade MCP Server From Scratch
- Build an MCP Client That Connects to Any Tool (and Any Model) (you are here)
- Custom MCP Tools Your AI Agents Can Actually Trust
- MCP Authentication With OAuth and Entra ID, Done Right
- Scoped Authorization for MCP Tools (Least Privilege for Agents)
- Securing MCP Against Prompt Injection and Tool Abuse
- Streaming and Long-Running Tools Over MCP
- Debugging and Observability for MCP in Production
- Rolling MCP Out Across the Enterprise
- Building MCP Servers in C# and .NET 9
- Hosting MCP on Azure at Real Scale
- Wiring MCP Into OpenAI and Agent Frameworks
- Running MCP in Production — Lessons From Mattrx
Further reading
- MCP Deep Dive, Part 3: Build a Production-Grade MCP Server From Scratch
- MCP Deep Dive, Part 5: Custom MCP Tools Your AI Agents Can Actually Trust
- MCP Deep Dive, Part 14: Wiring MCP Into OpenAI and Agent Frameworks
Building an MCP client or agent loop and want a second pair of eyes on the routing or resilience? I'm always happy to compare notes — reach me at randhir.jassal@gmail.com.
Get the next issue
A short, curated email with the newest posts and questions.