September 03, 2025

Photo Credit: Pollo AI
Building Agentic Systems with MCP in .NET – Why MCP and where LLMs fit(Part 2)
Brief (read me first)
We’ll build a tiny local runner first so you can experience how MCP tools are discovered and executed. Once that clicks, we’ll swap the runner for an LLM—specifically Claude Desktop—so the model can discover and call your tools automatically. The swap changes only the runner; your .NET MCP server stays the same.
Visual cheat‑sheet
Phase A — Hands‑on (local runner)
User
│
▼
Local Runner (heuristic)
│ discovers known tools + fills args
▼
Your MCP Server ──► Tools: hello, convert-currency
▲
│ results
└─────────────── logs/telemetry/guardrails (on the server)Phase B — Swap to LLM (Claude Desktop)
User prompt
│
▼
Claude Desktop (LLM + MCP client)
│ dynamic discovery via MCP, schema-aware tool calls
▼
Your MCP Server ──► Tools: hello, convert-currency
▲
│ structured results
└─────────────── same guardrails stay on the serverWhat’s swapped later? Only the runner (Local Runner ➜ Claude Desktop). Your server + tools + schemas don’t change.
What you already have from Part 1
-
.NET 9 MCP server exposing two tools:
hello(name)convert-currency(from, to, amount, apiKey?)→ callshttps://api.exchangerate.host/convert
-
Tested in MCP Inspector over STDIO.
Phase A — Build a tiny local runner (10–15 min)
We’ll create a minimal console app that:
- chooses a tool (simple heuristic),
- fills arguments,
- (optionally) validates,
- invokes your server to execute that tool.
0) Folder layout
McpDemo/ # your Part 1 server
AgentRunner/ # new console app: the "runner"1) Create the runner project
# from the repo root
dotnet new console -n AgentRunner && cd AgentRunner dotnet add package System.Text.Json --version 9.0.3Keep it simple—no extra deps needed for this hands‑on.
2) Add a temporary direct tool switch to your server
Insert this block at the very top of McpDemo/Program.cs (before hosting). It lets the runner call your tools directly with --tool and --args.
// ── DIRECT TOOL SWITCH (demo‑only) ────────────────────────────────────────────
if (args.Length > 0 && args.Contains("--tool"))
{
var toolIndex = Array.IndexOf(args, "--tool");
var name = args[toolIndex + 1];
var argsIndex = Array.IndexOf(args, "--args");
var json = argsIndex > -1 ? args[argsIndex + 1] : "{}";
var parsed = System.Text.Json.JsonDocument.Parse(json).RootElement;
if (name == "hello")
{
var nm = parsed.TryGetProperty("name", out var v) ? v.GetString() ?? "there" : "there";
Console.WriteLine($"Hello, {nm}!");
return;
}
if (name == "convert-currency")
{
var from = parsed.GetProperty("from").GetString()!;
var to = parsed.GetProperty("to").GetString()!;
var amount = parsed.GetProperty("amount").GetDouble();
var apiKey = parsed.TryGetProperty("apiKey", out var k) ? k.GetString() : null;
var result = await CurrencyConverter.ConvertAsync(from, to, amount, apiKey, CancellationToken.None);
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(result));
return;
}
Console.Error.WriteLine($"Unknown tool: {name}");
Environment.Exit(1);
}
// ─────────────────────────────────────────────────────────────────────────────This is purely for the hands‑on runner. Keep your real guardrails on the server side.
3) Runner code — split into tidy files
Create these files under AgentRunner/.
Tooling.cs
using System.Text.Json;
public record ToolParameter(string Name);
public record ToolDef(string Name, string Description, IReadOnlyDictionary<string, ToolParameter> Parameters);
public static class ToolCatalog
{
public static readonly ToolDef Hello = new(
Name: "hello",
Description: "Greets the given name quickly.",
Parameters: new Dictionary<string, ToolParameter>
{
["name"] = new("name")
}
);
public static readonly ToolDef ConvertCurrency = new(
Name: "convert-currency",
Description: "Convert an amount between currencies using exchangerate.host/convert",
Parameters: new Dictionary<string, ToolParameter>
{
["from"] = new("from"),
["to"] = new("to"),
["amount"] = new("amount"),
["apiKey"] = new("apiKey")
}
);
public static IReadOnlyList<ToolDef> All => new[] { Hello, ConvertCurrency };
}Reasoner.cs
public interface IReasoner
{
Task<string> DecideToolAsync(string userMessage, IReadOnlyList<ToolDef> tools);
Task<Dictionary<string, object?>> FillArgsAsync(string userMessage, ToolDef tool);
}
// Heuristic baseline: good enough for hands‑on learning.
public sealed class HeuristicReasoner : IReasoner
{
public Task<string> DecideToolAsync(string userMessage, IReadOnlyList<ToolDef> tools)
{
var m = userMessage.ToLowerInvariant();
if (m.Contains("convert") || m.Contains(" usd ") || m.Contains(" eur "))
return Task.FromResult("convert-currency");
return Task.FromResult("hello");
}
public Task<Dictionary<string, object?>> FillArgsAsync(string userMessage, ToolDef tool)
{
var args = new Dictionary<string, object?>();
if (tool.Name == "hello")
{
var tokens = userMessage.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var last = tokens.LastOrDefault();
args["name"] = string.IsNullOrWhiteSpace(last) ? "there" : last.Trim('!', '.', ',');
}
else if (tool.Name == "convert-currency")
{
// naive parse like: "convert 25 usd to eur"
var upper = userMessage.ToUpperInvariant();
var toks = upper.Split(' ', StringSplitOptions.RemoveEmptyEntries);
foreach (var t in toks)
if (double.TryParse(t, out var a)) { args["amount"] = a; break; }
args.TryAdd("from", toks.FirstOrDefault(t => t.Length == 3 && t.All(char.IsLetter)) ?? "USD");
args.TryAdd("to", toks.LastOrDefault (t => t.Length == 3 && t.All(char.IsLetter)) ?? "EUR");
}
return Task.FromResult(args);
}
}Guardrails.cs
public static class Guardrails
{
public static bool IsAllowed(string toolName) => toolName is "hello" or "convert-currency";
public static (bool ok, string? error) ValidateArgs(ToolDef tool, Dictionary<string, object?> args)
{
// Keep it simple for the demo. You can add strict JSON Schema validation later.
if (tool.Name == "hello" && (!args.TryGetValue("name", out var n) || string.IsNullOrWhiteSpace(n?.ToString())))
return (false, "Missing name.");
if (tool.Name == "convert-currency")
{
if (!args.TryGetValue("from", out var f) || f is null) return (false, "Missing 'from'.");
if (!args.TryGetValue("to", out var t) || t is null) return (false, "Missing 'to'.");
if (!args.TryGetValue("amount", out var a) || a is null) return (false, "Missing 'amount'.");
}
return (true, null);
}
public static bool NeedsConfirmation(ToolDef tool, Dictionary<string, object?> args)
{
if (tool.Name == "convert-currency" && args.TryGetValue("amount", out var v) &&
double.TryParse(v?.ToString(), out var amt) && amt > 10000) return true;
return false;
}
}ServerInvoker.cs
using System.Diagnostics;
using System.Text.Json;
public static class ServerInvoker
{
public static async Task<string> InvokeAsync(string projectDir, string toolName, object args, CancellationToken ct)
{
var payload = JsonSerializer.Serialize(args);
var psi = new ProcessStartInfo("dotnet")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
// Each Add = one argv, no manual quoting needed
psi.ArgumentList.Add("run");
psi.ArgumentList.Add("--no-build");
psi.ArgumentList.Add("--project");
psi.ArgumentList.Add(projectDir);
psi.ArgumentList.Add("--"); // separates your app args
psi.ArgumentList.Add("--tool");
psi.ArgumentList.Add(toolName);
psi.ArgumentList.Add("--args");
psi.ArgumentList.Add(payload); // raw JSON string, no escapes
Console.WriteLine($"[runner] launching: dotnet {string.Join(" ", psi.ArgumentList)}");
using var p = Process.Start(psi)!;
var stdout = await p.StandardOutput.ReadToEndAsync();
var stderr = await p.StandardError.ReadToEndAsync();
await p.WaitForExitAsync(ct);
if (p.ExitCode != 0) throw new Exception(stderr);
return stdout.Trim();
}
}Program.cs
using System.Text.Json;
var serverProject = args.FirstOrDefault(a => a.StartsWith("--server="))?.Split('=')[1] ?? "./McpDemo";
var reasoner = new HeuristicReasoner();
var tools = ToolCatalog.All;
Console.WriteLine("Try: 'hello Polly' or 'convert 25 usd to eur'. Ctrl+C to exit.");
while (true)
{
var msg = Console.ReadLine();
if (string.IsNullOrWhiteSpace(msg)) continue;
var toolName = await reasoner.DecideToolAsync(msg, tools);
if (!Guardrails.IsAllowed(toolName)) { Console.WriteLine($"Blocked: {toolName}"); continue; }
var tool = tools.First(t => t.Name == toolName);
var argsDict = await reasoner.FillArgsAsync(msg, tool);
var (ok, err) = Guardrails.ValidateArgs(tool, argsDict);
if (!ok) { Console.WriteLine($"Validation failed: {err}"); continue; }
if (Guardrails.NeedsConfirmation(tool, argsDict))
{
Console.WriteLine($"About to call '{tool.Name}' with: {JsonSerializer.Serialize(argsDict)}. Proceed? (y/N)");
if (!string.Equals(Console.ReadLine(), "y", StringComparison.OrdinalIgnoreCase))
{ Console.WriteLine("Cancelled."); continue; }
}
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
var response = await ServerInvoker.InvokeAsync(serverProject, tool.Name, argsDict, cts.Token);
Console.WriteLine($"→ {response}");
}
catch (Exception ex)
{
Console.WriteLine($"Execution failed: {ex.Message}");
}
}4) Run it
# terminal 1 — your MCP server (MCPHelloWorld)
# from repo root
dotnet run --project ./MCPHelloWorld
# terminal 2 — the runner
# from repo root
dotnet run --project ./AgentRunnerTry from the runner:
hello Tianaconvert 25 usd to eur
You’ve now felt the loop: decide → fill args → execute → structured result.
Sample runner output
╭─ 🏠 MCP | RAM: 26/31GB ⌛ 10.041s ⏰ 00:17:42
╰─ dotnet run --project ./AgentRunner/
Restore complete (0.3s)
You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy
AgentRunner succeeded (0.1s) → AgentRunner\bin\Debug\net9\AgentRunner.dll
Build succeeded in 0.8s
Try: 'hello Polly' or 'convert 25 usd to eur'. Ctrl+C to exit.
convert 25 usd to eur
[runner] launching: dotnet run --no-build --project ./MCPHelloWord -- --tool convert-currency --args {"amount":25,"from":"USD","to":"EUR"}
→ "25 USD = 21.4385 EUR (rate 0.857540 @ 2025-09-03 23:18:08Z)"
25 usd to eur
[runner] launching: dotnet run --no-build --project ./MCPHelloWord -- --tool convert-currency --args {"amount":25,"from":"USD","to":"EUR"}
→ "25 USD = 21.4403 EUR (rate 0.857610 @ 2025-09-03 23:33:04Z)"
please convert 25 usd to eur
[runner] launching: dotnet run --no-build --project ./MCPHelloWord -- --tool convert-currency --args {"amount":25,"from":"USD","to":"EUR"}
→ "25 USD = 21.4401 EUR (rate 0.857603 @ 2025-09-03 23:34:04Z)"
25 usd to inr
[runner] launching: dotnet run --no-build --project ./MCPHelloWord -- --tool convert-currency --args {"amount":25,"from":"USD","to":"INR"}
→ "25 USD = 2201.8313 INR (rate 88.073250 @ 2025-09-03 23:34:04Z)"
hello Polly
[runner] launching: dotnet run --no-build --project ./MCPHelloWord -- --tool hello --args {"name":"Polly"}
→ Hello, Polly!
hello Tiana
[runner] launching: dotnet run --no-build --project ./MCPHelloWord -- --tool hello --args {"name":"Tiana"}
→ Hello, Tiana!
say hello to TianaPhase B — Swap to Claude Desktop as the runner
Claude Desktop is an MCP‑aware client: it starts your local MCP server over STDIO, discovers its tools, and calls them with typed arguments during chat.
1) Open Claude Desktop config
In Claude Desktop: Settings → Developer → Edit config. This opens (or creates) claude_desktop_config.json in your profile directory.
2) Enable Developer Mode
Make sure Developer Mode is enabled in Claude Desktop settings.
3) Add your server under mcpServers
Use an absolute path to your server project and (optionally) pass secrets via env:
{
"mcpServers": {
"mcpdemo": {
"command": "dotnet",
"args": ["run", "--no-build", "--project", "/ABSOLUTE/PATH/TO/McpDemo"]
}
}
}
}Restart Claude Desktop after saving.
3) Chat with your tools
Open a fresh Claude Desktop chat and try:
- “Convert 25 USD to EUR using my currency tool.”
- “Say hello to Polly.”
Claude will dynamically discover your server and select/call the right tool with structured arguments. Your server‑side guardrails remain in effect.
What changed vs the local runner?
| Concern | Local Runner (Phase A) | Claude Desktop (Phase B) |
|---|---|---|
| Tool discovery | Hardcoded knowledge | Dynamic via MCP (schemas pulled from your server) |
| Argument filling | Naive heuristics | LLM‑driven, schema‑informed |
| Transport | Thin process shim (dotnet run …) | STDIO orchestration by Desktop |
| Guardrails | Keep on server (validation, allowlists) | Same—server remains source of truth |
| Distribution | N/A | Optional .dxt one‑click install |
The server doesn’t change. Only the runner swaps from your console app to Claude Desktop.
Safety notes (server‑side, regardless of runner)
- Validate inputs for every tool (schema or custom checks).
- Allowlist risky actions or require confirmation (e.g., large amounts).
- Enforce timeouts and log tool invocations (scrub secrets).
- Prefer idempotent operations or provide dry‑run modes.
MCP helps because the server owns the schemas and clients speak a standard protocol, reducing client drift.
Try this mini “play ”
- In Claude: “Convert 25 USD to EUR with my MCP tools.”
- Then: “Say hello to Tiana.”
- Watch your server logs: you’ll see the model calling the right tool with typed args.
Up next — Part 3: Building a .NET MCP Client
We’ll build a proper .NET MCP client that:
- connects over STDIO,
- lists and inspects tools,
- calls tools with structured args,
- streams results and handles errors.
Stay tuned—it will be a fun ride! 🚀
Happy coding 🍀!