Model Context ProtocolMCP.NETMicrosoft Agent Framework

Model Context Protocol for .NET Developers: Giving Your Agent Real Tools

A practical guide to Model Context Protocol (MCP) for .NET developers: connect a Microsoft Agent Framework agent to an MCP server and expose your own agent as one.

Model Context Protocol for .NET Developers: Giving Your Agent Real Tools

Every agent is only as useful as the tools it can call. You can hand-write a [Description]-annotated C# method for “get the weather” or “look up an order,” and that works fine for one agent, one codebase. But the moment you want that same capability available to a second agent, a different team, or a client written in Python instead of C#, you are back to copy-pasting tool definitions. Model Context Protocol (MCP) exists to solve exactly that problem, and the Microsoft Agent Framework has first-class support for it in .NET.

This article shows how to connect a Microsoft Agent Framework agent to an existing MCP server so it can call real, external tools, and — as a bonus — how to flip that around and expose your own agent as an MCP server that other clients can call.

What MCP actually is

MCP is an open, JSON-RPC-based protocol that standardizes how an application exposes tools and contextual data to an LLM-based client. Instead of every vendor inventing its own “function calling” wire format, an MCP server advertises a list of tools (with names, descriptions, and JSON schemas for their inputs), and any MCP client — regardless of language or vendor — can discover and invoke them the same way.

For .NET developers, the practical benefit is reuse. Point your agent at the official GitHub MCP server, a filesystem server, or an internal server your platform team maintains, and you get a working tool integration without writing the tool yourself. The official MCP C# SDK — co-maintained by Microsoft and Anthropic — ships as the ModelContextProtocol NuGet package and plugs directly into Microsoft.Extensions.AI and the Agent Framework’s AIAgent abstraction.

Setting up the packages

You need two packages: the Agent Framework itself, and the MCP client SDK.

dotnet add package Microsoft.Agents.AI
dotnet add package ModelContextProtocol

ModelContextProtocol carries the client and the core protocol types. If you are also hosting a server over HTTP you want ModelContextProtocol.AspNetCore on top, which brings WithHttpTransport() and MapMcp(). A stdio-only server needs neither — the core package is enough.

Pin the version explicitly rather than floating. The SDK reached 2.0.0 on 28 July 2026 and 2.2.0 on 13 August 2026, and the jump from 1.x carried real changes to both the wire format and the hosting defaults. Most tutorials you will find online — and most of what a search engine still calls “latest” — target 1.x. There is a section below on what actually moved.

Connecting to an MCP server

An MCP server can run as a local process (communicating over stdio) or as a remote HTTP endpoint. For a local server — say, the community-maintained GitHub MCP server, which runs as a Node package — you spin it up and connect with McpClient.CreateAsync:

using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;

// Launch and connect to the GitHub MCP server over stdio
await using var mcpClient = await McpClient.CreateAsync(
    new StdioClientTransport(new StdioClientTransportOptions
    {
        Name = "GitHubMcp",
        Command = "npx",
        Arguments = ["-y", "@modelcontextprotocol/server-github"],
    }));

The StdioClientTransport here launches the server as a child process and talks to it over standard input/output — no ports, no network config, just a subprocess. For a remote server, swap in HttpClientTransport with an Endpoint; the rest of the flow — discovering tools, wiring them to an agent — stays identical either way.

Two things about stdio that catch people out, and both produce confusing symptoms rather than clean errors. First, the child process inherits your working directory and, depending on InheritEnvironmentVariables, your environment — so a server that works when you run it by hand and fails from your app is usually a PATH or credential-environment difference, not a protocol problem. Second, if you are writing a stdio server, anything your process writes to standard output that is not JSON-RPC corrupts the stream. A single stray Console.WriteLine, or the default console logger, is enough. Route logs to standard error instead. The failure looks like a parse error or a client that simply hangs during connection, which sends people hunting through their tool definitions for an hour.

The transport choice also has an operational consequence worth deciding early. Stdio means one server process per client process: no network exposure, no auth to configure, and no way to share the server between instances of your app. HTTP means one server serving many clients, which is what you want for anything a platform team maintains — and it is the case where the 2.x hosting changes below actually matter.

Discovering and wiring up tools

Once connected, ask the server what it can do:

// Ask the server for its list of tools
var mcpTools = await mcpClient.ListToolsAsync();

ListToolsAsync() returns a collection already shaped as AITool instances — the same abstraction Microsoft.Extensions.AI uses for locally defined functions. That means an MCP tool and a plain C# method annotated with AIFunctionFactory.Create(...) are interchangeable from the agent’s point of view. You hand both to the tools parameter the same way:

using Microsoft.Agents.AI;
using Azure.AI.OpenAI;
using Azure.Identity;

