September 09, 2025

Part1 - Building Agentic Systems with MCP in .NET

Photo Credit: ChatGPT & Dall-E by OpenAI

Part 3 — Building a .NET MCP Client

In Part 1, you built a simple MCP server in .NET and explored it with MCP Inspector. In Part 2, you saw how LLMs can interact with MCP tools via runners.

You’ll build a real .NET client that connects to your MCP server over STDIO, discovers tools, reads each tool’s JSON schema, and calls them with structured arguments. We’ll add friendly error handling, timeouts, and a tiny interactive REPL. The same client can connect to any MCP server by changing the command.


Where this fits

From Part 1 (server) and Part 2 (runners), we now go programmatic:

User ↔ MCP .NET Client (this part)
         │
         ▼  STDIO
  Your MCP Server  ──► Tools: hello, convert-currency
         ▲
         └── structured results

Why this matters

  • One client connects to many servers via a standard transport.
  • Dynamic discovery lets you keep server + tool definitions in one place.
  • Schemas enable validation, UI generation, and safer automation.

Prereqs

  • .NET 9 SDK
  • Your Part 1 server (we’ll call it MCPHelloWord) builds and runs.
  • (Windows tip) Prefer running a built DLL or dotnet run --no-build to avoid rebuild noise on stdout when used by other clients.

The official C# SDK includes a ready-made STDIO client and high-level helpers for listing and calling tools. We’ll lean on those. citeturn1view0


Create the client project

# from your repo root
dotnet new console -n McpClientDemo && cd McpClientDemo

# packages
dotnet add package ModelContextProtocol --prerelease
 dotnet add package System.Text.Json --version 9.0.3

The client APIs live in the ModelContextProtocol package; they expose McpClientFactory and StdioClientTransport. citeturn1view0


Minimal client (connect → list tools → call tool)

Create Program.cs:

using System.Text.Json;
using ModelContextProtocol.Client; // McpClientFactory, StdioClientTransport

// 1) Configure how to launch your server (STDIO)
string serverProject = args.FirstOrDefault(a => a.StartsWith("--server="))?.Split('=')[1]
                      ?? "../MCPHelloWord"; // adjust to your path

// Choose one of the following strategies:
// A) Launch via dotnet run (fast dev):
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
    Name = "MCPHelloWord",
    Command = "dotnet",
    Arguments = ["run", "--no-build", "--project", serverProject],
});

// B) Or launch a built DLL (stable for Claude/IDEs):
// var transport = new StdioClientTransport(new StdioClientTransportOptions
// {
//     Name = "MCPHelloWord",
//     Command = "dotnet",
//     Arguments = [System.IO.Path.GetFullPath("../MCPHelloWord/bin/Debug/net9.0/MCPHelloWord.dll")],
// });

// 2) Create and connect the client
var client = await McpClientFactory.CreateAsync(transport);

// 3) Discover tools
var tools = await client.ListToolsAsync();
Console.WriteLine("Available tools:");
foreach (var t in tools)
    Console.WriteLine($"- {t.Name}: {t.Description}");

// 4) Call a tool by name with arguments
Console.WriteLine();
Console.WriteLine("Demo: call hello(name) → 'Polly'\n");
var helloResult = await client.CallToolAsync(
    name: "hello",
    arguments: new Dictionary<string, object?>{ ["name"] = "Polly" },
    cancellationToken: CancellationToken.None);

// Tool results are structured content blocks; print any text blocks
foreach (var block in helloResult.Content.Where(c => c.Type == "text"))
    Console.WriteLine(block.Text);

The exact pattern McpClientFactory.CreateAsync(new StdioClientTransport(...)) and ListToolsAsync / CallToolAsync comes straight from the SDK’s client quickstart. citeturn1view0turn0search1turn0search3

Run it:

# from McpClientDemo/
dotnet run -- --server=../MCPHelloWord

You should see the tool list, then Hello, Polly! (or your server’s equivalent output).


Interactive REPL (discover → validate → call)

Let’s add a lightweight loop so you can:

  • list tools (list),
  • inspect a tool’s input schema (schema <tool>),
  • call a tool with JSON args (call <tool> { ... }).

Replace Program.cs with:

using System.Text.Json;
using System.Text.Json.Nodes;
using ModelContextProtocol.Client;

string serverProject = args.FirstOrDefault(a => a.StartsWith("--server="))?.Split('=')[1]
                      ?? "../MCPHelloWord";

