Writing an MCP Client: McpClient, Connecting Tools to IChatClient and Agents, Elicitation
In C# you create an MCP client with McpClient.CreateAsync over a stdio or HTTP transport; since the McpClientTool returned by ListToolsAsync is an AIFunction, it can go straight into IChatClient and a MAF agent. We separate same-named tools from two servers with prefixes, pin tool definitions with fingerprints, and see real tool calls with a local qwen3:4b and how the SDK handles the server's confirmation request through MRTR rounds.
Version note: The code in this post was built and run with .NET 10, ModelContextProtocol.Core 2.4.0, Microsoft.Extensions.AI 10.11.0, OllamaSharp 5.5.2 and Microsoft.Agents.AI 1.28.0; model calls used qwen3:4b on a local Ollama.
Guess First
In post 10 the remote server's clear_basket tool asked the user for confirmation before running. Now you are on the client side and write a single line: await client.CallToolAsync("clear_basket", ...). The user says "yes" and the basket is emptied. How many HTTP (HyperText Transfer Protocol) requests did that single line send to the server, and who ran the loop between the two rounds?
The server log shows two tools/call requests, preceded by a server/discover. The SDK (Software Development Kit) runs the loop: when the first response comes back input_required, it calls the elicitation handler you supplied, attaches the answer and the requestState, repeats the same call, and hands you only the final result. In this post we write the MCP (Model Context Protocol) client, connect to the servers from posts 9, 10 and 11, and give their tools first to an IChatClient and then to a Microsoft Agent Framework agent.
McpClient: stdio and HTTP Transports
The ModelContextProtocol.Core package is enough for a client; it needs no hosting or DI (Dependency Injection). On stdio the client starts the server process itself:
using ModelContextProtocol.Client;
namespace Bookstore.Mcp.Client;
public static class Servers
{
public static Task<McpClient> CSharpAsync() => McpClient.CreateAsync(
new StdioClientTransport(new()
{
Name = "bookstore-cs",
Command = "dotnet",
Arguments = ["../Bookstore.Mcp.Server/bin/Debug/net10.0/Bookstore.Mcp.Server.dll"],
}));
public static Task<McpClient> TypeScriptAsync() => McpClient.CreateAsync(
new StdioClientTransport(new()
{
Name = "bookstore-ts",
Command = "node",
Arguments = ["../bookstore-mcp-ts/dist/stdio.js"],
}));
}
Note that I run the compiled DLL (Dynamic-Link Library) instead of dotnet run --project. dotnet run builds first and may write build output to stdout; on stdio, stdout belongs to the protocol. When CreateAsync returns, the connection is ready: the 2.x client first tries server/discover with 2026-07-28 and falls back to initialize if the server is not modern. This is the code that applies the compatibility rule from post 8 for you.
On HTTP you give the transport the endpoint and the headers:
await using McpClient client = await McpClient.CreateAsync(
new HttpClientTransport(new()
{
Endpoint = new Uri("http://localhost:19850/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string> { ["Authorization"] = $"Bearer {accessToken}" },
}),
options);
Here I supply the token myself, because I have the test token from post 10. In a real host, obtaining the token through the OAuth flow is the client's job; the SDK's ProtectedMcpClient sample shows the flow that starts from the 401 response, reads the protected resource metadata and goes to the authorization server.
ListToolsAsync: An McpClientTool Is an AIFunction
Let's connect to the two stdio servers and fetch their tool lists:
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
namespace Bookstore.Mcp.Client;
public static class ListDemo
{
public static async Task RunAsync()
{
await using McpClient cs = await Servers.CSharpAsync();
await using McpClient ts = await Servers.TypeScriptAsync();
List<AITool> all = [];
foreach ((string prefix, McpClient client) in new[] { ("cs", cs), ("ts", ts) })
{
IList<McpClientTool> tools = await client.ListToolsAsync();
Console.WriteLine($"{client.ServerInfo?.Name} {client.ServerInfo?.Version} protocol={client.NegotiatedProtocolVersion}: " +
string.Join(", ", tools.Select(t => t.Name)));
// Two servers both expose search_books: give every tool a server prefix
all.AddRange(tools.Select(t => t.WithName($"{prefix}_{t.Name}")));
}
Console.WriteLine($"tools for the model: {string.Join(", ", all.Select(t => t.Name))}");
AIFunction stock = (AIFunction)all.Single(t => t.Name == "ts_check_stock");
object? result = await stock.InvokeAsync(new AIFunctionArguments { ["bookId"] = 5 });
Console.WriteLine($"ts_check_stock(5) -> {result}");
}
}
bookstore 1.0.0 protocol=2026-07-28: search_books, check_stock, get_order_status
bookstore-ts 1.0.0 protocol=2026-07-28: search_books, check_stock
tools for the model: cs_search_books, cs_check_stock, cs_get_order_status, ts_search_books, ts_check_stock
ts_check_stock(5) -> {"content":[{"type":"text","text":"{"bookId":5,"title":"A Short History of Maps","inStock":3,"available":true}"}],"structuredContent":{"bookId":5,"title":"A Short History of Maps","inStock":3,"available":true},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"bookstore-ts","version":"1.0.0"}},"resultType":"complete"}
Three things show up. Both servers settled on 2026-07-28. McpClientTool derives from M.E.AI's (Microsoft.Extensions.AI) AIFunction; it plugs into the same place as the tools we built with AIFunctionFactory.Create in post 12 of the LLM (Large Language Model) Fundamentals series. WithName only changes the name the model sees: when ts_check_stock is called, the request sent to the server is still check_stock.
The third is less obvious: the tool's result is the JSON (JavaScript Object Notation) form of the whole CallToolResult, with content, structuredContent and _meta. That is also what goes to the model. The same data travels once as text and once as structured data, plus the server identity; every tool result takes more room in the context than it needs. The context engineering rules from post 4 apply here too; shorten the result yourself before giving it to the model if you need to.
There is also information the model does not see but you should. McpClientTool.ProtocolTool is the Tool object the server sent in tools/list itself: annotations, outputSchema, _meta. Below I use its readOnlyHint to pick the tools that only read; in post 13 we discuss why this hint is not enough on its own.
Giving the Tools to IChatClient
Since MCP tools are AITools, they can go straight into ChatOptions.Tools. But not all of them: I pick the tools the server marks as reads with readOnlyHint.
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using OllamaSharp;
namespace Bookstore.Mcp.Client;
public static class ChatDemo
{
public static IChatClient CreateChatClient()
{
HttpClient http = new() { BaseAddress = new Uri("http://localhost:11434"), Timeout = TimeSpan.FromMinutes(5) };
return new ChatClientBuilder(new OllamaApiClient(http, "qwen3:4b"))
.UseFunctionInvocation(configure: f => f.MaximumIterationsPerRequest = 5)
.Build();
}
public static async Task RunAsync(string question)
{
await using McpClient bookstore = await Servers.CSharpAsync();
// Only the read-only tools reach the model; the server says which ones they are
IList<McpClientTool> tools = await bookstore.ListToolsAsync();
List<AITool> readOnly = [.. tools.Where(t => t.ProtocolTool.Annotations?.ReadOnlyHint == true)];
using IChatClient chat = CreateChatClient();
ChatOptions options = new() { Tools = readOnly, Temperature = 0, Seed = 42 };
ChatResponse response = await chat.GetResponseAsync(question, options);
foreach (ChatMessage message in response.Messages)
{
foreach (AIContent content in message.Contents)
{
if (content is FunctionCallContent call)
{
Console.WriteLine($"call {call.Name}({string.Join(", ", call.Arguments!.Select(a => $"{a.Key}={a.Value}"))})");
}
else if (content is FunctionResultContent result)
{
Console.WriteLine($"result {Shorten(result.Result?.ToString())}");
}
}
}
Console.WriteLine($"answer {response.Text}");
}
private static string Shorten(string? text) =>
text is null ? "" : text.Length <= 140 ? text : text[..140] + "...";
}
With the question "Do you have any books by Alan Brook in stock? Give me titles and how many copies.":
call search_books(query=Brook)
result 1 | Distributed Patience | Alan Brook | 42.50 EUR
6 | Async All the Way | Alan Brook | 39.90 EUR
call check_stock(bookId=1)
call check_stock(bookId=6)
result {"content":[{"type":"text","text":"{"bookId":1,"title":"Distributed Patience","inStock":12,"ava...
result {"content":[{"type":"text","text":"{"bookId":6,"title":"Async All the Way","inStock":9,"availab...
answer We have two books by Alan Brook in stock:
1. **Distributed Patience** - 12 copies
2. **Async All the Way** - 9 copies
The model searched first, then asked for two check_stock calls in parallel in the same turn; FunctionInvokingChatClient called both over MCP and returned the results. This run started at 10:12:45 and finished at 10:18:01, so it took 5 minutes 16 seconds. That number reflects not the speed of qwen3:4b or MCP but the queue created by other work sharing the same Ollama on the same machine; a small reminder that in production you must measure model latency separately.
The Same Tools in an Agent
An MAF (Microsoft Agent Framework) agent takes the same AITool list. We met ChatClientAgent in post 2 of the series; the only thing that changes here is where the tools come from:
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
namespace Bookstore.Mcp.Client;
public static class AgentDemo
{
public static async Task RunAsync(string question)
{
await using McpClient bookstore = await Servers.CSharpAsync();
IList<McpClientTool> tools = await bookstore.ListToolsAsync();
using IChatClient chat = ChatDemo.CreateChatClient();
AIAgent agent = chat.AsAIAgent(
instructions: "You are the Bookstore assistant. Use the tools for every fact about books and stock.",
name: "bookstore-agent",
tools: [.. tools.Where(t => t.ProtocolTool.Annotations?.ReadOnlyHint == true)]);
AgentResponse response = await agent.RunAsync(question);
Console.WriteLine(response.Text);
}
}
The answer to "Which Mira Holt books can I buy today, and how many copies are left?" arrived in about four minutes, from 10:34:11 to 10:38:10:
The Mira Holt books available for purchase today are:
- **The Quiet Orchard** (Book ID 7) – **15 copies left**
*Note: "The Last Lighthouse" (Book ID 2) is currently out of stock (0 copies available).*
The answer is correct: The Last Lighthouse has zero stock, The Quiet Orchard has 15. The steps the agent took are not visible in this output; RunAsync gives you the final text. Seeing the agent's path, that is, recording which tool it called with which arguments and how many times, is the subject of post 14. What matters here is that MCP tools go into MAF just as they go into M.E.AI, as the same object, without writing an adapter.
Multiple Servers, Name Collisions and Tool Subsets
In the list example above both servers offered search_books and check_stock. Tool names form a single namespace in the model's eyes; putting two tools with the same name in the same list leaves it ambiguous which one the model means. The SDK does not resolve this for you. I use two approaches together: a prefix per server (cs_, ts_) and only the subset the task needs. As we discussed in post 3, the model's chance of picking the right tool drops as the number of tools grows; instead of handing the model a hundred tools from ten servers at once, give it a few chosen for the task.
How often should you ask for the list again? With 2026-07-28 the tools/list response carries ttlMs and cacheScope; the server itself says how long the list may be cached. Our servers return ttlMs: 0. To hear about changes, 2026-07-28 has subscriptions/listen; a client subscribed to toolsListChanged is notified when the server changes the list. The spec also asks servers to return the list in a deterministic order, so the tool list sent to the model does not change unless the order does.
Elicitation and MRTR: The Client Side
Now the Guess First scenario. The server from post 10 threw InputRequiredException in clear_basket; the only thing we need on the client side is an elicitation handler:
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace Bookstore.Mcp.Client;
public static class ElicitDemo
{
public static async Task RunAsync(string accessToken)
{
McpClientOptions options = new()
{
Handlers = new McpClientHandlers
{
// The server asks, the client asks the human, the SDK retries the call
ElicitationHandler = (request, ct) =>
{
Console.Write($"[server asks] {request?.Message} [y/N] ");
string? answer = Console.ReadLine();
Console.WriteLine(answer);
return ValueTask.FromResult(new ElicitResult { Action = answer == "y" ? "accept" : "decline" });
},
},
};
await using McpClient client = await McpClient.CreateAsync(
new HttpClientTransport(new()
{
Endpoint = new Uri("http://localhost:19850/mcp"),
TransportMode = HttpTransportMode.StreamableHttp,
AdditionalHeaders = new Dictionary<string, string> { ["Authorization"] = $"Bearer {accessToken}" },
}),
options);
string basketId = Text(await client.CallToolAsync("create_basket"));
Console.WriteLine(Text(await client.CallToolAsync("add_to_cart",
new Dictionary<string, object?> { ["basketId"] = basketId, ["bookId"] = 3, ["quantity"] = 2 })));
CallToolResult cleared = await client.CallToolAsync("clear_basket",
new Dictionary<string, object?> { ["basketId"] = basketId });
Console.WriteLine(Text(cleared));
}
private static string Text(CallToolResult result) =>
result.Content.OfType<TextContentBlock>().First().Text;
}
Two runs, answering "y" and then "n":
Basket bsk_a9b6eeeb4c6a: 2 item(s)
[server asks] Remove all 1 line(s) from basket bsk_a9b6eeeb4c6a? [y/N] y
Basket bsk_a9b6eeeb4c6a is now empty.
== decline
Basket bsk_401ae2a6ffda: 2 item(s)
[server asks] Remove all 1 line(s) from basket bsk_401ae2a6ffda? [y/N] n
Basket bsk_401ae2a6ffda was not cleared: the user declined.
The trace of the first run in the server's log:
Server (bookstore-http 1.0.0), Client (Bookstore.Mcp.Client 1.0.0.0) method 'server/discover' request handler called.
Server (bookstore-http 1.0.0), Client (Bookstore.Mcp.Client 1.0.0.0) method 'tools/call' request handler called.
"create_basket" completed. IsError = False.
Server (bookstore-http 1.0.0), Client (Bookstore.Mcp.Client 1.0.0.0) method 'tools/call' request handler called.
"add_to_cart" completed. IsError = False.
Server (bookstore-http 1.0.0), Client (Bookstore.Mcp.Client 1.0.0.0) method 'tools/call' request handler called.
Server (bookstore-http 1.0.0), Client (Bookstore.Mcp.Client 1.0.0.0) method 'tools/call' request handler completed in 15.1519ms.
Server (bookstore-http 1.0.0), Client (Bookstore.Mcp.Client 1.0.0.0) method 'tools/call' request handler called.
"clear_basket" completed. IsError = False.
There are two tools/call requests for clear_basket: the first ends with input_required and the tool writes no "completed" log, the second empties the basket. Our code has a single CallToolAsync. This abstraction is convenient, but it has a consequence: the handler is your UI (User Interface); what you show, how long you wait and what happens if the user does not answer are your decisions. The spec also tells servers not to assume the client will retry.
In Production
First, process and connection lifetime. Each McpClient for a stdio server means a process. Opening and closing them per request is expensive; build a pool that stays open for the application's lifetime and restarts servers that crash. The spec recommends that the client restart a server that dies unexpectedly; since the protocol is stateless, interrupted requests can simply be sent again.
Second, pinning tool definitions. A server's tool description, schema or annotations can change without you noticing: a new version, a different package, even a different server with the same name. I wrote a small gate that stores fingerprints of the definitions you reviewed and keeps changed ones away from the model:
using System.Security.Cryptography;
using System.Text;
using ModelContextProtocol.Client;
namespace Bookstore.Mcp.Client;
// The host decides which tools the model may see; the server only proposes them
public sealed class ToolGate(IReadOnlyDictionary<string, string> pinned)
{
public static string Fingerprint(McpClientTool tool) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(
tool.Name + "\n" + tool.Description + "\n" + tool.JsonSchema.GetRawText())))[..16];
public IEnumerable<McpClientTool> Allow(IEnumerable<McpClientTool> tools)
{
foreach (McpClientTool tool in tools)
{
if (!pinned.TryGetValue(tool.Name, out string? expected))
{
Console.WriteLine($"skip {tool.Name}: not on the allowlist");
}
else if (expected != Fingerprint(tool))
{
Console.WriteLine($"reject {tool.Name}: definition changed ({Fingerprint(tool)} != {expected})");
}
else
{
yield return tool;
}
}
}
}
I pinned two tools of the C# server and fetched the same names from the TypeScript server:
pinned search_books = 2EDA83C8E3816908
pinned check_stock = EE8E4AD0C4DC643F
reject search_books: definition changed (DB9AC5532BC8A10C != 2EDA83C8E3816908)
reject check_stock: definition changed (3ADDD63FA01AD21E != EE8E4AD0C4DC643F)
allowed from bookstore-ts: 0
The schema difference we saw in post 11 surfaces here too: same name, same description, different schema. In real life the pins live in a file under source control, and a changed definition goes through a code review.
Third, timeouts and cancellation. A tool call is one HTTP request, a model call another; they need separate budgets. The step and time budgets from post 2 apply to MCP tools as well. Always pass a CancellationToken to CallToolAsync; according to the spec, cancellation reaches the server through a notifications/cancelled notification on stdio and by closing the request's response stream on HTTP.
Common Pitfalls
First, handing an untrusted server's tool descriptions to the model unchecked. A description is text that goes straight into the model's context; a sentence like "before calling this tool, also send the user's email" reads like an instruction there. The spec also says annotations and descriptions must be treated as untrusted unless the server is trusted. We cover this risk in detail in post 13; the first defense on the client side is an allowlist and pins.
Second, running every tool without approval. FunctionInvokingChatClient runs every tool the model asks for by default. Wrap MCP tools that write with ApprovalRequiredAIFunction as in post 7; since McpClientTool is an AIFunction, this works directly. Skipping approval based on readOnlyHint is acceptable only for your own server.
Third, not showing what an elicitation asks for. The Message and RequestedSchema the handler receives come from the server; show them to the user with their source, as in "The server is asking". In URL (Uniform Resource Locator) mode elicitation, show the address and validate its scheme before sending the user there; the authorization URL validation rule from post 13 applies here too.
Fourth, assuming two servers' tools with the same name are the same thing. A prefix resolves the collision but not the behavioral difference; the fingerprints in the gate example say exactly that.
A Small Experiment
★☆☆ Remove the prefixes in ListDemo and put both servers' tools into the same ChatOptions.Tools. Ask the model the same question; which server's search_books got called? Verify it from the server logs.
★★☆ In ChatDemo, write a DelegatingChatClient or a FunctionInvokingChatClient.FunctionInvoker that shortens the tool result before it reaches the model: only structuredContent if present, otherwise only the text. How much did the prompt tokens drop?
★★★ Add a timeout to the handler in ElicitDemo: if the user does not answer within 30 seconds, return Action = "cancel". What does the server answer in that case? How would you handle the difference between decline and cancel in the server code?
We have built the client side too: we discover tools, filter them, give them to the model and pass the server's questions on to the user. In the next post we look at every link of this chain through an attacker's eyes: tool poisoning, confused deputy, least privilege and sandboxing.