In the last lesson, the Kladde got an MCP server. Now its own quote agent uses it. So far, it had its reading tools from T7.2 as methods in its own code. After this lesson, it fetches them from the server, and the same tools are available to every other client that speaks MCP. You'll get to know the second side of the protocol, the client, and an uncomfortable truth: An MCP server that you plug in is code that you run.

The examples ran with these packages:

Package Version
ModelContextProtocol.Core (the client in Kladde.Agents) 2.4.0
ModelContextProtocol (the server from T8.1 and the file-based apps) 2.4.0
Microsoft.Agents.AI 1.28.0
Microsoft.Extensions.AI.OpenAI, OpenAI (only for the local model) 10.11.0, 2.15.0
xunit.v3.mtp-v2 4.0.2

ModelContextProtocol.Core contains client and server without the extensions for the host. For a client, it's enough; the server from T8.1 is registered in the container by ModelContextProtocol.

As in T8.1, the tool descriptions, test data and the agent's instructions in the code are German. A gloss follows wherever the meaning matters.

The client

A client comes about in two steps. First, a transport describes how the client reaches the server. For stdio, that's StdioClientTransport: which program with which arguments, in which folder and with which environment. Then McpClient.CreateAsync starts the process, has the first conversation with the server and returns a finished client. The file-based app tools/mcp-client.cs in the Kladde's folder shows both:

C#
// tools/mcp-client.cs
#:package ModelContextProtocol@2.4.0
#:property PublishAot=false

using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;

var transport = new StdioClientTransport(new StdioClientTransportOptions
{
    Name = "kladde",
    Command = "dotnet",
    Arguments = ["run", "--project", "src/Kladde.Mcp.Stdio"],
    StandardErrorLines = line => Console.WriteLine($"[Server] {line}"),
});

await using McpClient client = await McpClient.CreateAsync(transport);
Console.WriteLine($"Server: {client.ServerInfo?.Name} {client.ServerInfo?.Version}");
Console.WriteLine($"Bietet Werkzeuge an: {client.ServerCapabilities?.Tools is not null}");

IList<McpClientTool> tools = await client.ListToolsAsync();
McpClientTool findContacts = tools.Single(t => t.Name == "find_contacts");

// An McpClientTool is an AIFunction: this is the call a model's tool call becomes.
object? result = await findContacts.InvokeAsync(new AIFunctionArguments { ["text"] = "stadtwerke" });
Console.WriteLine(result);

("Bietet Werkzeuge an" means "Offers tools".)

PowerShell
dotnet run tools/mcp-client.cs

The output is long because the server logs every request. Shortened, it looks like this:

[Server] info: Microsoft.Hosting.Lifetime[0]
[Server]       Application started. Press Ctrl+C to shut down.
…
[Server]       Server (kladde 1.0.0), Client (mcp-client 1.0.0.0) method 'initialize' request handler completed in 30.7045ms.
[Server] warn: ModelContextProtocol.Server.McpServer[975074943]
[Server]       Server (kladde 1.0.0), Client (mcp-client 1.0.0.0) method 'server/discover' request handler failed in 13.9789ms.
[Server]       System.OperationCanceledException: The operation was canceled.
…
Server: kladde 1.0.0
Bietet Werkzeuge an: True
…
[Server]       Server (kladde 1.0.0), Client (mcp-client 1.0.0.0) method 'tools/call' request handler called.
[Server] info: ModelContextProtocol.Server.McpServer[2065726448]
[Server]       "find_contacts" completed. IsError = False.
…
[{"id":"5b3e9d10-7a2c-4f68-b1d4-8c0e6f2a9b22","name":"Max Mustermann","company":"Stadtwerke Beispielstadt","email":"max.mustermann@example.org"}]
[Server]       Server (stream) (kladde) transport completed reading messages.
[Server] info: Microsoft.Hosting.Lifetime[0]
[Server]       Application is shutting down...

What you see in it:

  • StandardErrorLines gets every line the server writes to its error output, that is, everything from its logger (T8.1). Here it's on the console with [Server] in front of it; in an application, you pass it to your own logger. Even without this callback, the client appends the last lines of the error output to its exception when the server crashes. With it, you see all of them, even when everything goes well.
  • The first conversation ran here over the older handshake initialize (T8.1). The client first asks with server/discover and waits five seconds for the answer. But dotnet run --project checks the build before it starts and often needs longer for that. Then the client takes initialize, and the server reports the canceled first question as a warning. That's harmless. If the client starts the built DLL, as the tests in T8.1 do, the answer to server/discover comes in time. Which version of the protocol applies is then in client.NegotiatedProtocolVersion.
  • ServerInfo and ServerCapabilities are what the client learned in the first conversation. In it, the server says who it is and what it can do. That's a self-declaration: A server that calls itself kladde doesn't have to be yours. A security decision never builds on it.
  • ListToolsAsync returns McpClientTool. That's an AIFunction from Microsoft.Extensions.AI, with name, description and JSON schema from the server. InvokeAsync is exactly the call that happens when the model calls a tool: It sends tools/call and returns the text of the response as TextContent.
  • Besides this path, there's client.CallToolAsync(name, arguments), which you know from T8.1. It returns the protocol's CallToolResult with IsError and all content blocks. For tests and tools without a model, it's the more exact one; for model and agent, the path over AIFunction.
  • await using closes the connection at the end, and with it the server process ends. A client that is never closed leaves a running process behind. ShutdownTimeout sets how long the client waits for the end.