cd /news/developer-tools/give-your-net-rest-api-an-ai-mouth-a… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-118820] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

Give Your .NET REST API an AI Mouth: Adding MCP So Claude and Gemini Can Actually Use It

A developer at T1Tech detailed how to give a .NET REST API an AI interface by adding the Model Context Protocol (MCP), enabling AI clients like Claude and Gemini to discover and call API endpoints as tools. The approach uses the official C# SDK to create a thin MCP server that translates between AI clients and the existing JWT-secured API, with authentication handled via OAuth 2.1 to preserve user identity.

read8 min views1 publishedSep 2, 2026

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.

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.

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.

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.

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.

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:

stdio

server or machine-to-machine automation β†’ API key.

// 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.

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.

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.

HttpClient

. Business logic stays put.[Description]

text. Vague text produces bad calls.stdio

or M2M integrations.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @t1tech 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/give-your-net-rest-a…] indexed:0 read:8min 2026-09-02 Β· β€”