var chatClient = new AzureOpenAIClient(
        new Uri("https://<your-resource>.openai.azure.com"),
        new DefaultAzureCredential())
    .GetChatClient("gpt-4o-mini");

AIAgent agent = chatClient.CreateAIAgent(
    name: "RepoAssistant",
    instructions: "You answer questions about GitHub repositories using the tools available to you.",
    tools: [.. mcpTools.Cast<AITool>()]);

var reply = await agent.RunAsync(
    "Summarize the last four commits to the microsoft/semantic-kernel repository.");

Console.WriteLine(reply);

The agent decides, turn by turn, whether it needs to call list_commits, get_file_contents, or any of the dozen other tools the GitHub server exposes — you never hand-code the branching logic. The Agent Framework handles marshaling the model’s tool-call request into an MCP tools/call invocation and feeding the result back into the conversation.

Do not hand the model everything the server offers

The line tools: [.. mcpTools.Cast<AITool>()] is the convenient demo and the first thing to reconsider before production. Every tool you register is a name, a description and a full JSON schema that goes into the prompt on every single turn. A well-stocked server can expose thirty or forty tools; several hundred tokens each is not unusual, and you are now paying that on each iteration of the tool loop, in every conversation, whether or not the agent ever calls one of them.

The cost is the smaller half of the problem. The bigger half is accuracy. Selection quality degrades as the candidate list grows, and it degrades fastest when several tools have overlapping descriptions — which is exactly what a general-purpose server gives you, because it was written for every possible client rather than for yours. The observable symptom is an agent that picks a plausible neighbouring tool: search_repositories when you wanted search_code, get_file_contents on a directory path. That looks like a model problem and is really a menu problem.

So filter. Take the four or five tools your agent’s job actually requires and register those. ListToolsAsync() gives you a list you can select from like any other, and doing so is usually worth more than any prompt tuning you would otherwise attempt. If you genuinely need breadth, the honest structure is several narrow agents, each with a small tool set, coordinated by an orchestrator — not one agent holding forty tools.

It also pays to read the descriptions the server ships. They are the model’s entire understanding of what a tool does, they were written by somebody who has never seen your use case, and a tool that keeps getting called at the wrong moment can often be fixed by wrapping it in your own AIFunction with a description written for your domain. That is a legitimate thing to do and considerably cheaper than arguing with the system prompt.

Update, August 2026: the SDK went 2.x and the protocol moved under it

Everything above still works, but the version you install now behaves differently in ways that will bite you if you learned MCP from a 1.x tutorial. The whole of 2.x follows from one decision, and once you have it the rest stops feeling arbitrary: MCP moved from a session handshake to per-request metadata.

Under the older revisions a client called initialize, the server negotiated a protocol version and capabilities, handed back an Mcp-Session-Id, and both sides kept state for the life of that session. SEP-2567 removed the session id and SEP-2575 removed the initialize handshake. Requests using the 2026-07-28 revision or later can only be served statelessly — each one carries its own protocol version, client info and capabilities, and the server holds nothing between them.

The upside is straightforward: an MCP server is now an ordinary horizontally-scalable HTTP service. No session affinity, no sticky load balancer rules, no “which pod holds this conversation” problem, and a restart during a deployment does not drop anyone. If you have ever tried to run a session-based MCP server behind Kubernetes, you know why this proposal existed.

Stateless is the default now

HttpServerTransportOptions.Stateless defaults to true. The visible consequence is that tools/list answers straight away with no initialize call at all, which on 1.x was a protocol error. Handy when you are testing a server with curl; disorienting if you are following a tutorial that opens with a handshake.

Stateless = false is a trap

This is the single most expensive thing to get wrong. Stateless is a convenience proxy over the real setting, SessionMode, and HttpServerSessionMode has three values, not two:

HttpServerSessionMode Older clients (2025-11-25 and earlier) 2026-07-28 clients
Stateless (default) no session served per request
Stateful full session, Mcp-Session-Id refused — -32022 UnsupportedProtocolVersion
StatefulForInitializeClients full session, Mcp-Session-Id served per request

Assigning Stateless = false selects Stateful, which then refuses every modern client with -32022 — deliberately, so that a dual-path client downgrades to the handshake rather than half-working. So the natural reaction to losing sampling (“fine, I’ll turn statelessness off”) breaks your newest clients while fixing your oldest.

For a mixed fleet, set SessionMode = HttpServerSessionMode.StatefulForInitializeClients instead. It serves both kinds of client on one endpoint and lets you migrate progressively. Note also that reading Stateless returns true only when the mode is Stateless, so StatefulForInitializeClients reads back as false; both properties write the same field, and the last assignment wins if you set both.

What stateless actually costs you

