← Pekka Ylenius

Building an MCP Gateway for Microservices: A Lightweight Approach with .NET and K8s (Part 1)

10 Mar 2026 · 12 min read · Originally published on Medium

MCP (Model Context Protocol) is the standard way to connect AI clients — Claude Desktop, VS Code Copilot, custom chat UIs — to your backend services. Each service exposes tools that the AI can discover and call. But if you have five or ten microservices, you don’t want to configure each one as a separate MCP server in every client. You need a single entry point.

Enterprise solutions already exist. Microsoft’s MCP Gateway is a reverse proxy with session-aware routing and lifecycle management for Kubernetes. Azure API Management extends APIM to handle MCP traffic using existing Azure policies and monitoring. Docker’s MCP Gateway is an open-source proxy that centralizes config, credentials, and access control. IBM ContextForge federates MCP, A2A, and REST with governance and observability. AWS MCP Proxy handles SigV4 auth for AWS-hosted servers. These are solid choices for large organizations with dedicated platform teams.

But if you’re a small team with a handful of .NET microservices and you want to understand exactly what’s happening. You can build it yourself in about 300 lines. That’s what we did, and this article walks through the key parts of it.

We’ll build five things:

  1. A startup discovery loop that connects to downstream services and collects their tools
  2. A forwarding tool that proxies calls with service-name prefixes
  3. A claims-to-headers pattern for propagating auth context to internal services
  4. A tenant selection system where admins pick a merchant per session, stored in Redis
  5. Extension points (MCP request filters) for access control, rate limiting, and audit logging

All built with the official “ModelContextProtocol.AspNetCore” SDK. All code simplified from production — you can copy it and adapt it.

The Discovery Loop

The gateway needs to know which downstream services exist and what tools they offer. We handle this with a simple configuration dictionary — service name mapped to its internal URL:

{
"McpGateway": {
"DownstreamServers": {
"os": "http://orderservice:8080/",
"ps": "http://productservice:8080/",
"ns": "http://notificationservice:8080/",
"cs": "http://invoiceservice:8080/"
}
}
}

In Kubernetes, those URLs are just internal service DNS names. No ingress, no public endpoints — the gateway is the only thing exposed externally.

At startup, the gateway connects to each service, calls “ListToolsAsync()” to discover what tools it offers, and wraps each tool in a “GatewayForwardingTool” with a service prefix:

var gatewayOptions = builder.Configuration
.GetSection("McpGateway")
.Get<GatewayOptions>() ?? new GatewayOptions();

var allGatewayTools = new List<McpServerTool>();
var downstreamConnections = new List<DownstreamServiceConnection>();

foreach (var (name, endpoint) in gatewayOptions.DownstreamServers)
{
try
{
// Build HTTP pipeline: ClaimsForwardingHandler → ContextEnrichingHandler → HttpClientHandler
var claimsForwarder = new ClaimsForwardingHandler(httpContextAccessor)
{
InnerHandler = new ContextEnrichingHandler(httpContextAccessor)
{
InnerHandler = new HttpClientHandler()
}
};
var httpClient = new HttpClient(claimsForwarder);

var transport = new HttpClientTransport(
new HttpClientTransportOptions { Endpoint = new Uri(endpoint), Name = name },
httpClient,
ownsHttpClient: false);

var client = await McpClient.CreateAsync(transport);
var tools = await client.ListToolsAsync();

var connection = new DownstreamServiceConnection(name, endpoint, httpClient, client, tools);
downstreamConnections.Add(connection);

foreach (var tool in tools)
{
allGatewayTools.Add(new GatewayForwardingTool(tool, name, connection));
}
}
catch (Exception ex)
{
Log.Error(ex, "Failed to connect to '{Name}' at {Endpoint}. Skipping.", name, endpoint);
}
}

Log.Information("Gateway initialized with {TotalTools} total tools", allGatewayTools.Count);

The Forwarding Tool

Each discovered tool gets wrapped in a GatewayForwardingTool. A tool called “search_orders” from the Order Service becomes “os_search_orders” at the gateway level. The AI sees one flat list of prefixed tools — it doesn’t know or care about the service topology behind them.

