September 09, 2025

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 resultsWhy 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-buildto 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. citeturn1view0
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.3The client APIs live in the
ModelContextProtocolpackage; they exposeMcpClientFactoryandStdioClientTransport. citeturn1view0
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(...))andListToolsAsync/CallToolAsynccomes straight from the SDK’s client quickstart. citeturn1view0turn0search1turn0search3
Run it:
# from McpClientDemo/
dotnet run -- --server=../MCPHelloWordYou 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
InputSchemaand exposes CallToolAsync for typed calls. This is the idiomatic flow. citeturn1view0
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. citeturn1view0
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. citeturn1view0
Troubleshooting
- Windows quoting: Prefer
call <tool> '{ "k":"v" }'in PowerShell or use@args.json. - Build spam on STDIO: Use
--no-buildor 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). citeturn0search11
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.