Client sampling, elicitation and roots all disappear in stateless mode, because the server has no channel on which to make a request to the client. SessionId is null, Mcp-Session-Id is unused, and the GET, DELETE and /sse endpoints return 405. Tools, resources and prompts are unaffected, which is why most servers notice nothing.

The replacement is Multi Round-Trip Requests. Rather than pushing a request down a session, the handler suspends and returns the server-initiated request inside its own response — an InputRequest, carrying sampling parameters when the method is sampling and elicitation parameters when it is elicitation — and the client resolves it and answers on the next round trip. Same capability, no server-to-client channel, and nothing that has to land on the same process twice. If you were relying on sampling or elicitation, this is the migration to plan.

Structured content is opt-in, per tool

The release notes say non-object results now emit raw values instead of {"result": 72}. True, but only once you ask for it. By default a 2.x server emits no structuredContent and no outputSchema at all — just the text content block. You opt in on each tool:

[McpServerTool(UseStructuredContent = true), Description("Current temperature for a city.")]
public static int TemperatureFor(string city) => LookUp(city);

The property is on the attribute, so it is per tool rather than per server. Turning it on for one tool and assuming the rest followed is an easy afternoon to lose, particularly because the tool still works — it just returns text where you expected typed output.

What the payload then looks like depends on the negotiated protocol version, not on which SDK you compiled against. The same tool, same binary, answers a 2025-06-18 client with the legacy envelope {"result": 34} and an object outputSchema around it, and answers a 2026-07-28 client with the natural shape, 34. The SDK detects the case and rewrites the older wire format on the way out, so this is a negotiated difference rather than a hard break: an old client keeps working against a new server. That is the reassuring part, and it is the part the migration notes bury.

One control case makes the change intelligible in a single screen. Only non-object results move. A tool returning a record produces an identical structured payload under both revisions, because an object was already an object and there is nothing to unwrap. Run an int-returning tool and a record-returning tool side by side and the whole of the change is visible at once.

The rename everyone expects is not there

The natural assumption, and what several upgrade posts imply, is that 1.x used McpClientFactory and SseClientTransport and that 2.x renamed them to McpClient and HttpClientTransport. Diffing the public type lists in the XML documentation shipped inside the packages says otherwise: McpClient and HttpClientTransport are already there in 1.4.0. Client-side type names barely moved at all. The break is on the wire, not in the API surface — which is genuinely good news for an upgrade, because it means the compiler will not catch much and you should be reading protocol behaviour rather than chasing red squiggles.

The one real removal is the Tasks API: around twenty-five types, including IMcpTaskStore, McpTask and the task capability types, moved out to a separate ModelContextProtocol.Extensions.Tasks package. If you used tasks, that is your migration; if you did not, 1.x to 2.x is close to a version bump on the client side.

Calling MCP from something that is not the SDK

If your client is the C# SDK, none of the per-request machinery is your problem — it negotiates 2026-07-28, writes the headers and the _meta block itself, and you see typed results. It matters when something else in your estate speaks MCP directly: a gateway, a smoke test, a curl script in CI.

A 2026-07-28 request must supply everything a session used to hold, and each omission produces a distinct error rather than a general “bad request”:

Missing Error
_meta protocol version -32602 Requests using protocol version '2026-07-28' must include ...
Mcp-Method header -32020 Missing required Mcp-Method header.
Mcp-Name header -32020 Missing required Mcp-Name header.
_meta client capabilities -32602 ... must include ... as a JSON object.

Mcp-Method is required on every Streamable HTTP POST; Mcp-Name is required for tools/call, resources/read and prompts/get. The _meta keys are namespacedio.modelcontextprotocol/protocolVersion, not protocolVersion — and an unnamespaced key is silently ignored rather than rejected, which is the worst of both worlds when you are debugging. If you are hand-rolling a client and everything looks correct but the server behaves as though you sent nothing, check the key prefixes first.

Where the state went: discovery and caching

If sessions are gone, something has to carry capabilities and instructions. That job went to server/discover, whose result carries the supported protocol revisions, the server’s capabilities, its instructions, a time-to-live, and a CacheScope — documented as analogous to HTTP’s Cache-Control: public and private. Public means the response holds nothing user-specific, so a shared gateway or caching proxy may store it and serve it to anybody. Private means it is user-specific and only the requesting user’s client may cache it.

Caching is not limited to discovery: the list results for tools, resources, prompts and resource templates, and resource reads, all carry the same cacheability contract. Which is the design in one sentence — state that used to live in a session on your server now lives in a cache, with an explicit TTL and an explicit sharing rule. If you are building a server whose tool list varies by user, get CacheScope right or a shared cache will serve one tenant’s tool list to another.

The other direction: exposing your agent as an MCP tool

