DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on • Originally published at prepstack.co.in

MCP Deep Dive, Part 4: Build an MCP Client That Connects to Any Tool (and Any Model)

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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) => /* ... */;
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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)