public sealed class GatewayForwardingTool : McpServerTool
{
private readonly DownstreamServiceConnection _connection;
private readonly string _downstreamToolName;
private readonly Tool _protocolTool;

public GatewayForwardingTool(
McpClientTool clientTool,
string namePrefix,
DownstreamServiceConnection connection)
{
_connection = connection;
_downstreamToolName = clientTool.ProtocolTool.Name;

// Create a prefixed copy of the tool definition
_protocolTool = new Tool
{
Name = $"{namePrefix}_{clientTool.ProtocolTool.Name}",
Description = clientTool.ProtocolTool.Description,
InputSchema = clientTool.ProtocolTool.InputSchema,
OutputSchema = clientTool.ProtocolTool.OutputSchema,
Annotations = clientTool.ProtocolTool.Annotations,
};
}

public override Tool ProtocolTool => _protocolTool;

public override async ValueTask<CallToolResult> InvokeAsync(
RequestContext<CallToolRequestParams> request,
CancellationToken cancellationToken = default)
{
var arguments = request.Params?.Arguments?
.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value);

var clientTool = _connection.GetTool(_downstreamToolName);
if (clientTool == null)
return ErrorResult($"Tool '{_downstreamToolName}' is not available.");

try
{
return await clientTool.CallAsync(arguments, cancellationToken: cancellationToken);
}
catch (HttpRequestException ex) when (IsSessionLost(ex))
{
// Downstream pod restarted — reconnect and retry once
var freshTool = await _connection.ReconnectAndGetToolAsync(
_downstreamToolName, cancellationToken);

if (freshTool == null)
return ErrorResult($"Tool '{_downstreamToolName}' no longer exists after reconnection.");

return await freshTool.CallAsync(arguments, cancellationToken: cancellationToken);
}
}

private static bool IsSessionLost(HttpRequestException ex)
=> ex.StatusCode == HttpStatusCode.NotFound
&& ex.Message.Contains("Session not found");

private static CallToolResult ErrorResult(string message) => new()
{
IsError = true,
Content = [new TextContentBlock { Text = message }]
};
}

Two things worth highlighting:

No re-serialization. The CallToolResult from the downstream service passes straight through to the AI client. The gateway doesn’t parse or transform the response — it’s a passthrough.

Session-loss retry. In Kubernetes, pods restart. When a downstream service restarts, the MCP session is gone. The gateway detects the “Session not found” error, reconnects (creating a fresh MCP session), re-discovers the tool, and retries the call — all transparently. The AI never knows a pod restarted.

The DownstreamServiceConnection manages the reconnection with a semaphore so concurrent calls don’t trigger multiple reconnects:

public sealed class DownstreamServiceConnection : IAsyncDisposable
{
private readonly SemaphoreSlim _reconnectLock = new(1, 1);
private McpClient? _client;
private Dictionary<string, McpClientTool> _toolsByName = new();

public McpClientTool? GetTool(string toolName)
=> _toolsByName.GetValueOrDefault(toolName);

public async Task<McpClientTool?> ReconnectAndGetToolAsync(
string toolName, CancellationToken ct = default)
{
await _reconnectLock.WaitAsync(ct);
try
{
if (_client != null)
await _client.DisposeAsync();

var transport = new HttpClientTransport(
new HttpClientTransportOptions { Endpoint = new Uri(_endpoint), Name = Name },
_httpClient, ownsHttpClient: false);

_client = await McpClient.CreateAsync(transport, cancellationToken: ct);
var tools = await _client.ListToolsAsync();
_toolsByName = tools.ToDictionary(t => t.Name);

return _toolsByName.GetValueOrDefault(toolName);
}
finally { _reconnectLock.Release(); }
}
}

Claims Forwarding — Auth Without Shared Secrets

Here’s the problem: your downstream services are internal — ClusterIP in Kubernetes, no public endpoint. The gateway already validated the JWT. Making every downstream service also validate JWTs means sharing signing keys or configuring JWKS discovery in every service. That’s unnecessary coupling.

Instead, the gateway converts JWT claims into simple HTTP headers, and downstream services reconstruct a ClaimsPrincipal from those headers. Two components make this work.

Gateway Side: Claims to Headers

A DelegatingHandler on the gateway’s outgoing HTTP pipeline reads the current user’s claims and sets “X-MCP-*” headers:

