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:
// 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".)
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:
StandardErrorLinesgets 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 withserver/discoverand waits five seconds for the answer. Butdotnet run --projectchecks the build before it starts and often needs longer for that. Then the client takesinitialize, 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 toserver/discovercomes in time. Which version of the protocol applies is then inclient.NegotiatedProtocolVersion. ServerInfoandServerCapabilitiesare 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 itselfkladdedoesn't have to be yours. A security decision never builds on it.ListToolsAsyncreturnsMcpClientTool. That's anAIFunctionfrom Microsoft.Extensions.AI, with name, description and JSON schema from the server.InvokeAsyncis exactly the call that happens when the model calls a tool: It sendstools/calland returns the text of the response asTextContent.- Besides this path, there's
client.CallToolAsync(name, arguments), which you know from T8.1. It returns the protocol'sCallToolResultwithIsErrorand all content blocks. For tests and tools without a model, it's the more exact one; for model and agent, the path overAIFunction. await usingcloses the connection at the end, and with it the server process ends. A client that is never closed leaves a running process behind.ShutdownTimeoutsets how long the client waits for the end.