Originally published as a longer guide on AI Wisdom.
You put a bearer token on your MCP endpoint, the scope check passes, and the server feels done. Then the model asks for another tenant's deployments, and nothing stops it.
It usually starts small. Someone wires your internal search into a Semantic Kernel plugin for one assistant. A month later another team wants the same search in their LangChain agent, and a third wants it inside VS Code. Each gets its own copy of the tool, with its own schema, credentials and bugs. When the search API changes, you find out how many copies exist by watching them break.
The Model Context Protocol gives that tool one home: you expose it once from an MCP server, and compatible clients discover it. Getting a demo running takes an afternoon. Getting one you can hand to other teams is where the real decisions are.
Who this is for
- You have built tools for AI agents, but each one only works with one framework
- You want to expose internal APIs to AI assistants through a standard interface
- You want VS Code Copilot or Claude to work with your own systems
The short version
- The token on your
/mcpendpoint is only the outer fence. Every sensitive tool checks permission itself. - Since the 2026-07-28 spec revision, MCP is stateless: no handshake, no session ID, no sticky sessions.
- The model picks tools by their descriptions, so a vague description is a bug, not a style issue.
The endpoint token is not the permission check
RequireAuthorization on the endpoint proves the caller has a valid token with the right scope. It says nothing about which service, tenant or record that caller may touch.
The model chooses the arguments, so a tool that trusts them will read another tenant's data for anyone who asks nicely. Each sensitive tool has to derive the caller from the validated identity and authorize the specific resource before doing anything:
[McpServerTool(Name = "list_recent_deployments")] [Description("List recent deployments for a service")] public async Task<string> ListDeployments( [Description("Service name")] string service, [Description("Number of deployments to return")] int count = 5) { await callerAccess.RequireServicePermissionAsync(service, "deployments:read"); var response = await _http.GetAsync( $"/api/deployments?service={Uri.EscapeDataString(service)}&top={count}"); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); } The callerAccess implementation never trusts a tenant or service name supplied by the model. It is built from validated claims for HTTP, and from an explicit local policy for stdio.
Prove it with negative tests
A permission check you have not tried to break is a guess. Write these before the second tool, not after the first incident:
- No token at all
- A token from the wrong issuer
- A token for the wrong audience
- An expired token
- A valid token with a missing scope
- A valid token asking for another tenant's resource
If the cross-tenant test passes, you have a backdoor with a nice schema.
stdio is not a pass either. It limits who can reach the server, but the server still runs with your host's permissions and can reach the network. Pin trusted server code and scope its credentials.
Run it stateless, because the protocol now is
Until this year, an MCP client ran an initialize handshake, got an Mcp-Session-Id, and the server remembered that session. Fine on a laptop. Behind a load balancer it meant sticky routing or a shared session store, just to serve what is usually a read-only lookup.
The 2026-07-28 revision drops all of that: each request carries its own version and capabilities, so any replica can answer it. Here is the HTTP host from the full guide, trimmed to the part that matters. Look at the transport line and the last MapMcp call:
// ... JWT bearer authentication and the scope policy are registered above builder.Services .AddMcpServer() // Stateless is the default in the v2 SDK. Spelled out here because it is // the decision that lets you run several replicas behind a load balancer. .WithHttpTransport(options => options.SessionMode = HttpServerSessionMode.Stateless) .WithTools<DevOpsTools>(); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapMcp("/mcp").RequireAuthorization(RequiredScope); app.Run(); For local tools, stdio is still the simple choice. For remote access, use Streamable HTTP; the older HTTP+SSE transport is deprecated.
Let the gateway see which tool is called
The same revision adds Mcp-Method and Mcp-Name headers. A gateway can now route and rate limit by tool without parsing the JSON body, so you can throttle an expensive search_logs without touching the cheap health check.
If you are migrating an existing server, search for anything that reads the session ID first. Moving that state out is where the real work is.
Write tool descriptions like API docs
The model decides which tool to call from its description. Compare:
| Description | What the model learns |
|---|---|
| "Get data" | Nothing. It will guess. |
| "Get current health status including uptime percentage, error rate, and last deployment timestamp for a named service" | When to call it, what comes back, and what argument it needs |
The description is the model's API documentation. Specific descriptions lead to the right tool; vague ones lead to wrong calls.
Keep output small and writes confirmed
Two habits that pay off on day one:
- Return the minimum the model needs. Thousands of lines flood the context window and make its reasoning worse. Paginate, cap row counts, summarise large results.
- Confirm before anything that creates, updates or deletes. The model might decide to "clean up" by deleting things. MCP does not enforce confirmation; your tool has to.
What I'd do first
Start with stdio and one read-only tool that a team actually wants, such as deployment status or scoped log search. Add Streamable HTTP only when remote access is justified.
And if you only have one assistant and one tool, a direct integration is simpler. MCP pays off once the same tool is being copied across several assistants. It adds a process you have to run, version and secure, so it is not free.
Go deeper
The full guide on AI Wisdom covers what this post leaves out:
- The complete five-step .NET build: stdio server, tool definitions, VS Code
mcp.json, Streamable HTTP with JWT bearer auth, and resources - Everything the 2026-07-28 revision removed or replaced, in one table, including
input_required,subscriptions/listenand the Tasks extension - Why
requestStateneeds integrity protection, and what the TypeScript SDK codemod does and does not migrate - Long-running tools that survive dropped connections and redeploys
- A completion checklist to run before handing the server to another team
| Full guide on AI Wisdom | |
|---|---|
![]() | MCP Servers: Building Tool-Using AI Agents with the Model Context Protocol How MCP works, how to build MCP servers that expose tools to AI agents, and practical patterns for connecting LLMs to your systems. Read the full guide on aiwisdom.dev → |
Over to you: open the MCP server your team uses most and check one tool that reads data. Does it check only the token, or the tenant too? Reply with "token only" or "per tool", and I will answer every comment.
The free Production AI System Checklist is a 6-page PDF with 30 checks from the same site.