public class ClaimsForwardingHandler(
IHttpContextAccessor httpContextAccessor) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var user = httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated == true)
{
var name = user.Identity.Name;
if (name != null)
request.Headers.TryAddWithoutValidation("X-MCP-User", name);

var cid = user.FindFirst("urn:myapp:cid")?.Value;
if (cid != null)
request.Headers.TryAddWithoutValidation("X-MCP-Cid", cid);

var mid = user.FindFirst("urn:myapp:mid")?.Value;
if (mid != null)
request.Headers.TryAddWithoutValidation("X-MCP-Mid", mid);

var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value);
request.Headers.TryAddWithoutValidation("X-MCP-Roles", string.Join(",", roles));
}
else
{
// No user (e.g. startup discovery) — send service identity
request.Headers.TryAddWithoutValidation("X-MCP-User", "mcp-gateway");
request.Headers.TryAddWithoutValidation("X-MCP-Roles", "Admins");
}

return base.SendAsync(request, cancellationToken);
}
}

The else branch matters — during startup, the gateway calls “ListToolsAsync()” to discover tools. There’s no HTTP request context yet, no user. The handler sends a service identity so downstream auth doesn’t reject the discovery call.

Downstream Side: Headers to ClaimsPrincipal

Each downstream service registers an AuthenticationHandler that reads the headers and builds a ClaimsPrincipal:

public class InternalMcpAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "InternalMcp";

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("X-MCP-User", out var userName)
|| string.IsNullOrEmpty(userName))
return Task.FromResult(AuthenticateResult.Fail("Missing X-MCP-User header"));

var claims = new List<Claim> { new(ClaimTypes.Name, userName!) };

if (Request.Headers.TryGetValue("X-MCP-Cid", out var cid))
claims.Add(new("urn:myapp:cid", cid!));

if (Request.Headers.TryGetValue("X-MCP-Mid", out var mid))
claims.Add(new("urn:myapp:mid", mid!));

if (Request.Headers.TryGetValue("X-MCP-Roles", out var roles))
foreach (var role in roles.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries))
claims.Add(new(ClaimTypes.Role, role.Trim()));

var identity = new ClaimsIdentity(claims, SchemeName, ClaimTypes.Name, ClaimTypes.Role);
var principal = new ClaimsPrincipal(identity);
return Task.FromResult(
AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
}
}

The downstream service now has a fully hydrated ClaimsPrincipal with the user’s name, tenant ID, roles — everything it needs for authorization. The same authorization attributes you use on your regular API controllers [Authorize(Roles = “Admins”)] work unchanged on MCP tools. No JWT validation, no JWKS, no shared secrets.

Security note: This works because downstream services are not publicly accessible. The headers are trusted because only the gateway can reach them. If you expose downstream services externally, you need a different approach.

Tenant Selection — Admin Picks a Merchant Per Session

This is the pattern you’ll need if you have multi-tenant services where some users (admins, support agents) can access multiple tenants.

Here’s the scenario: a support agent logs in. Their JWT doesn’t contain a specific tenant — they have access to all of them. Before they can use tools like “search orders” or “check stock levels,” they need to pick which merchant they’re working with.

Two Token Types

We support two kinds of tokens:

  1. Regular token: Tenant ID is baked into the JWT. The user belongs to one merchant. Done.
  2. Deferred-context token: No tenant claim. Contains a “deferred-context: true” flag instead. User must select a merchant before other tools work.

The Selection Flow

The gateway has four local tools (not forwarded to downstream services):

  • list_merchants — shows available merchants for this user
  • list_channels — shows channels for a specific merchant
  • select_merchant_channel — stores the selection in Redis
  • get_current_context — shows what’s currently selected

A typical conversation:

