Connecting a .NET chat app to an MCP server is now a short job. McpClient.CreateAsync, ListToolsAsync(), put the tools in ChatOptions.Tools, add UseFunctionInvocation(), and the model starts calling tools. It's a great demo.
What the demo skips is that every one of those tools belongs to someone else. A server can offer tools you never meant to expose, reuse a name another server already uses, take 30 seconds to answer, return 14 KB of text, or return a page that tells your model to ignore its instructions. And some days the server just isn't there.
Tech Skill Builder is $5/month for a very limited time (normally $10) — grab it now before it goes back to $10. You get 20+ complete, tested .NET + AI projects with full source code and PDF guides, and a new one added every day. 👉 Join Tech Skill Builder
These are the seven rules I now follow on the client side, the chat host that consumes MCP servers. They come from a host I built on the official MCP C# SDK (ModelContextProtocol 2.2.0) and Microsoft.Extensions.AI 10.11 in .NET 10, with 70 offline tests (43 unit, 27 integration). The code below is the shape of each rule, not the full implementation; the omitted parts are marked.
A stdio server is a process your app launches. By default, StdioClientTransport passes it your host's entire environment, including whatever API keys live there. The server you installed from a package registry five minutes ago doesn't need your model key.
new StdioClientTransport(new StdioClientTransportOptions
{
Name = server.Name,
Command = server.Command!,
InheritEnvironmentVariables = false,
EnvironmentVariables = StdioClientTransportOptions.GetDefaultEnvironmentVariables(),
// Arguments and logger omitted here
}, loggerFactory);
GetDefaultEnvironmentVariables() is the SDK's small curated set (things like PATH and HOME). Start there and add only what a given server really needs. For remote servers I also pin the HTTP transport to Streamable HTTP instead of letting it auto-detect and possibly fall back to legacy SSE.
Never pass a server's tool list straight to the model. Each server in config gets an exact, case-sensitive allowlist, and an empty list exposes nothing. Fail closed.
Don't build this on tool annotations either. The MCP spec says clients must treat annotations as untrusted unless they come from trusted servers, so readOnlyHint: true is a claim, not a guarantee.
var allowed = new HashSet<string>(server.AllowedTools, StringComparer.Ordinal);
// for each discovered tool: Exposed = allowed.Contains(name),
// otherwise hidden with reason "not in allowlist" (full policy omitted here)
Hidden tools still show up in a status endpoint, so you can see what a server offered and you said no to.
Two servers can both have a tool called search, and MCP names can be up to 128 characters with dots, while common model providers want function names of letters, digits, _ and -, at most 64 characters.
So the model sees {prefix}_{tool}, sanitized and cut to 64: weather_search and docs_search instead of two search tools. Prefixes are validated as unique at startup, and a final pass hides any collision that sanitizing still produces.
The renaming lives in a wrapper built on DelegatingAIFunction, which is also where rules 4 to 6 happen:
public sealed class GuardedMcpTool : DelegatingAIFunction
{
public override string Name => _exposedName; // e.g. "docs_search"
public override string Description => $"[{_server} server] {_tool.Description}";
protected override async ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments args, CancellationToken ct)
{
// timeout, raw CallAsync, truncate, fence, audit
// (full implementation in the complete project)
}
}
Every tool call gets its own timeout (10 seconds by default in my host), linked to the request's token. Two details matter:
McpClient.Completion turns that into an immediate "server unavailable".
A timeout comes back to the model as a fenced error ("timed out, don't retry in this turn"), not as an exception that kills the request. A cancellation from the caller is different: that one is rethrown.
Tool output goes into your prompt, so it's a cost and a risk. The formatter pulls text out of the content blocks, swaps images and audio for a placeholder, falls back to structured JSON when there's no text, and cuts the result to a character budget (4,000 by default). The cut is announced in-band, like ...[truncated: showing 600 of 14377 chars], so the model knows it's looking at partial output.
Characters aren't tokens. It's a cheap, predictable ceiling, which is what you want here.
Every result goes back wrapped in a <tool_result ... trust="untrusted"> fence, and any <tool_result or </tool_result inside the payload is neutralized so a server can't close the fence early. The system prompt says what's inside the fence is data, never instructions. A small regex tripwire flags phrases like "ignore previous instructions" or "call the X tool" and logs a warning.
Be honest about what that buys you. Fencing and a regex don't stop prompt injection; they make it less likely and much more visible. The real protection is rule 2: in my sample, the poisoned result asks the model to call a delete tool that was never exposed, so there's nothing to trigger. Destructive tools you really need belong behind human approval.
While you're there, audit every call: server, real and exposed name, outcome, duration, result size, truncation and injection flags, and the argument names. Not the values; they carry customer data.
All enabled servers connect in parallel, each with its own connect timeout (5 seconds by default). A failure doesn't throw. The server is marked unavailable with a short reason and isn't retried until a cooldown passes (30 seconds by default), so one dead server doesn't add a connect timeout to every message.
The chat continues with whatever tools are left, and the system prompt names the missing servers so the model says so instead of inventing an answer. The chat endpoint still returns 200, with the missing servers listed in unavailableServers.
You don't need API keys to test a guarded MCP host, and you can still put real MCP on the wire:
System.IO.Pipelines, with StreamServerTransport on one end and StreamClientTransport on the other.StdioClientTransport. TestServer.
Make the sample servers awkward on purpose: duplicate tool names, a tool that must never be exposed, a slow tool, a huge result and a poisoned one. Then test three kinds of "server down" (unknown, missing command, refused port) and a crash in the middle of a session.
All 70 tests run offline against a fake model, so I haven't measured how a real model behaves with the fenced results. Plugging one in means registering its IChatClient before the MCP services, without its own UseFunctionInvocation().
The complete, working .NET 10 solution behind this post is in Tech Skill Builder: the guarded MCP host (Core, minimal API and CLI), the sample servers and the stdio companion, all 70 offline tests, and a step-by-step PDF guide. No API key needed to run it.
Tech Skill Builder is $5/month for a very limited time (normally $10) — grab it now before it goes back to $10. You get 20+ complete, tested .NET + AI projects with full source code and step-by-step PDF guides, and a new one is added every day. 👉 Join Tech Skill Builder
Which MCP server would you be most nervous to give your chat app, and what would you lock down first?