You can give a .NET agent a tool two ways: write a C# method and register it (function calling), or connect to an MCP server that exposes tools over a standard protocol. Both end up as an AITool the agent can call — so which should you use? People often reach for MCP because it’s new and interesting, when a plain method would be simpler. This article is the decision guide.
For the mechanics, see the tool-using agent tutorial (function calling) and the MCP guide (MCP).
The one-line difference
- Function calling = a tool that lives inside your app as a C# method. The agent calls it in-process.
- MCP = a tool that lives in a separate server, spoken to over a standard JSON-RPC protocol. The agent discovers and calls it over stdio or HTTP.
Everything below follows from “in your process” versus “in a separate server.”
Reach for function calling when…
A local C# method is the right default for most tools:
- The logic is yours and specific. “Look up this customer’s orders in our database” is your business logic — write it as a method, test it, ship it.
- You want the least moving parts. No extra process, no protocol, no network hop. Just a method the agent can call.
- It’s tightly coupled to your app’s data and types. In-process access to your services, DI container, and domain models is simpler than marshaling across a protocol boundary.
- Latency matters. An in-process call has none of MCP’s transport overhead.
If you’re writing the tool anyway and only your agent will use it, function calling is almost always the answer. Don’t add a protocol you don’t need.
Two advantages of the local method are easy to overlook until you’ve lost them. The first is that the compiler is on your side: rename a parameter and every call site fails the build, so a tool contract can’t drift out from under you. An MCP tool’s schema is checked at runtime, if at all — a server author renaming an argument produces a tool call the server rejects, in production, in a way your CI never saw. The second is the error surface. An in-process tool throws a typed exception you can catch, log with a stack trace, and map to a message you control. A failure across a protocol boundary arrives as a JSON-RPC error or, worse, as a plain text result that the model reads and may decide to retry blindly, three times, because nothing told it not to.
Testing follows the same split. A C# method is a unit test. An MCP tool is an integration test that needs the server running, which for a stdio server means spawning a process in CI and cleaning it up afterwards. That’s tractable, and it is strictly more work than Assert.Contains.
Reach for MCP when…
MCP earns its extra complexity when the tool needs to be shared or external:
- You want to consume tools someone else built. Official MCP servers exist for GitHub, filesystems, databases, and more. Connecting to one is far less work than reimplementing it as C# methods.
- The same tool must serve many clients. If a Python agent, a VS Code session, and your .NET agent all need the same capability, an MCP server exposes it once for all of them — no per-language reimplementation.
- You want a clean boundary between the agent and the capability. Different team, different deployment lifecycle, different security domain — a server boundary maps to an organizational one.
- You’re publishing a capability for others. Wrapping a tool (or a whole agent) as an MCP server lets anyone with an MCP client use it.
The theme: MCP is about reuse and interoperability across boundaries. If there’s no boundary to cross, it’s overhead.
What MCP charges you for that convenience
The pitch for connecting to a ready-made server is that you get a dozen tools for free. The bill arrives in three places, and none of them appear in a getting-started tutorial.
Every tool’s schema is in the prompt, every request. Tool definitions aren’t free context — name, description and full JSON schema for each one goes to the model on every call so it can choose. Connect to a server exposing thirty tools and you’ve added thirty schemas to every request in the conversation, whether the user asked about repositories or about the weather. That’s input tokens on a loop, and it’s the less serious half of the problem. The worse half is selection accuracy: models pick worse from long, similar lists. Two tools whose descriptions both begin “search for…” is a coin flip the model performs on your behalf. The mitigation is to filter the server’s tool list down to the handful you actually want rather than passing everything ListToolsAsync returns — a single line of LINQ that most integrations skip.
Names collide. Two servers each exposing search gives the model an ambiguous menu and gives you no obvious place to disambiguate. Prefix on ingest if you’re connecting to more than one.
The process has a lifecycle you now own. A stdio server is a child process. It can fail to start because the runtime isn’t installed on the container image, it can die mid-conversation and leave the next tool call hanging, and its first-run start-up — particularly for anything fetched by a package runner at launch — lands on a user’s request. In production this belongs behind a health check and a timeout, exactly like any other dependency, which is a sentence nobody writes in the enthusiasm of the first integration.
And then there’s trust. Tool descriptions and tool results are model-visible text arriving from a system you don’t control, which makes a third-party MCP server an injection surface rather than merely a dependency. Treat an unfamiliar server the way you’d treat a NuGet package that has network access and side effects, because that’s exactly what it is.
What changed in the C# SDK 2.x
MCP tooling in .NET stopped being a moving target in a way that matters for this decision. Version 2.0.0 of the C# SDK shipped on 28 July 2026, with 2.2.0 following on 13 August. Anything you read that describes the SDK as prerelease, or that quotes 1.4.0 as current, predates a major version with real changes on the wire.
One myth is worth dispatching first, because it wastes migration time: the expected rename from McpClientFactory and SseClientTransport to McpClient and HttpClientTransport isn’t the breaking change. Both McpClient and HttpClientTransport already existed in 1.4.0. Client-side type names barely moved. The break is in the protocol, not the API surface.
The change that actually matters is architectural. The 2026-07-28 protocol revision removed the initialize handshake and the session id, so every request carries its own protocol version, client info and capabilities in a namespaced _meta block, plus Mcp-Method and Mcp-Name headers. State that used to live in a session on the server now lives in a client-side cache, described by a server/discover response carrying a time-to-live and a CacheScope of Public or Private — the same distinction as HTTP’s Cache-Control, and for the same reason: a shared proxy must not serve one user’s private tool list to another.
If you’re writing a C# client, none of this is your problem — the SDK negotiates it and you never hand-write a header. If you’re calling an MCP server from anything else, you send all of it yourself, and the errors are specific enough to diagnose quickly: a missing Mcp-Method header returns -32020 Missing required Mcp-Method header., and incomplete _meta returns -32602 naming what’s absent. Note that the _meta keys are namespaced — io.modelcontextprotocol/protocolVersion, not protocolVersion. A plain key is ignored silently, which reads as the server not implementing the feature.
The one trap worth knowing before you build a server
HttpServerTransportOptions.Stateless now defaults to true, and stateless mode disables the server’s ability to call back to the client — sampling, elicitation and roots are all unavailable, because there’s no channel to make a request over. Multi Round-Trip Requests are the replacement: rather than pushing a request down a session, the handler suspends and returns the request inside its own response for the client to resolve on the next round trip.
The obvious reaction — “I need sampling, so I’ll set Stateless = false” — is a trap. That property is a convenience proxy over a three-valued SessionMode, and setting it to false selects Stateful, which refuses every 2026-07-28 client with -32022 UnsupportedProtocolVersion so that dual-path clients downgrade. For a mixed fleet the value you want is StatefulForInitializeClients, which serves old clients with a full session and new ones per request on the same endpoint. Getting this backwards produces an outage that looks like a client bug.
For a decision guide the takeaway is narrow but real: publishing an MCP server is a protocol-versioning commitment, not just a wrapper around your method. A local C# tool has no wire format to get wrong.
A quick decision table
| Situation | Use |
|---|---|
| Tool is your own business logic, only your agent uses it | Function calling |
| Consuming a pre-built tool (GitHub, filesystem, DB) | MCP |
| Same tool needed by multiple agents/languages | MCP |
| Lowest latency, tightest coupling to your app | Function calling |
| Publishing a capability for others to call | MCP |
| Prototyping, keeping it simple | Function calling |
You don’t have to choose globally
The best part: an MCP tool and a C# method are both just AITool to the agent, so a single agent can use both at once. A typical production agent has a handful of local C# methods for its own business logic and connects to one or two MCP servers for shared or third-party capabilities. Mixing them is normal and encouraged — the decision is per-tool, not per-agent.
The hybrid that usually wins
There’s a third arrangement worth naming, because it solves problems both pure approaches have. Rather than handing the MCP server’s tools to the agent directly, wrap the ones you want in your own C# methods and expose those.
The wrapper costs you a few lines and buys back everything the protocol boundary took away. You get to inject the tenant id and the authenticated user’s scope rather than trusting the agent to pass them. You get to rename get_file_contents to something your prompt uses consistently, and rewrite a description written for a general audience into one that describes your use of it. You get a place to put a timeout, a retry policy and a log line. You get to filter thirty tools down to four before they ever reach the prompt. And your agent’s tool surface stops changing when someone else ships a new version of their server.
The cost is that you’ve reintroduced a hand-written layer, which is what MCP was meant to remove. That trade is worth it in exactly one situation, which is also the common one: you’re consuming somebody else’s server in a production application where auth, cost and stability matter. It’s not worth it for a local developer tool, where the point is convenience and there’s no tenant to scope.
When not to reach for MCP
Skip it for tools that only your agent will call. Skip it when the tool needs your DI container, your DbContext and your domain types — marshalling those across a JSON-RPC boundary is work with no payoff. Skip it while prototyping, because a method you can delete is cheaper to be wrong about than a server you have to deploy.
Be sceptical of one specific pattern: splitting a single application’s tools across an MCP server “for architectural cleanliness” when both halves are yours, ship together, and are used by nothing else. That buys a protocol, a process and a versioning obligation in exchange for a boundary nobody needed. If the same team owns both sides and deploys them together, it isn’t a boundary — it’s a method call with extra steps.
And if you’re publishing a server, make sure someone actually wants it. A capability nobody else consumes has all of MCP’s costs and none of its benefit.
Note: the SDK details above were verified against
ModelContextProtocol.AspNetCore2.2.0. MCP is moving quickly, so check the C# SDK releases for anything version-specific. The architectural trade-off — in-process methods for your own logic, MCP for shared or external capabilities — is durable regardless of API changes.
Takeaway
Default to function calling for tools that are your own logic and used only by your agent — it’s simpler, faster, and easier to test. Reach for MCP when a capability needs to be reused across agents or languages, consumed from someone else’s server, or published for others. And remember it’s not either/or: a single .NET agent happily uses local methods and MCP servers together, choosing per tool. Use the protocol when you’re crossing a boundary — and skip it when you’re not.
Have a correction or a topic you want covered? Email mani.bc72@gmail.com.
