cd /news/developer-tools/consuming-mcp-servers-from-net-when-… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-96933] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

Consuming MCP Servers from .NET: When Your App Becomes the Client

A developer demonstrated how to consume MCP servers from a .NET application, turning the app into a client. The post shows connecting to the Aurora Coffee Co. server via the ModelContextProtocol SDK, listing tools, and invoking them, reducing a hand-rolled agent loop to three lines. It emphasizes tool discovery and the security boundary where the server owns execution.

read7 min views1 publishedAug 14, 2026

Last time we built an MCP server in C# and plugged it into Claude Desktop and Claude Code. Both of those are somebody else's app. You wrote the tools, handed them over, and a client you didn't write got all the benefit.

So here's the other half of the story: what happens when your .NET app is the thing that needs those tools? Not Claude Desktop β€” your internal support console, your worker service, your CLI. This post writes the client side, connects it to the same Aurora Coffee Co. server from last time, and ends somewhere satisfying: the hand-rolled agent loop from the tool-use post β€” the one we wrote by hand in about sixty lines β€” collapses into three.

A server post talks about exposing capabilities. A client post is the mirror image, and it's just three verbs:

That third step is worth pausing on, because it's the same security boundary from the tool-use post viewed from the opposite side. Back then, your code owned execution and Claude only got to ask. Now you're the one asking, and the server owns execution. You send a name and arguments; what actually runs is entirely the server's business.

The discovery step is what makes this different from just calling an API client. You don't have a generated proxy class with GetOrderStatus(string)

on it. You have a list of tools that arrived at runtime, and code that has to be okay with that.

Start a console app and add the SDK. The client lives in ModelContextProtocol.Core

if you want minimal dependencies, or the full ModelContextProtocol

package if this same app also hosts a server:

dotnet new console -o AuroraCoffee.Support
cd AuroraCoffee.Support
dotnet add package ModelContextProtocol.Core

For a local server, the stdio transport launches it as a child process β€” exactly what Claude Desktop was doing for you via that JSON config last time, except now you're the one spawning it:

using ModelContextProtocol.Client;

var transport = new StdioClientTransport(new StdioClientTransportOptions
{
    Name = "aurora-coffee",
    Command = "dotnet",
    Arguments = ["run", "--project", "../AuroraCoffee.Mcp"],
});

await using var client = await McpClient.CreateAsync(transport);

That's the whole connection. McpClient.CreateAsync

starts the process, performs the protocol handshake, and hands you a live client. Note the await using

β€” the client owns a child process, and letting it go undisposed leaves an orphaned dotnet

running. Ask me how many stray processes it took to internalize that.

Now the part people skip and then debug for an hour. Print the tool list before you write a single CallToolAsync:

foreach (var tool in await client.ListToolsAsync())
{
    Console.WriteLine($"{tool.Name} β€” {tool.Description}");
}

The reason isn't ceremony. Tool names are derived from your server's method names by the SDK, so the exact string the protocol exposes may not be the C# identifier you typed. Discovery is the contract; the C# source on the server side is an implementation detail. Print the list, copy the real names, then write your calls against those. Guessing the name gets you a runtime error with no compiler to save you.

With a real name in hand, invoking it is a name plus a dictionary of arguments:

using ModelContextProtocol.Protocol;

var result = await client.CallToolAsync(
    "get_order_status",
    new Dictionary<string, object?> { ["orderId"] = "A-1001" },
    cancellationToken: CancellationToken.None);

Console.WriteLine(result.Content.OfType<TextContentBlock>().First().Text);
Order A-1001: shipped, ETA 2026-08-03.

Content

is a list of blocks, not a string, because a tool can return text, images, or several blocks at once. Filtering with OfType<TextContentBlock>()

is the honest way to read it β€” and if you .First()

on a tool that returned no text, that's an exception, so use FirstOrDefault()

when you don't control the server.

Notice what you didn't write: no JSON Schema, no HTTP plumbing, no message framing. You also didn't reference the server's project. Order

and OrdersStore

don't exist in this app β€” the only contract is the wire.

Everything above is a client driving tools by hand. The interesting version is letting a model decide which to call β€” which is exactly what we hand-wrote a loop for in the tool-use post.

