Building an MCP client in C# is the part of Model Context Protocol that turns a server's advertised capabilities into something your .NET application can actually use. The client connects, negotiates the session, discovers tools, resources, and prompts, and then invokes capabilities through the protocol instead of through a one-off integration. This article is the hands-on client-side deep dive: stdio, HTTP auto-detection, capability discovery, tool calls, content blocks, notifications, and lifecycle.
As of writing, the latest stable NuGet version for ModelContextProtocol, ModelContextProtocol.Core, and ModelContextProtocol.AspNetCore is 1.4.0, with 2.0.0-preview.1 also listed as a prerelease. I verified the API names in this article against the live MCP C# SDK repository, the current SDK getting started guide, the transport documentation, and NuGet package feeds. Check NuGet again before pinning a package because this SDK is moving quickly.
Where an MCP Client in C# Fits
An MCP client sits between your application and an MCP server. The host might be an IDE, a chat application, a background service, or an agent runtime. The server exposes capabilities. Your client is responsible for creating a session, asking the server what it supports, and calling the right capability with the right arguments.
For .NET developers, this is a useful boundary. If you've already been working with AI application patterns like Microsoft Agent Framework in C#, MCP gives you a protocol-level way to reach external tools without baking every tool into the host. That doesn't mean every MCP client in C# needs to become an agent framework tutorial. It doesn't. The client can be a console application, a worker, a web backend, or a thin integration layer that discovers capabilities and passes structured results to another part of the system.
The official SDK package choice is straightforward for client work. Use ModelContextProtocol.Core if you want the minimum dependency set for client and low-level APIs. Use ModelContextProtocol if you also want the common hosting and dependency injection pieces available. The getting started docs say ModelContextProtocol is the right starting point for most projects, and that is the package used in the simple examples below.
Creating an MCP Client in C# with StdioClientTransport
Stdio is the local-process transport. Your application starts a server process, then the client talks to that process over stdin and stdout. This is common for desktop tools, IDE integrations, and local developer automation because the server can run beside the user and access local context.
The current SDK API is StdioClientTransport plus McpClient.CreateAsync. CreateAsync connects to the server and returns a connected McpClient. Since McpClient implements IAsyncDisposable, use await using so shutdown and transport cleanup happen predictably.
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
var transport = new StdioClientTransport(new StdioClientTransportOptions
{
Name = "Everything",
Command = "npx",
Arguments = ["-y", "@modelcontextprotocol/server-everything"],
ShutdownTimeout = TimeSpan.FromSeconds(10)
});
await using var client = await McpClient.CreateAsync(transport);
IList<McpClientTool> tools = await client.ListToolsAsync();
foreach (var tool in tools)
{
Console.WriteLine($"{tool.Name}: {tool.Description}");
}
CallToolResult result = await client.CallToolAsync(
"echo",
new Dictionary<string, object?> { ["message"] = "Hello MCP!" },
cancellationToken: CancellationToken.None);
Console.WriteLine(result.Content.OfType<TextContentBlock>().First().Text);
That is the smallest useful MCP client in C#. It launches the server, lists tools, calls echo, and reads the returned text block. This example uses the SDK's reference everything server because the goal here is the client side, not building the server.
The most important stdio rule is that stdout belongs to the protocol. Server logs should go to stderr. From the client side, StdioClientTransportOptions.StandardErrorLines can capture stderr lines for diagnostics without corrupting JSON-RPC messages.
Stdio Security Tradeoffs for an MCP Client in C#
The stdio transport is convenient, but it has a security tradeoff that is easy to miss. The SDK docs state that StdioClientTransportOptions.InheritEnvironmentVariables defaults to true. That means the child server process can inherit environment variables from your client process.
That might be fine when you control the server. It can be risky for third-party or untrusted servers. Environment variables often contain tokens, API keys, proxy settings, internal endpoints, and deployment configuration. A local MCP client in C# that blindly launches a server with inherited environment variables may give that process more information than it needs.
The SDK provides StdioClientTransportOptions.GetDefaultEnvironmentVariables() as a safer starting point. It returns a curated set of variables that most child processes need to start, such as path-related values, without forwarding every secret from the parent. The practical pattern is: set InheritEnvironmentVariables = false, start from GetDefaultEnvironmentVariables(), add only server-specific values like MY_MCP_SERVER_MODE, and capture diagnostics with StandardErrorLines instead of reading stdout.
The tradeoff is compatibility. Turning inheritance off can break servers that depend on variables like DOTNET_ROOT, JAVA_HOME, HTTP_PROXY, HTTPS_PROXY, or custom config values. The safer pattern is not "never pass environment variables." The safer pattern is to pass the smallest intentional set.
If you're building a client that launches user-provided servers, make this a configuration decision. For trusted internal servers, inheritance may be acceptable. For arbitrary third-party servers, default-deny plus an explicit allowlist is a better starting point.
Connecting an MCP Client in C# over HTTP
HTTP is the shape you want when the MCP server is remote or shared by multiple clients. The C# SDK uses HttpClientTransport for both Streamable HTTP and legacy SSE. The transport mode defaults to HttpTransportMode.AutoDetect, which tries Streamable HTTP first and falls back to SSE if the server doesn't support it.
That default matters because SSE is now a legacy transport in the current SDK docs. New HTTP servers should prefer Streamable HTTP. Auto-detection is useful for client compatibility, but if you control both sides, I would usually be explicit about Streamable HTTP so future behavior is clear.
| Client scenario | Transport choice | Tradeoff |
|---|---|---|
| Local developer tool | stdio | Simple process launch, but environment inheritance needs care |
| Remote shared server | Streamable HTTP | Fits normal HTTP infrastructure, but needs lifecycle and timeout design |
| Legacy compatibility | AutoDetect or SSE | Helpful during migration, but SSE should not be the default for new work |
using ModelContextProtocol.Client;
var transport = new HttpClientTransport(new HttpClientTransportOptions
{
Name = "Remote MCP Server",
Endpoint = new Uri("https://mcp.example.com/mcp"),
TransportMode = HttpTransportMode.AutoDetect,
ConnectionTimeout = TimeSpan.FromSeconds(30)
});
await using var client = await McpClient.CreateAsync(transport);
Console.WriteLine($"Connected to {client.ServerInfo.Name}");
Console.WriteLine($"Protocol version: {client.NegotiatedProtocolVersion}");
If you're already comfortable with HttpClient, the same operational thinking applies here. Timeouts, DNS behavior, handlers, and observability still matter. The HttpClientTransport constructor can accept an HttpClient instance, and the ownsHttpClient parameter controls whether disposing the transport also disposes that client. If you're using factory-managed clients, keep ownership clear. The patterns in HttpClient in C# and IHttpClientFactory in .NET are relevant when the MCP client in C# is part of a long-running app.
I am intentionally not covering authentication here. Remote MCP authentication deserves its own treatment because OAuth, browser clients, protected resource metadata, and token handling change the shape of the client.
Discovering Tools, Resources, and Prompts
A good MCP client in C# should not assume every server exposes the same shape. Discovery is the point of the protocol. After McpClient.CreateAsync, you can inspect ServerCapabilities, ServerInfo, and ServerInstructions, then list tools, resources, resource templates, and prompts.
Tools are callable functions. Resources are readable pieces of data. Prompts are reusable prompt templates. If you blur those responsibilities in your client, you end up with awkward code. Treat each as a different contract.
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
await using McpClient client = await McpClient.CreateAsync(transport);
Console.WriteLine($"Server: {client.ServerInfo.Name} {client.ServerInfo.Version}");
Console.WriteLine($"Instructions: {client.ServerInstructions ?? "(none)"}");
IList<McpClientTool> tools = await client.ListToolsAsync();
IList<McpClientResource> resources = await client.ListResourcesAsync();
IList<McpClientResourceTemplate> templates = await client.ListResourceTemplatesAsync();
IList<McpClientPrompt> prompts = await client.ListPromptsAsync();
Console.WriteLine($"Tools: {tools.Count}");
Console.WriteLine($"Resources: {resources.Count}");
Console.WriteLine($"Resource templates: {templates.Count}");
Console.WriteLine($"Prompts: {prompts.Count}");
foreach (var prompt in prompts)
{
Console.WriteLine($"Prompt: {prompt.Name} -- {prompt.Description}");
}
if (resources.FirstOrDefault() is { } resource)
{
ReadResourceResult read = await client.ReadResourceAsync(resource.Uri);
foreach (var content in read.Contents.OfType<TextResourceContents>())
{
Console.WriteLine($"[{content.MimeType}] {content.Text}");
}
}
Discovery also gives you a chance to decide whether the server is appropriate for your scenario. A MOFU decision point here is whether your client should be permissive or constrained. A permissive client lists everything and lets a higher-level planner choose. A constrained client filters by tool name, annotations, server identity, or configuration before anything can be called.
For most production applications, I prefer an explicit allowlist around tools that can mutate state. Even if a model or orchestration layer eventually picks the tool, the MCP client in C# can still enforce what capabilities are exposed to that layer.
Calling Tools and Handling Content Blocks
The SDK gives you two convenient ways to call tools. You can call by name with client.CallToolAsync(...), or you can list tools and call an McpClientTool instance with tool.CallAsync(...). The latter is useful when you want to keep the tool metadata and invocation behavior together.
Tool results deserve careful handling. A result can contain multiple content blocks, and those blocks may be text, images, audio, or embedded resources. A tool can also return IsError = true without throwing a protocol exception. That distinction matters: tool errors are part of the tool result, while protocol failures usually surface as McpException.
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
try
{
McpClientTool searchTool = (await client.ListToolsAsync())
.Single(tool => tool.Name == "search_docs");
CallToolResult result = await searchTool.CallAsync(
new Dictionary<string, object?>
{
["query"] = "HttpClient streaming",
["maxResults"] = 5
});
if (result.IsError is true)
{
string message = result.Content
.OfType<TextContentBlock>()
.FirstOrDefault()?.Text ?? "The tool returned an error.";
Console.Error.WriteLine(message);
return;
}
foreach (var block in result.Content)
{
switch (block)
{
case TextContentBlock text:
Console.WriteLine(text.Text);
break;
case ImageContentBlock image:
File.WriteAllBytes("tool-output.png", image.DecodedData.ToArray());
break;
case AudioContentBlock audio:
File.WriteAllBytes("tool-output.wav", audio.DecodedData.ToArray());
break;
case EmbeddedResourceBlock embedded when embedded.Resource is TextResourceContents resource:
Console.WriteLine($"Resource {resource.Uri}: {resource.Text}");
break;
}
}
}
catch (McpException ex)
{
Console.Error.WriteLine($"MCP protocol failure: {ex.Message}");
}
This is where an MCP client in C# needs application judgment. If the result is going straight to a user, text might be enough. If the result is going to an LLM or another planner, annotations, structured content, and embedded resources can matter. If the tool mutates state, log the operation and preserve enough context to audit why it was called.
There is one integration note worth making without turning this into an agent tutorial: McpClientTool inherits from Microsoft.Extensions.AI.AIFunction. That means tools returned by ListToolsAsync can be handed to MEAI-compatible callers. If your actual goal is consuming MCP tools inside Microsoft Agent Framework, I already have a focused article on MCP Tool Integration in Microsoft Agent Framework in C#. Here, the focus stays on the client API itself.
Registering Notification Handlers
Some MCP servers can notify connected clients when their available tools, resources, or prompts change. In the C# SDK, McpSession.RegisterNotificationHandler(...) is inherited by McpClient. It returns an IAsyncDisposable, so you can unregister the handler when the client or feature scope ends.
The important transport nuance is that unsolicited notifications require a transport/session shape that can deliver them. Stdio and stateful HTTP can support this. Stateless Streamable HTTP does not support unsolicited server-to-client notifications because every message must be part of a client request/response flow.
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
await using McpClient client = await McpClient.CreateAsync(transport);
await using IAsyncDisposable toolListSubscription = client.RegisterNotificationHandler(
NotificationMethods.ToolListChangedNotification,
async (_, cancellationToken) =>
{
IList<McpClientTool> updatedTools = await client.ListToolsAsync(
cancellationToken: cancellationToken);
Console.WriteLine($"Tool list changed. {updatedTools.Count} tools are now available.");
});
await client.PingAsync(cancellationToken: CancellationToken.None);
Notifications are a good fit when the server's capabilities are dynamic. For example, a server might expose tools based on a connected workspace, installed plugins, or user-specific configuration. If the capabilities are static, listing once at startup may be enough.
A practical pattern is to build a small client-side capability cache and refresh it when list-change notifications arrive. Keep the cache invalidation logic boring. If the refresh fails, keep the last known safe list and log the failure through your normal observability path. If you need to brush up on production logging habits, Logging in .NET is a good foundation.
Lifecycle and Disposal Decisions
The lifecycle for an MCP client in C# starts before CreateAsync. You choose a transport, configure security and timeout settings, and decide who owns dependent objects like HttpClient and ILoggerFactory. CreateAsync then establishes the session and runs initialization. After that, the client can list capabilities, call tools, read resources, retrieve prompts, register handlers, and ping the server.
Disposal is not just cosmetic. For stdio, disposal gives the child process a chance to shut down gracefully based on ShutdownTimeout. For HTTP, disposal can end the session depending on transport options such as OwnsSession, and it can dispose the transport-owned HttpClient if you configured ownership that way. If you're using a shared or factory-created HttpClient, don't accidentally let the transport own it.
The decision model I use is simple. Short-lived console app? Create the transport, create the client, do the work, dispose everything with await using. Long-running service? Treat the client as a connection with health checks, logging, cancellation tokens, and a reconnection story. Remote server with session resumption? Look at KnownSessionId and McpClient.ResumeSessionAsync(...), but only if your server and scenario actually need it.
This is also where you choose how much of the protocol to expose upward. Some applications should expose every discovered McpClientTool. Others should map MCP results into a smaller domain-specific interface. Both approaches are valid. The tradeoff is flexibility versus control.
Common Pitfalls When Building an MCP Client in C#
The first pitfall is assuming the server is stable forever. MCP is built around discovery. Cache when it helps, but design for the tool list, resource list, or prompt list to change.
The second pitfall is treating all tool failures as exceptions. A tool can report IsError = true as a normal tool result. Your client should inspect that property and handle content blocks instead of assuming every failure becomes an exception.
The third pitfall is being too casual with stdio environment inheritance. If your client launches untrusted servers, disable inheritance and pass an explicit environment dictionary. That one setting can be the difference between a contained local integration and accidental credential exposure.
The fourth pitfall is overfitting to SSE. The SDK docs describe SSE as legacy. For remote servers, Streamable HTTP is the recommended path, and HttpClientTransport can auto-detect when compatibility is needed. If you're already dealing with HTTP streaming concepts, HttpClient streaming in C# can help with the underlying mental model.
The fifth pitfall is skipping lifecycle ownership. If your MCP client in C# runs in a web app or worker, decide where the client is created, who disposes it, how cancellation flows, and what happens when the server disappears.
FAQ
What package should I use for an MCP client in C#?
Use ModelContextProtocol.Core if you only need the client and low-level APIs with the smallest dependency footprint. Use ModelContextProtocol if you want the common SDK package that also includes hosting and DI-oriented pieces. The SDK getting started guide says ModelContextProtocol is the right starting point for most projects.
Should an MCP client in C# use stdio or HTTP?
Use stdio when the server should run as a local child process, such as a developer tool or desktop integration. Use HTTP when the server is remote, shared, or hosted behind normal web infrastructure. Streamable HTTP is the recommended remote transport, while SSE is legacy and mainly relevant for compatibility.
How does HttpClientTransport choose between Streamable HTTP and SSE?
HttpClientTransportOptions.TransportMode defaults to HttpTransportMode.AutoDetect. In that mode, the SDK tries Streamable HTTP first and falls back to SSE if needed. If you control the server and know it supports Streamable HTTP, you can set TransportMode = HttpTransportMode.StreamableHttp explicitly.
How do I list tools from an MCP client in C#?
After creating a connected client with McpClient.CreateAsync, call await client.ListToolsAsync(). The result is an IList<McpClientTool>. Each tool includes properties such as Name, Description, and JsonSchema, and you can invoke it with tool.CallAsync(...).
How should I handle tool errors?
Check CallToolResult.IsError. Tool execution errors can be returned as normal tool results with error content blocks, especially when the server wants the caller or model to recover. Protocol-level failures are different and may throw McpException.
Can I use MCP tools with Microsoft.Extensions.AI?
Yes, McpClientTool inherits from AIFunction, so discovered MCP tools can be passed to MEAI-compatible callers. That said, deeply consuming MCP tools inside Microsoft.Extensions.AI, Semantic Kernel, or Microsoft Agent Framework is a separate integration topic, not the focus of this client-side article.
Do notification handlers work with every MCP transport?
No. Tool list, resource list, and prompt list change notifications require a transport/session that can receive unsolicited server-to-client messages. Stdio and stateful HTTP can support that pattern. Stateless Streamable HTTP cannot send unsolicited notifications outside a client request/response flow.
Final Thoughts on Building an MCP Client in C#
A useful MCP client in C# is more than a call to CreateAsync. The design work is in the boundaries: which transport fits, which environment variables a child process receives, which capabilities you expose upward, how you handle result content, and how you dispose or reconnect the session.
Start small. Connect with StdioClientTransport or HttpClientTransport. List tools, resources, and prompts. Call one tool and handle every content block you receive. Then add the production decisions: allowlists, notification handlers, logging, cancellation, and lifecycle ownership. That's where the client moves from a demo into something you can confidently build on.

