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

> Source: <https://dev.to/jgomezdev/consuming-mcp-servers-from-net-when-your-app-becomes-the-client-2goc>
> Published: 2026-08-14 14:59:40+00:00

Last time we [built an MCP server in C#](https://dev.to/jgomezdev/build-an-mcp-server-in-c-write-your-tools-once-use-them-in-every-claude-4oa1) 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](https://dev.to/jgomezdev/build-a-claude-tool-use-agent-in-c-not-a-chatbot-on-steroids-3e9g) — 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:

``` js
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:**

``` js
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:

``` js
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](https://dev.to/jgomezdev/build-a-claude-tool-use-agent-in-c-not-a-chatbot-on-steroids-3e9g). 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:

``` js
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](https://dev.to/jgomezdev/build-a-claude-tool-use-agent-in-c-not-a-chatbot-on-steroids-3e9g) 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.