McpClientTool

derives from AIFunction

in Microsoft.Extensions.AI. That single inheritance is the whole trick: tools discovered from an MCP server drop straight into any IChatClient

as callable functions, no adapter code.

dotnet add package Anthropic
dotnet add package Microsoft.Extensions.AI
using Anthropic;
using Microsoft.Extensions.AI;

IChatClient chatClient = new AnthropicClient()   // reads ANTHROPIC_API_KEY
    .AsIChatClient("claude-opus-4-8")
    .AsBuilder()
    .UseFunctionInvocation()
    .Build();

var tools = await client.ListToolsAsync();

var response = await chatClient.GetResponseAsync(
    "Is order A-1001 shipped, and do you still have ETH-250 in stock?",
    new ChatOptions { Tools = [.. tools] });

Console.WriteLine(response.Text);
Order A-1001 has shipped and is on track to arrive 2026-08-03. And yes β€” the
Ethiopia beans (ETH-250) are in stock, with 42 units available.

Read that output, then go back and look at the loop we wrote by hand. Same question, same two tool calls, same answer β€” except the while (true)

, the message-list bookkeeping, the block reconstruction, and the "echo the assistant turn back verbatim" gotcha are all gone. UseFunctionInvocation()

is the middleware that runs that loop for you: Claude asks for a tool, it invokes the matching AIFunction

, feeds the result back, and repeats until there's an answer.

The tools themselves live in a completely separate process, written by someone who never heard of this app. That's the part worth sitting with.

stdio works when the server is a local child process. For a shared server β€” the ASP.NET Core HTTP version from the end of the last post β€” swap the transport and change nothing else:

var transport = new HttpClientTransport(new HttpClientTransportOptions
{
    Endpoint = new Uri("https://tools.auroracoffee.example/mcp"),
    TransportMode = HttpTransportMode.StreamableHttp,
});

await using var client = await McpClient.CreateAsync(transport);

ListToolsAsync

, CallToolAsync

, and the IChatClient

wiring are all identical from here β€” the transport is the only thing that knows the difference. TransportMode

defaults to AutoDetect

, which tries Streamable HTTP and falls back to legacy SSE, so you can leave it off if you don't know what the far end supports. Set it explicitly when you do; auto-detection costs a round trip.

Worth flagging if you're building on the server post: the C# SDK shipped v2.0 on July 28, 2026, implementing the 2026-07-28 spec revision β€” the largest protocol change since MCP launched. The server-side attributes ([McpServerToolType]

, [McpServerTool]

) and the client APIs above are unchanged, but two things moved:

HttpServerTransportOptions.Stateless

now defaults to true

, the initialize

handshake is gone, and the Mcp-Session-Id

header went away. Great for scaling a shared server behind a load balancer; a behavior change if you assumed sessions.MCP9005

and throw in stateless mode. Interactive flows move to Multi Round-Trip Requests instead.If your server is doing plain request/response tools β€” which is most servers, including Aurora β€” you won't notice. If it was leaning on sessions, read the migration notes before you upgrade.

Write an MCP client when your app needs capabilities that live outside it: a server your platform team maintains, a third-party server you don't control, or your own server that several apps share. The payoff is that the tool list can change on the server without a redeploy on your side β€” new tool appears, ListToolsAsync

returns it, and if a model is driving, it starts using it without you shipping a line of code.

Skip it when the tools are just... your own methods, in your own app, called by your own model loop. Going through a protocol and a process boundary to call a function you could have called directly is architecture cosplay. The tool-use approach is simpler and faster there, and it always was.

Rule of thumb: MCP client when the tools cross a boundary you don't own; plain in-process tool use when they don't.

ListToolsAsync()

and use the names it returns. The compiler can't catch a guessed tool name.McpClientTool

is an AIFunction

IChatClient

, and UseFunctionInvocation()

replaces the entire hand-written agent loop.HttpClientTransport

for a shared server; the discover-and-call code is identical.initialize

handshake and sampling/roots are deprecated. Plain tool servers are unaffected; session-dependent ones need a look.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @modelcontextprotocol 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/consuming-mcp-server…] indexed:0 read:7min 2026-08-14 Β· β€”