var transport = new StdioClientTransport(new StdioClientTransportOptions
{
    Name = "MCPHelloWord",
    Command = "dotnet",
    Arguments = ["run", "--no-build", "--project", serverProject],
});

var client = await McpClientFactory.CreateAsync(transport);
Console.WriteLine("Connected. Commands: list | schema <tool> | call <tool> <json> | quit\n");

while (true)
{
    Console.Write("> ");
    var line = Console.ReadLine();
    if (string.IsNullOrWhiteSpace(line)) continue;
    if (line.Equals("quit", StringComparison.OrdinalIgnoreCase)) break;

    var parts = line.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
    var cmd = parts[0].ToLowerInvariant();

    try
    {
        switch (cmd)
        {
            case "list":
            {
                var tools = await client.ListToolsAsync();
                foreach (var t in tools)
                    Console.WriteLine($"- {t.Name}: {t.Description}");
                break;
            }
            case "schema":
            {
                if (parts.Length < 2) { Console.WriteLine("usage: schema <tool>"); break; }
                var toolName = parts[1];
                var t = (await client.ListToolsAsync()).FirstOrDefault(x => x.Name == toolName);
                if (t is null) { Console.WriteLine("tool not found"); break; }
                Console.WriteLine(t.InputSchema.ToString());
                break;
            }
            case "call":
            {
                if (parts.Length < 3) { Console.WriteLine("usage: call <tool> <json>"); break; }
                var toolName = parts[1];
                var json = parts[2];

                // (simple) parse args; for complex payloads, support @file later
                var node = JsonNode.Parse(json) as JsonObject ?? new JsonObject();
                var dict = node.ToDictionary(kv => kv.Key, kv => kv.Value?.GetValue<object?>());

                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
                var result = await client.CallToolAsync(toolName, dict, cts.Token);
                foreach (var block in result.Content)
                {
                    if (block.Type == "text") Console.WriteLine(block.Text);
                    else Console.WriteLine($"[{block.Type}] {JsonSerializer.Serialize(block)}");
                }
                break;
            }
            default:
                Console.WriteLine("unknown command");
                break;
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error: {ex.Message}");
    }
}

The SDK returns a Tool model with InputSchema and exposes CallToolAsync for typed calls. This is the idiomatic flow. citeturn1view0

Try it:

> list
- hello: Greets the given name quickly.
- convert-currency: Convert an amount between currencies

> schema convert-currency
{ "type":"object", "properties": { ... }, "required": [ ... ] }

> call hello { "name": "Tiana" }
Hello, Tiana!

Polishing touches

1) Safer JSON input

Support reading arguments from a file to dodge shell quoting:

// call <tool> @args.json
if (json.StartsWith("@"))
{
    var path = json[1..];
    json = File.ReadAllText(path);
}

2) Timeouts & retries

Wrap calls in CancellationTokenSource(TimeSpan) (already shown). You can also add a simple retry for transient failures.

3) Server logging to stderr

Ensure your server logs go to stderr so the stdout stream is reserved for MCP JSON—this avoids confusing clients that parse STDIO. The SDK’s server template shows how to configure that. citeturn1view0

4) Point at any server

Swap the launch command to connect to, for example, the sample “everything” server used in SDK docs:

var transport = new StdioClientTransport(new StdioClientTransportOptions
{
    Name = "Everything",
    Command = "npx",
    Arguments = ["-y", "@modelcontextprotocol/server-everything"],
});

This is the same snippet used in the C# SDK README for client quickstart. citeturn1view0


Troubleshooting

  • Windows quoting: Prefer call <tool> '{ "k":"v" }' in PowerShell or use @args.json.
  • Build spam on STDIO: Use --no-build or run the built DLL so compiler output never pollutes client JSON.
  • Protocol drift: Keep your SDK up to date—the C# SDK tracks the latest MCP spec (initialize, tools, structured output). citeturn0search11

What you learned

  • How to launch an MCP server from a .NET client over STDIO.
  • How to discover tools and call them with structured args.
  • How to build a tiny REPL that respects schemas and timeouts.

Up next — Part 4: Agentic Patterns

We’ll compose single‑responsibility agents that coordinate via MCP, pick the right tool per step, and apply safety/guardrails centrally.

Tech Innovation Hub
Modern Software Architecture

Exploring cutting-edge technologies and architectural patterns that drive innovation in software development.

Projects

© 2025 Tech Innovation Hub. Built with Gatsby and modern web technologies.