A production engineer's guide to wrapping an existing ASP.NET Core API with the Model Context Protocol — without tearing out your JWT stack.
Last quarter a product manager dropped a Slack message that a lot of us are getting now: "Can I just ask Claude to pull the open invoices from our system?"
We already had the API. Fully built, JWT-secured, battle-tested in production. The problem was never the data — it was that an LLM has no idea our /api/invoices?status=open endpoint exists, and even if it did, it can't read our OpenAPI spec and authenticate itself.
The Model Context Protocol (MCP) is the missing adapter. This is how you bolt it onto an API you already own, in an afternoon, and what to do about authentication before security reviews it.
The Core Concept: MCP Is an Adapter, Not a Rewrite
MCP is an open protocol that lets AI clients (Claude Desktop, Gemini, Cursor, and others) discover and call your capabilities as "tools." You do not rewrite your API. You stand up a thin MCP server that exposes selected endpoints as tools and forwards the calls.
AI Chat (Claude / Gemini)
│ MCP protocol (JSON-RPC)
▼
┌──────────────────────┐
│ MCP Server (.NET) │ ← [McpServerTool] methods
│ - GetOpenInvoices │
│ - CreateTicket │
└─────────┬────────────┘
│ HttpClient + Bearer/API key
▼
┌──────────────────────┐
│ Existing REST API │ ← unchanged, still JWT-secured
└──────────────────────┘
The MCP server is a translator: it speaks JSON-RPC to the model and plain HTTP to your existing API. Your business logic never moves.
Step 1: Stand Up the MCP Server
Use the official C# SDK (maintained together with Microsoft). Add it to a new minimal ASP.NET Core project so you can host a remote MCP server over Streamable HTTP:
dotnet add package ModelContextProtocol.AspNetCore
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services
.AddMcpServer()
.WithHttpTransport() // Streamable HTTP for remote clients
.WithToolsFromAssembly(); // discover [McpServerTool] methods
// Typed client to your EXISTING API
builder.Services.AddHttpClient("BackendApi", static client =>
{
client.BaseAddress = new Uri("https://api.internal.t1tech.com/");
});
WebApplication app = builder.Build();
app.MapMcp(); // exposes the /mcp endpoint
app.Run();
That is the entire host. MapMcp() wires up discovery, so any compliant AI client can enumerate your tools.
Step 2: Turn an Endpoint Into a Tool
A tool is just a method. The attributes and XML-style descriptions are not decoration — the model reads them to decide when and how to call you. Be explicit; vague descriptions cause hallucinated arguments.
[McpServerToolType]
public sealed class InvoiceTools
{
private readonly IHttpClientFactory _httpClientFactory;
public InvoiceTools(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
[McpServerTool]
[Description("Returns open (unpaid) invoices for a given customer ID.")]
public async Task<string> GetOpenInvoices(
[Description("The numeric customer identifier.")] int customerId,
CancellationToken cancellationToken)
{
HttpClient client = _httpClientFactory.CreateClient("BackendApi");
HttpResponseMessage response = await client.GetAsync(
$"api/invoices?customerId={customerId}&status=open",
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
}
Notice there is no token in that call yet. That is the entire fight, and the next two sections are where production engineering actually happens.
Step 3: The JWT Question — Whose Identity Is Calling?
Your API trusts a JWT. The naive instinct is to bake a service account token into the MCP server. Do not. That collapses every user into one identity and hands the LLM god-mode over your data.
The correct model: the MCP server is an OAuth 2.1 Resource Server. The AI client authenticates the human, receives a token, and passes it through. The MCP Authorization spec standardizes this with Protected Resource Metadata (RFC 9728), so the client can discover where to log in.
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(static options =>
{
options.Authority = "https://login.t1tech.com/";
options.Audience = "mcp-invoice-server";
});
builder.Services.AddAuthorization();
// ...after Build()
app.UseAuthentication();
app.UseAuthorization();
app.MapMcp().RequireAuthorization();
Then forward the caller's identity instead of a hardcoded secret. Grab the incoming token from HttpContext and attach it downstream:
public InvoiceTools(
IHttpClientFactory httpClientFactory,
IHttpContextAccessor httpContextAccessor)
{
_httpClientFactory = httpClientFactory;
_httpContextAccessor = httpContextAccessor;
}
private async Task<HttpClient> CreateAuthorizedClientAsync(CancellationToken ct)
{
HttpClient client = _httpClientFactory.CreateClient("BackendApi");
string? token = await _httpContextAccessor.HttpContext!
.GetTokenAsync("access_token");
if (!string.IsNullOrEmpty(token))
{
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
}
return client;
}
Now the existing API sees the real user's scopes and roles. Your authorization rules keep working, untouched. The LLM cannot read an invoice the human behind it could not read.
Step 4: Do You Need an API Key Instead? Pick By Client Type
JWT/OAuth is right for interactive chat where a human is present. But not every MCP consumer is a person clicking "Authorize." Choose the scheme by who connects:
- Remote HTTP server, human in a chat client → OAuth 2.1 with JWT pass-through. This is the spec's blessed path. Full user identity, scoped access, revocable.
-
Local
stdioserver or machine-to-machine automation → API key. When there is no interactive login (a scheduled agent, a local dev tool), issue a scoped API key and read it from configuration, never from source.
// API key path — for non-interactive / stdio clients
string apiKey = builder.Configuration["Backend:ApiKey"]
?? throw new InvalidOperationException("Missing Backend:ApiKey.");
client.DefaultRequestHeaders.Add("X-Api-Key", apiKey);
My recommendation for a team shipping this: default to OAuth 2.1 JWT pass-through for anything remote, and reserve API keys for headless integrations where you can mint narrowly scoped, per-integration keys and rotate them. Treat a static API key like a password — short TTL, per-client, logged, revocable. Never a single shared secret with full API surface.
The Flow: What Actually Happens When Claude Uses Your Tool
Code is abstract until you watch a real request move through it. Here is the end-to-end round trip when a user types "Show me the open invoices for customer 4821" into Claude.
But first — the question everyone asks: where does Claude get that JWT? It is not magic and Claude does not "have your token." The MCP client obtains it through a standard OAuth 2.1 handshake the first time it connects, and this is worth seeing on its own.
[0a] Claude → MCP Server: first call, no token
← 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.t1tech.com/.well-known/..."
[0b] Claude reads Protected Resource Metadata (RFC 9728)
→ learns which Authorization Server (your IdP) issues tokens
[0c] Claude runs OAuth 2.1 Authorization Code + PKCE:
→ opens a browser window
→ USER logs in at login.t1tech.com and clicks "Allow"
→ IdP redirects back with an auth code
→ Claude exchanges code → access_token (JWT) + refresh_token
[0d] Claude securely stores the token and reuses it.
Refresh happens silently; the login prompt does NOT repeat each message.
The critical point: the human authenticates directly with your identity provider, not with Claude. Claude never sees a password. It receives a scoped, expiring JWT through the same OAuth flow your web frontend would use — and your MCP server, as the Resource Server, advertises where that login lives via the 401 challenge in Step 0a.
On the .NET side, RequireAuthorization() already returns the 401. The one extra thing you owe the client is the discovery pointer in Step 0b — register the MCP server as a protected resource so the SDK emits the resource_metadata challenge and serves the metadata document:
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMcp(static options =>
{
// Advertises this server as an OAuth 2.1 Resource Server
options.ResourceMetadata = new ProtectedResourceMetadata
{
AuthorizationServers = { new Uri("https://login.t1tech.com/") }
};
});
That single registration is what turns "Claude somehow has a token" into a discoverable, spec-compliant login the client can drive on its own.
Only after that handshake does the everyday request loop below run:
[1] USER → Claude Chat
"Show me the open invoices for customer 4821."
[2] Claude → MCP Server: discovery (once, on connect)
"What tools do you have?"
← [{ name: "GetOpenInvoices",
description: "Returns open (unpaid) invoices for a customer ID.",
params: { customerId: int } }]
[3] Claude reasons:
intent = list open invoices → tool = GetOpenInvoices
extracts argument → customerId = 4821
[4] Claude → MCP Server: tools/call (JSON-RPC)
Authorization: Bearer <the signed-in user's JWT>
{ "name": "GetOpenInvoices", "arguments": { "customerId": 4821 } }
[5] MCP Server validates the JWT, then forwards downstream:
GET /api/invoices?customerId=4821&status=open
Authorization: Bearer <same JWT — user identity preserved>
[6] Existing REST API applies the user's scopes → returns JSON
[7] MCP Server → Claude: tool result (raw JSON)
[{ "id": 5567, "amount": 1200.00, "due": "2026-09-15" }, ...]
[8] Claude → USER:
"Customer 4821 has 2 open invoices totaling $2,050 —
one due Sep 15 ($1,200) and one due Oct 2 ($850)."
Two things are worth pausing on.
Step 2 happens once, not per message. The client caches your tool catalog on connect. This is why the [Description] text is load-bearing — the model chooses tools purely from it, long before your code ever runs.
Step 4 to Step 6 is the whole security story. The JWT the human logged in with is the same JWT that reaches your API. Claude never sees a service credential, and it can never retrieve an invoice the signed-in user isn't authorized to see. The model orchestrates; your API still decides.
If you want to see this concretely, the tool result at Step 7 is exactly the string your GetOpenInvoices method returned — Claude does the natural-language summarization in Step 8. You return data; the model handles the prose.
Practical Impact
The payoff is not "AI hype." It is that a capability you already built becomes usable by a new class of client with near-zero duplication.
Your team writes one thin tool method per endpoint you want to expose — not a second API. Because identity flows through, your security posture is unchanged: the same JWT, the same scopes, the same audit trail. And because tools are just decorated C# methods, they unit-test like any other service, and new endpoints become new tools in minutes.
The long-term maintainability win is that MCP is a stable seam. When you swap Claude for Gemini, or add a third client, nothing changes on your side. You built the adapter once.
Actionable Takeaways
-
Wrap, don't rewrite. Stand up a separate MCP server that forwards to your existing API over
HttpClient. Business logic stays put. -
Write ruthless tool descriptions. The model chooses tools from your
[Description]text. Vague text produces bad calls. - Pass the user's identity through — never hardcode a service token. Make the MCP server an OAuth 2.1 Resource Server and forward the caller's JWT so existing authorization still applies.
-
Choose auth by client type. OAuth 2.1/JWT for interactive remote clients; scoped, rotatable API keys only for headless
stdioor M2M integrations. - Ship the smallest surface first. Expose read-only tools before write tools, and let least-privilege scopes gate everything the LLM can reach.
Top comments (0)