AI: "No merchant selected. Let me check what's available."
[calls list_merchants → gets 200 merchants]
AI: "Which merchant would you like to work with?"
User: "Acme Corp"
AI: [calls select_merchant_channel with Acme Corp's ID]
"Selected Acme Corp. How can I help?"
User: "Show me their late orders"
AI: [calls os_get_late_orders → works because merchant context is now set]

Redis-Backed Session Context

The selection is stored in Redis with a key combining user ID and MCP session ID:

public class McpContextService(
IHttpContextAccessor httpContextAccessor,
IDistributedCache cache) : IMcpContextService
{
private static readonly TimeSpan ContextTtl = TimeSpan.FromHours(8);

public async Task<McpUserContext?> GetContextAsync()
{
var key = GetContextKey();
if (key == null) return null;

var json = await cache.GetStringAsync(key);
return json != null ? JsonSerializer.Deserialize<McpUserContext>(json) : null;
}

public async Task SetContextAsync(Guid merchantId, int channelId, string? merchantName)
{
var key = GetContextKey()
?? throw new InvalidOperationException("No user or session to set context for.");

var context = new McpUserContext
{
MerchantId = merchantId,
ChannelId = channelId,
MerchantName = merchantName
};

await cache.SetStringAsync(key, JsonSerializer.Serialize(context),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ContextTtl });
}

private string? GetContextKey()
{
var user = httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true) return null;

var userId = user.FindFirst("sub")?.Value;
if (userId == null) return null;

var sessionId = httpContextAccessor.HttpContext?
.Request.Headers["Mcp-Session-Id"].ToString();

return string.IsNullOrEmpty(sessionId) ? userId : $"{userId}:{sessionId}";
}
}

The session ID comes from the MCP protocol’s “Mcp-Session-Id” header — the SDK sets it automatically. This means the same user can have different merchant selections in different MCP clients (Claude Desktop vs. a custom chat UI) without interference.

Enriching Downstream Requests

When a context is selected, the ContextEnrichingHandler overrides the forwarded headers before they reach the downstream service:

public class ContextEnrichingHandler(
IHttpContextAccessor httpContextAccessor) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var httpContext = httpContextAccessor.HttpContext;
var user = httpContext?.User;

if (user?.Identity?.IsAuthenticated == true)
{
var contextService = httpContext!.RequestServices
.GetRequiredService<IMcpContextService>();
var mcpContext = await contextService.GetContextAsync();

if (mcpContext != null)
{
var tokenMid = user.FindFirst("urn:myapp:mid")?.Value;

if (tokenMid == null)
{
// Deferred token: override merchant from session
request.Headers.Remove("X-MCP-Mid");
request.Headers.TryAddWithoutValidation(
"X-MCP-Mid", mcpContext.MerchantId.ToString());
}

// Always override channel from session
request.Headers.Remove("X-MCP-Cid");
request.Headers.TryAddWithoutValidation(
"X-MCP-Cid", mcpContext.ChannelId.ToString());
}
}

return await base.SendAsync(request, cancellationToken);
}
}

There’s a subtlety here: if the user’s token already has a merchant baked in, the enricher only overrides the channel — you can’t switch merchants, only pick which channel of your merchant to work with. For deferred-context tokens, both merchant and channel come from the session.

The GatewayForwardingTool also checks for deferred context. If a user with a deferred-context token tries to call a tool before selecting a merchant, it returns an error:

// Inside GatewayForwardingTool.InvokeAsync, before forwarding:
if (user.FindFirst("deferred-context")?.Value == "true")
{
var mcpContext = await contextService.GetContextAsync();
if (mcpContext == null)
{
return new CallToolResult
{
IsError = true,
Content = [new TextContentBlock
{
Text = "No merchant selected. Call `list_merchants` then " +
"`select_merchant_channel` before using other tools."
}]
};
}
}

This error message is for the AI, not the user. The AI reads it, understands it needs to help the user select a merchant, and initiates that flow automatically.

Extension Points: Access Control, Rate Limits, and More

The MCP C# SDK provides request filters — middleware that runs before tools are listed or invoked. You register them with “WithRequestFilters()” on the MCP server builder. This is where you add any cross-cutting logic.

builder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools(allGatewayTools)
.WithTools<MerchantContextTools>()
.WithRequestFilters(filters =>
{
// Filter which tools appear in tool listings
filters.AddListToolsFilter(next => async (context, ct) =>
{
var result = await next(context, ct);
var user = httpContextAccessor.HttpContext?.User;
result.Tools = result.Tools
.Where(t => toolAccessPolicy.IsToolAllowed(t.Name, user))
.ToList();
return result;
});

// Block unauthorized tool invocations
filters.AddCallToolFilter(next => async (context, ct) =>
{
var toolName = context.Params?.Name;
var user = httpContextAccessor.HttpContext?.User;
if (toolName != null && !toolAccessPolicy.IsToolAllowed(toolName, user))
{
return new CallToolResult
{
IsError = true,
Content = [new TextContentBlock
{
Text = $"Access denied: you do not have permission to use '{toolName}'."
}]
};
}
return await next(context, ct);
});
});