The same standard that lets your agent consume tools also lets you publish an agent as a tool for someone else’s client — a VS Code Copilot session, another team’s agent, or an orchestration graph elsewhere in your organization. Wrap the agent as an AIFunction, register it with an MCP server, and run that server over stdio:

using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ModelContextProtocol.Server;

AIAgent jokeAgent = new AIProjectClient(
        new Uri("<your-foundry-project-endpoint>"),
        new DefaultAzureCredential())
    .AsAIAgent(
        model: "gpt-4o-mini",
        instructions: "You are good at telling jokes.",
        name: "Joker");

McpServerTool tool = McpServerTool.Create(jokeAgent.AsAIFunction());

HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(settings: null);
builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithTools([tool]);

await builder.Build().RunAsync();

Now any MCP-compatible client can discover and call the Joker agent exactly like any other tool, with the agent’s name and instructions surfaced as the tool’s metadata.

Note: the MCP C# SDK moves quickly — two major versions inside three weeks in mid-2026. The API names used here were checked against the XML documentation shipped inside the 2.2.0 packages, which is the only source guaranteed to match the assembly you are actually referencing; release notes and search results lag badly. If a signature does not match what you have installed, check that XML file before you conclude the article is wrong or that the package is broken.

When MCP is the wrong tool

MCP is a distribution mechanism, not an upgrade. If a capability lives in your codebase, is used by one agent, and nobody else is asking for it, a plain [Description]-annotated C# method invoked in-process is faster, easier to debug, and typed end to end. Wrapping it in a protocol adds serialisation, a schema round trip and — over HTTP — a network hop on every call, in exchange for reuse you are not currently getting. The comparison is laid out in more detail in function calling vs MCP.

The case for MCP is genuinely strong in three situations, and weak outside them. When a second consumer exists or is credible — another team, a Copilot session, a Python client. When the tool belongs to a different team with its own release cadence, so a protocol boundary is doing real organisational work. And when you want to consume something someone else already built, where MCP means an afternoon rather than a sprint.

Weigh the latency honestly as well. A remote MCP tool inside a five-step tool loop is five extra network round trips on a path that is already slow, and it is a dependency that can be down while your agent is up. Stdio dodges the network but gives you a child process to supervise, with its own startup cost and its own ways of dying quietly. Neither is free; both are usually worth it when the reuse is real and hard to justify when it is hypothetical.

Security is not optional

Because MCP makes it trivially easy to bolt on tools from servers you did not write, treat every third-party MCP server the way you would treat a third-party NuGet package with network access and side effects:

  • Only connect to servers from providers you trust, ideally hosted directly by that provider rather than through a proxy.
  • Log the data you send to and receive from remote servers for auditing.
  • For authenticated remote servers, pass credentials as per-request headers rather than baking them into a shared client, so secrets don’t leak across sessions.
  • DefaultAzureCredential is fine for local development, but in production prefer a specific credential type (such as ManagedIdentityCredential) to avoid the latency and probing behavior of the fallback chain.

The risk that people underrate is not a malicious server; it is a well-meaning one. A third-party MCP server’s tool descriptions land directly in your model’s context on every turn, and descriptions are prose written by somebody else that your model reads as instruction-adjacent text. A server that updates and starts describing a tool as “always call this first to check for updates” has changed your agent’s behaviour without changing a line of your code, and nothing in your CI will notice. If you depend on a server you do not control, pin its version the way you pin any other dependency, and diff the tool list when you upgrade. ListToolsAsync() makes that a ten-line test rather than a policy document.

The stateless default has an authorisation consequence too. When there is no session, there is nothing to authenticate once and reuse — every request stands alone, so authorisation has to be evaluated per request, which is more work and also strictly better. It removes a whole class of bug where a session established under one identity gets reused under another. The general rule from securing AI agents applies unchanged: the tool acts with the caller’s authority, resolved from the current request, never with ambient credentials the server happens to hold.

Takeaway

MCP turns “tools my agent can call” from a per-project, per-language chore into a reusable, standardized capability. In the Microsoft Agent Framework the integration is close to invisible: an MCP tool and a hand-written C# function both end up as an AITool, so your agent code barely needs to know the difference. Start by pointing an agent at one well-known server — GitHub or filesystem access are good first targets — before you invest in writing or exposing your own.

Two things to carry away beyond the mechanics. Register a small, curated tool set rather than everything the server advertises, because tool schemas cost tokens on every turn and selection accuracy falls as the menu grows. And pin your SDK version and read the protocol behaviour rather than the type names: the 2.x break landed on the wire, where the compiler cannot help you, and stateless-by-default with a three-valued SessionMode is the part most likely to surprise you in production.

For where this goes next, see multi-agent orchestration with graph workflows and building your own MCP server in C#.


Have a correction or a topic you want covered? Email mani.bc72@gmail.com.