Tool Access Control

Our ToolAccessPolicy is keyword-based — simple but effective. Tools with “admin” in the name require the “Admins” role:

public class ToolAccessPolicy(IEnumerable<ToolAccessRule>? rules = null)
{
private static readonly List<ToolAccessRule> DefaultRules =
[
new() { Keyword = "admin", RequiredRole = "Admins" }
];

private readonly List<ToolAccessRule> _rules =
rules?.ToList() is { Count: > 0 } r ? r : DefaultRules;

public bool IsToolAllowed(string toolName, ClaimsPrincipal? user)
{
foreach (var rule in _rules)
{
if (toolName.Contains(rule.Keyword, StringComparison.OrdinalIgnoreCase))
if (user?.IsInRole(rule.RequiredRole) != true)
return false;
}
return true;
}
}

This is applied at both the list level (non-admin users don’t even see admin tools) and the call level (in case someone crafts a direct tool call). The rules are configurable — you can add more keyword/role pairs in appsettings.json.

Where to Add More

The gateway is your single choke point for all AI-to-service communication. The filter system makes it easy to add:

  • Rate limiting — check a Redis counter per user before forwarding. The filter has access to HttpContext, so you have the user identity.
  • Audit logging — log every tool invocation with user, tool name, timestamp, and whether it succeeded.
  • Feature flags — enable tools per tenant or gradually roll them out.

Any cross-cutting concern you’d add to an API gateway can be added here via filters.

Libraries and Kubernetes Deployment

NuGet Packages

Four packages make this work:

  • ModelContextProtocol.AspNetCore (v1.0.0): “AddMcpServer()”, “WithHttpTransport()”, “WithTools()”, “WithRequestFilters()”, “MapMcp()”
  • ModelContextProtocol.Client: “McpClient.CreateAsync()”, “HttpClientTransport”, “ListToolsAsync()”
  • Microsoft.AspNetCore.Authentication.JwtBearer: Standard JWT validation with JWKS discovery
  • StackExchange.Redis: Session context storage

The gateway itself uses standard AddJwtBearer with JWKS discovery — nothing custom about the JWT validation. Point it at your auth server’s discovery endpoint and it handles key rotation automatically.

Kubernetes Deployment

The gateway runs as a single pod with a ClusterIP service. Downstream services are also ClusterIP — nothing is publicly exposed except the gateway’s ingress.

apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-gateway
spec:
replicas: 2
template:
spec:
containers:
- name: mcp-gateway
image: registry.example.com/mcp-gateway:latest
ports:
- containerPort: 8080
env:
- name: McpGateway__DownstreamServers__os
value: "http://orderservice:8080/"
- name: McpGateway__DownstreamServers__ps
value: "http://productservice:8080/"
- name: McpGateway__DownstreamServers__ns
value: "http://notificationservice:8080/"
livenessProbe:
httpGet:
path: /health
port: 8080
---
apiVersion: v1
kind: Service
metadata:
name: mcp-gateway
spec:
type: ClusterIP
ports:
- port: 8080
selector:
app: mcp-gateway

The “McpGateway__DownstreamServers__os” environment variable maps to “McpGateway:DownstreamServers:os” in .NET configuration — standard ASP.NET Core environment variable binding. You can also use a ConfigMap or keep it in “appsettings.json”.

Wrapping Up

The MCP Gateway is about 300 lines of meaningful code. It does five things:

  1. Discovers tools from downstream services at startup
  2. Forwards tool calls with service-name prefixes, handling session loss transparently
  3. Propagates user identity via claims-to-headers, so downstream services don’t need JWT validation
  4. Manages tenant selection per session using Redis, supporting both fixed-tenant and deferred-context tokens
  5. Filters tool access based on user roles, with hooks for rate limiting, audit logging, and feature flags

The MCP SDK’s filter system turns the gateway into a natural extension point. Any policy you’d enforce at an API gateway — rate limits, audit trails, feature flags — fits cleanly into the same pattern.

In the next article, we’ll look at the other side: building MCP tools in downstream services. How to write tools that integrate with your existing service layers, use the forwarded claims for authorization, and expose the right level of functionality to AI clients.

Next parts: (Edited)

  • OpenAI MCP client has issues with sessions. Read more here.

Read, clap or comment on Medium →