Sichere .NET-MCP-Server: API-Keys, Microsoft Entra ID und Agent Governance
Ein Rundgang durch ein reales .NET Aspire MCP-Server-Repo — API-Key-Auth, Scope- und rollenbasierte Entra-ID-Autorisierung pro Tool, ein On-Behalf-Of-Aufruf einer Downstream-API und policy-gesteuerte Agent Governance.
Worum es geht
Im Mai 2026 durfte ich auf dem European AI & Cloud Summit in Köln sprechen — Gelegenheit und Ehre zugleich. In meinem Vortrag ging es darum, mit .NET und C# sichere MCP-Server zu bauen und sie über die integrierte Autorisierung mit Microsoft Entra ID in Copilot Studio einzubinden. Dieser Beitrag konzentriert sich auf den Bau sicherer MCP-Server; die Anbindung des Clients überlasse ich dir.
MCP-Server sind keine reinen Lese-APIs. Ein Tool-Aufruf kann ein Work Item anlegen, einen Commit pushen oder stellvertretend für einen Nutzer eine Downstream-API aufrufen. Und sobald dein Server im Namen eines anderen handelt, genügt die Frage „Ist der Aufrufer authentifiziert?" nicht mehr — du musst wissen, wer da aufruft und was diese Person darf, und zwar für jedes Tool einzeln. Dieser Beitrag arbeitet ein reales Repository durch, das zwei Authentifizierungsmodelle zeigt — API-Key und Microsoft Entra ID — und darüber hinaus eine Governance-Schicht, die Tool-Aufrufe unabhängig vom gewählten Modell blockiert, bereinigt und protokolliert.
Hintergrund: Was ist MCP?
Das Model Context Protocol ist ein offener Standard (modelcontextprotocol.io), der Folgendes ermöglicht:
- KI-Agenten (GitHub Copilot, Claude über die API oder eigene Agenten) können die von einem Server bereitgestellten Tools entdecken und aufrufen.
- Server legen fest, welche Tools verfügbar sind (Name, Beschreibung, Parameter), und führen sie stellvertretend für die Aufrufer aus.
- Agenten kommunizieren mit den Servern über einen JSON-RPC-Transport — meist zustandsloses HTTP, ohne dauerhafte Session.
Stell dir MCP als Brücke vor: Statt API-Integrationen fest in ein LLM einzubauen, schreibst du einen MCP-Server, der deine APIs bereitstellt, und richtest den Agenten darauf aus. Der Agent erfährt, welche Tools es gibt, ruft sie auf und bekommt die Ergebnisse zurück.
Die Sicherheitsherausforderung
Bevor ein MCP-Server auch nur in die Nähe des Produktivbetriebs kommt, musst du drei Fragen beantworten:
- Wie authentifiziere ich Aufrufer? Darf dieser Aufrufer überhaupt mit diesem MCP-Server sprechen?
- Welche Autorisierungsregeln greifen? Ist dieser Aufrufer berechtigt, dieses konkrete Tool aufzurufen?
- Wie verhindere ich Missbrauch? Lassen sich bösartige Versuche oder Prompt-Injection auf Tool-Ebene abwehren?
Das Referenz-Repository zu diesem Beitrag führt zwei konkrete Authentifizierungsmuster und ein Governance-Muster vor:
| Pattern | Anwendungsfall | Was es zeigt |
|---|---|---|
| API-Key | Interne Demos, Service-zu-Service, schnelle Prototypen | Einfache, zustandslose Authentifizierung mit Constant-Time-Vergleich, eingebettet in die reguläre ASP.NET-Core-Auth-Pipeline. |
| Microsoft Entra ID (OAuth/JWT) | Enterprise-Produktionsszenarien, SaaS, föderierte Identitäten, rollenbasierter Zugriff | Token-Validierung, scopebasierte Autorisierung, rollenbasierte Policies, On-Behalf-Of-Aufrufe an Downstream-APIs. |
| Agent Governance | Sichere KI-Ausführung, richtliniengesteuertes Blockieren von Tools, Audit/Compliance | Deklarative Deny-/Allow-Regeln, Erkennung von Prompt-Injection, Bereinigung von Responses, Audit-Trails. |
Ein Praxisszenario
Du baust einen KI-Agenten für dein Unternehmen. Er muss interne APIs ansprechen — Projektmanagement, Abrechnung, DevOps. Dein Security-Team stellt Bedingungen: Nur authentifizierte Nutzer kommen herein, nur bestimmte Rollen dürfen bestimmte Tools aufrufen, jeder Aufruf wird auditiert, und Prompt-Injection-Versuche werden erkannt und blockiert. Du stellst diese APIs über einen MCP-Server bereit; die Nutzer authentifizieren sich mit ihren vorhandenen Entra-ID-Anmeldedaten, der Server validiert das Token, prüft Rollen und Scopes, setzt die Richtlinie durch und protokolliert jeden Aufruf. Versucht ein bösartiger Prompt, ein blockiertes Tool aufzurufen, verweigert die Governance den Aufruf und hält den Versuch fest.
Was das Beispiel-Repo bereitstellt
Der Code zu diesem Beitrag liegt in hluxenhofer/ecs2026-mcp-server — einem Konferenz-Demo-Repository, ausdrücklich „documentation/demo-only" und nicht für aktive Feature-Entwicklung gedacht. Es ist eine .NET-10-Aspire-Solution mit zwei kleinen MCP-Servern, die nebeneinander laufen:
-
ECS2026.MCP.ApiKey— Authentifizierung auf die einfache Art.- Ein eigener Authentication-Handler, der einen
X-Api-Key-Header prüft — mit Constant-Time-Vergleich, damit sich über die Vergleichsdauer nicht auf die Gültigkeit des Keys schließen lässt. - Erzeugt einen vollwertigen ASP.NET-Core-
ClaimsPrincipal, sodass Logging und Audit im weiteren Verlauf genauso greifen wie bei jeder anderen authentifizierten Anfrage. - Ein einziges Tool:
echo. - Geeignet für: interne und Demo-Szenarien oder als Zwischenschritt auf dem Weg zu OAuth.
- Ein eigener Authentication-Handler, der einen
-
ECS2026.MCP.EntraID— Authentifizierung in Produktionsqualität.- Nutzt Microsoft.Identity.Web zur JWT-Validierung gegen einen Entra-ID-Tenant.
- Verbindet scopebasierte (
mcp.tools,joke.tell,devops.read) und rollenbasierte (Default,Admin) Autorisierung auf ein und demselben Token. - Ruft aus einem seiner Tools heraus Azure DevOps stellvertretend für den angemeldeten Nutzer auf (On-Behalf-Of-Flow).
- Legt Microsoft.AgentGovernance obendrauf: deklarative Deny-Regeln, Erkennung von Prompt-Injection, Bereinigung von Responses, Audit.
- Fünf Tools:
who_am_iund seine auf Admins beschränkte Variantewho_am_i_as_admin(zwei Ausprägungen desselben Identitäts-Tools), dazudo_blocked_action(Zielobjekt der Governance-Demo),tell_joke(ruft eine externe Witz-API auf) undlist_projects(ruft Azure DevOps stellvertretend für den Nutzer auf).
Beide Server laufen unter einem gemeinsamen ECS2026.MCP.AppHost (dem .NET-Aspire-Orchestrator, mit je einem Dev-Tunnel pro Server) sowie einem ECS2026.MCP.ServiceDefaults-Projekt, das gemeinsame Defaults für OpenTelemetry, Health-Checks und einen resilienten HttpClient bereitstellt.
Da das Repository nicht aktiv weiterentwickelt wird, kann die schlichte URL oben mit der Zeit von dem abweichen, was hier beschrieben ist — wirf einen Blick auf den aktuellen Stand des Repos (oder auf einen Tag, falls vorhanden), wenn du dich exakt an diesem Walkthrough orientieren willst.
Die Umsetzung ist bewusst kompakt gehalten — zugeschnitten auf einen 45-minütigen Konferenz-Slot —, doch die Muster sind auf Produktionsniveau angelegt, keine Spielzeugbeispiele. Der Rest des Beitrags geht beide Server, die Governance-Schicht und das Testen jedes Einzelnen der Reihe nach durch.
Loslegen (5 Minuten)
Voraussetzungen: .NET 10 SDK, Git. Optional: ein Azure-Abonnement für das Entra-ID-Szenario.
git clone https://github.com/hluxenhofer/ecs2026-mcp-server.git
cd ecs2026-mcp-server
dotnet restore
dotnet buildSecrets konfigurieren (eines der beiden Szenarien oder beide):
API-Key-Modus — bearbeite src/ECS2026.MCP.ApiKey/appsettings.json:
{
"Authentication": { "ApiKey": "Test1234!" }
}Entra-ID-Modus — bearbeite src/ECS2026.MCP.EntraID/appsettings.json:
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "your-tenant.onmicrosoft.com",
"TenantId": "your-tenant-id",
"ClientId": "your-app-client-id",
"ClientCredentials": [
{ "SourceType": "ClientSecret", "ClientSecret": "your-app-secret" }
]
}
}(Die Konfiguration für den Downstream-Aufruf an Azure DevOps folgt später, sobald das zugehörige Tool eingeführt ist.)
Beide Server über Aspire starten:
dotnet run --project src/ECS2026.MCP.AppHost/ECS2026.MCP.AppHost.csprojWas steckt drin
ECS2026.MCP.ApiKey— MCP-Server, abgesichert durch einen eigenen Handler für denX-Api-Key-Header mit Constant-Time-Vergleich.ECS2026.MCP.EntraID— MCP-Server mit Entra-ID-JWT-Validierung, Scope- und Rollen-Autorisierung, einem Downstream-Aufruf an Azure DevOps und Agent Governance.ECS2026.MCP.AppHost— Aspire-Orchestrator, der beide Server mit getrennten Dev-Tunneln startet.ECS2026.MCP.ServiceDefaults— gemeinsame Telemetrie, Health-Checks und Resilienz.
Warum ein eigener API-Key-Handler statt eines String-Vergleichs
ApiKeyAuthenticationHandler (src/ECS2026.MCP.ApiKey/Authentication/ApiKeyAuthenticationHandler.cs) tritt aus mehreren Gründen an die Stelle einer einzeiligen if (header == secret)-Prüfung:
- Constant-Time-Vergleich — verwendet
CryptographicOperations.FixedTimeEquals, sodass die Vergleichsdauer nicht verrät, wie nah der Key am richtigen Wert lag. - Striktes Parsing — weist fehlende oder doppelte Header-Werte zurück, statt zu raten, welcher gemeint ist.
- Integration in ASP.NET Core — erzeugt ein echtes
AuthenticationTicketund einenClaimsPrincipal, sodass Standard-Logging,[Authorize]und Audit-Trails im weiteren Verlauf genauso funktionieren wie bei jedem anderen Auth-Schema. - Testbar und erweiterbar — eine einzelne Klasse, die du im Unit-Test prüfen oder um Throttling, Rotation und Auditing ergänzen kannst.
- Konfigurationsgesteuert — das Secret stammt aus der Konfiguration (User Secrets, Umgebungsvariablen, ein Secret Store), sodass eine Rotation zur Sache der Umgebung wird und keine Codeänderung erfordert.
Ein naiver String-Vergleich ist anfällig für Timing-Angriffe, umgeht die ASP.NET-Core-Auth-Pipeline vollständig (kein ClaimsPrincipal, keine Anbindung an Logging und Autorisierung) und lässt sich schlechter testen und erweitern.
Entra ID: Authentifizierungs-Stack
Program.cs (src/ECS2026.MCP.EntraID/Program.cs) registriert zwei Authentifizierungsschemata mit zwei unterschiedlichen Aufgaben, verkettet an einem einzigen AddAuthentication()-Aufruf:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;
using ModelContextProtocol.AspNetCore.Authentication;
var scopeName = "mcp.tools";
var settings = new Settings();
builder.Configuration.Bind(settings);
builder.Services.AddAuthentication(options =>
{
// MCP auth is used for protocol challenge, while JWT bearer validates the actual access token.
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddMcp(options =>
{
options.ResourceMetadata = new()
{
AuthorizationServers = { settings.AzureAd!.Authority },
ScopesSupported =
[
$"api://{settings.AzureAd.ClientId}/{scopeName}",
$"api://{settings.AzureAd.ClientId}/joke.tell",
$"api://{settings.AzureAd.ClientId}/devops.read",
],
};
})
.AddMicrosoftIdentityWebApi(builder.Configuration)
.EnableTokenAcquisitionToCallDownstreamApi()
.AddDownstreamApi("AzureDevOps", builder.Configuration.GetSection("DownstreamApis:AzureDevOps"))
.AddInMemoryTokenCaches();Gemäß der MCP-Autorisierungsspezifikation stellt ein Server niemals selbst Tokens aus — er ist ein OAuth-2.1-Resource-Server, der die von Entra ID ausgestellten Bearer-Tokens validiert und unter /.well-known/oauth-protected-resource die Protected Resource Metadata nach RFC 9728 veröffentlicht, damit Clients erkennen, welchen Autorisierungsserver und welche Scopes sie ansprechen müssen.
McpAuthenticationDefaults.AuthenticationScheme, registriert über.AddMcp(...), ist das Default Challenge Scheme. Es kümmert sich um die OAuth-Protokollmechanik, die ein MCP-Client erwartet: das Veröffentlichen des RFC-9728-Metadatendokuments und das Formulieren derWWW-Authenticate-Challenge bei einem401.JwtBearerDefaults.AuthenticationScheme, registriert über.AddMicrosoftIdentityWebApi(...), ist das Default Authenticate Scheme. Es validiert das eingehende Bearer-Token — Signaturschlüssel, Issuer, Audience und Ablauf — mit der Standardmechanik von Microsoft.Identity.Web.
Der Rest der Kette — EnableTokenAcquisitionToCallDownstreamApi(), AddDownstreamApi("AzureDevOps", ...) und AddInMemoryTokenCaches() — richtet die On-Behalf-Of-Token-Beschaffung für Azure DevOps ein, die später das Tool list_projects nutzt.
Settings ist ein kleines POCO, das aus der Konfiguration gebunden wird:
// Settings.cs
public class Settings
{
public AzureAd? AzureAd { get; set; }
}
public class AzureAd
{
public required string ClientId { get; set; }
public required string TenantId { get; set; }
public string Authority => $"https://login.microsoftonline.com/{TenantId}/v2.0";
}Das Repository zielt auf .NET 10 und pinnt stabile Nicht-Preview-Paketversionen: ModelContextProtocol.AspNetCore 1.4.1 sowie Microsoft.Identity.Web und Microsoft.Identity.Web.DownstreamApi 4.13.2.
Entra-ID-App-Registrierung: Scopes und App-Rollen
In der Entra-ID-App-Registrierung hinter dem MCP-Server konfigurierst du zwei voneinander unabhängige Dinge:
Scopes (delegierte Berechtigungen) — unter Eine API verfügbar machen:
- Lege eine Anwendungs-ID-URI fest, z. B.
api://<client-id>. - Füge die Scopes
mcp.tools,joke.tellunddevops.readhinzu — mit Admin- oder Nutzerzustimmung, je nach Bedarf. - Sobald ein Client sie anfordert und ein Nutzer ihnen zustimmt, landen sie im Access-Token als
"scp": "mcp.tools joke.tell devops.read"(durch Leerzeichen getrennt).
App-Rollen — unter App-Rollen (dem App-Rollen-Manifest):
- Füge die App-Rollen
DefaultundAdminhinzu. - Setze Zulässige Mitgliedertypen so, dass Benutzer/Gruppen einbezogen sind und nicht nur Anwendungen, falls du Rollen direkt an Personen vergeben willst.
- Weise die Rolle unter Unternehmensanwendungen → Benutzer und Gruppen einem Benutzer oder einer Gruppe zu. Das nächste delegierte Token dieses Benutzers trägt dann
"roles": ["Default"]— zusätzlich zu denscp-Claims, die es ohnehin enthält.
App-Rollen lassen sich auch einem Service Principal als Anwendungsberechtigung für Client-Credentials-Tokens erteilen — auch dieser Weg existiert. Entscheidend ist: Es ist nicht der einzige. Derselbe Claim-Typ erscheint auf delegierten Tokens, sobald einem Benutzer eine Rolle direkt zugewiesen wurde.
Ein Scope wird pro Token angefordert und zugestimmt — eng gefasst und auf eine einzelne Fähigkeit bezogen. Eine App-Rolle ist demgegenüber eine breitere, eher statische Zuweisung, die ein Admin im Voraus vergibt, statt sie beim Anfordern des Tokens auszuhandeln — gleichgültig, ob sie einem Benutzer direkt oder einem Service Principal für App-Only-Aufrufe zugewiesen ist. Beides auf demselben Token zu verlangen, wie es die Baseline-Policy weiter unten tut, verschafft dir zwei voneinander unabhängige Prüfungen: „Hat dieser Aufruf diese konkrete Fähigkeit angefordert?" und „Ist diese Identität überhaupt für diese Anwendung freigeschaltet?"
Sobald die App-Registrierung steht, erstelle unter Zertifikate & Geheimnisse ein Client-Secret und bewahre es sicher auf, statt es in appsettings.json abzulegen:
dotnet user-secrets set "AzureAd:ClientCredentials:0:ClientSecret" "your-secret" --project src/ECS2026.MCP.EntraIDDas Gate auf Route-Ebene
Bevor du dich einzelnen Tools zuwendest, setze ein grobes Gate vor den gesamten MCP-Endpunkt. Im Repo ist das die Policy UseMCP — und sie verlangt einen Scope und eine Rolle und einen authentifizierten Nutzer, alle drei zusammen:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("UseMCP", policy =>
{
policy.AddAuthenticationSchemes(McpAuthenticationDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme);
policy.Requirements.Add(new ScopeAuthorizationRequirement(new[] { "mcp.tools" }));
policy.RequireRole("Default", "Admin");
policy.RequireAuthenticatedUser();
});
});
var app = builder.Build();
app.MapMcp().RequireAuthorization("UseMCP");Lies diese Policy genau: Es heißt „Scope und Rolle", nicht „Scope oder Rolle". Jeder Aufrufer muss ein Token vorlegen, das sowohl den Scope mcp.tools angefordert hat als auch eine zugewiesene App-Rolle Default oder Admin trägt. AddAuthenticationSchemes(Mcp, JwtBearer) bindet die Policy an beide zuvor registrierten Schemata, sodass sowohl das MCP-Challenge-Verhalten als auch die eigentliche Validierung der JWT-Claims in die Auswertung einfließen.
Die meisten Artikel über Entra-ID-geschützte APIs, die sowohl delegierte als auch App-Only-Aufrufer bedienen, landen bei einer Entweder-oder-Prüfung — Scope ODER Rolle. Der Grund: Ein delegiertes (Benutzer-)Token trägt scp (und meist keine roles, sofern nicht gesondert zugewiesen), während ein App-Only-Client-Credentials-Token roles trägt, aber niemals scp — es gibt schlicht keinen Benutzer, in dessen Namen delegiert werden könnte. Scope und Rolle gemeinsam zu fordern, wie UseMCP es hier tut, ist die strengere, bewusst gewählte Defense-in-Depth-Variante: Der Client muss zugestimmt haben (Scope) und der aufrufende Benutzer muss von einem Admin ausdrücklich eine Rolle erhalten haben (Rolle). Eine unmittelbare Folge daraus: Da App-Only-Tokens niemals scp tragen, schließt diese Form der Policy reine Service-Principal-Aufrufer strukturell aus — nur delegierte Benutzer-Tokens können sie je erfüllen. Das ist eine bewusste Entscheidung, diesen MCP-Endpunkt auf delegierte Benutzer zu beschränken, und keine allgemeingültige Vorlage für jede Entra-ID-geschützte API.
app.MapMcp() bindet den MCP-Endpunkt standardmäßig an den Root-Pfad der Anwendung — ein /mcp-Segment gibt es nur, wenn du es mit app.MapMcp("/mcp") ausdrücklich anforderst. Das Repo bleibt beim Standard, weshalb jede Anfrage im Testabschnitt weiter unten auf / zielt.
So viel zur groben Baseline. Die feingranularen Prüfungen pro Tool folgen eine Ebene tiefer.
Autorisierung pro Tool mit AddAuthorizationFilters()
Das SDK erlaubt es, [McpServerTool]-Methoden (oder die sie umschließende [McpServerToolType]-Klasse) mit gewöhnlichen ASP.NET-Core-[Authorize]-Attributen zu versehen, die über AddAuthorizationFilters() in das Dispatching der MCP-Tools eingebunden werden. Dabei kommen derselbe IAuthorizationService und ClaimsPrincipal zum Einsatz wie bei jeder anderen ASP.NET-Core-Autorisierungsprüfung — es handelt sich nicht um einen MVC-Action-Filter.
Neben UseMCP stehen drei weitere benannte Policies:
options.AddPolicy("TellJokes", policy =>
{
policy.AddAuthenticationSchemes(McpAuthenticationDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme);
policy.Requirements.Add(new ScopeAuthorizationRequirement(new[] { "joke.tell" }));
policy.RequireAuthenticatedUser();
});
options.AddPolicy("AzureDevOps", policy =>
{
policy.AddAuthenticationSchemes(McpAuthenticationDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme);
policy.Requirements.Add(new ScopeAuthorizationRequirement(new[] { "devops.read" }));
policy.RequireAuthenticatedUser();
});
options.AddPolicy("AdminOnly", policy =>
{
policy.AddAuthenticationSchemes(McpAuthenticationDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme);
policy.RequireRole("Admin");
});Die Tools werden registriert, indem AddAuthorizationFilters() an den MCP-Server-Builder angehängt wird:
builder.Services.AddHttpContextAccessor();
builder.Services.AddMcpServer()
.WithTools<WhoAmITool>()
.WithTools<JokeTool>()
.WithTools<AzureDevOpsTool>()
.WithHttpTransport(options =>
{
// Stateless mode is recommended for servers that don't need server-to-client
// requests like sampling or elicitation. It enables horizontal scaling without
// session affinity and works with clients that don't send Mcp-Session-Id.
options.Stateless = true;
})
.AddAuthorizationFilters();JokeTool schützt sein einziges Tool auf Klassenebene:
// Tools/JokeTool.cs
using System.ComponentModel;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using ModelContextProtocol.Server;
[Authorize(Policy = "TellJokes")]
[McpServerToolType]
public class JokeTool
{
private readonly IHttpClientFactory _httpClientFactory;
public JokeTool(IHttpClientFactory httpClientFactory) => _httpClientFactory = httpClientFactory;
[McpServerTool, Description("Tells a joke to the client.")]
public async Task<string> TellJoke()
{
var client = _httpClientFactory.CreateClient("JokeClient");
var response = await client.GetAsync("/jokes/random");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
using var document = JsonDocument.Parse(json);
return document.RootElement.GetProperty("value").GetString() ?? string.Empty;
}
}JokeClient ist ein benannter HttpClient, der einmalig in Program.cs registriert und auf die Chuck-Norris-Witz-API gerichtet wird:
builder.Services.AddHttpClient("JokeClient", client =>
{
client.BaseAddress = new Uri("https://api.chucknorris.io");
});WhoAmITool zeigt in einer einzigen Klasse beide Varianten — „gar kein Attribut" und eine Policy auf Methodenebene:
// Tools/WhoAmITool.cs
using System.ComponentModel;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using ModelContextProtocol.Server;
[McpServerToolType]
public sealed class WhoAmITool
{
private readonly IHttpContextAccessor _httpContextAccessor;
public WhoAmITool(IHttpContextAccessor httpContextAccessor) => _httpContextAccessor = httpContextAccessor;
// No [Authorize] attribute — this tool relies entirely on the
// route-level "UseMCP" policy that already gates every request.
[McpServerTool, Description("Returns information about the current user.")]
public string WhoAmI()
{
// This is only used for debug purposes to show the raw bearer token in the request header.
var authorizationHeader = _httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
string? bearerToken = null;
if (!string.IsNullOrWhiteSpace(authorizationHeader) &&
authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
bearerToken = authorizationHeader["Bearer ".Length..].Trim();
}
ClaimsPrincipal? user = _httpContextAccessor.HttpContext?.User;
return $"Hello {user?.Identity?.Name}. Welcome to ECS 2026!";
}
[Authorize(Policy = "AdminOnly")]
[McpServerTool, Description("Returns admin information about the current user.")]
public string WhoAmIAsAdmin()
{
ClaimsPrincipal? user = _httpContextAccessor.HttpContext?.User;
return $"Hello Admin {user?.Identity?.Name}. Welcome to ECS 2026!";
}
}Erwähnenswert: Die lokale Variable bearerToken, die in WhoAmI() oben ermittelt wird, kommt an keiner anderen Stelle der Methode zum Einsatz — nur user?.Identity?.Name fließt in die Antwort ein. Such also nicht nach einer zweiten Verwendung, die du übersehen hättest; sie dient rein zu Debugging-Zwecken.
Die reale Klasse besitzt noch eine dritte Methode, DoBlockedAction, ebenfalls ohne [Authorize]-Attribut — kein Versehen, sondern eine Demonstration des Durchsetzungsmechanismus der Governance-Schicht selbst (der deklarativen Deny-Regeln), auf den wir später eingehen.
[RequiredScopeOrAppPermission] bewirkt an einem MCP-Tool schlicht gar nichts. Es ist reine Metadaten-Information, und der Handler von Microsoft.Identity.Web sucht sie über HttpContext.GetEndpoint()?.Metadata — doch jeder Tool-Aufruf läuft über denselben MapMcp()-Endpunkt, sodass dein Attribut auf Tool-Ebene in diesen Metadaten nie auftaucht.
[Authorize(Policy = "...")] funktioniert hingegen: Es trägt nur einen Policy-Namen, den der MCP-Dispatcher von der Methode ausliest und über IAuthorizationService gegen den ClaimsPrincipal des Aufrufers prüft. Kein Endpunkt im Spiel.
Die Regel lautet: Attribute, deren Handler Endpunkt-Metadaten auslesen, greifen an Tools nicht. Benannte Policies schon. Du brauchst Scope-oder-App-Permission-Semantik? Dann schreib eine Requirement-Klasse, die die Claims scp und roles direkt auswertet, registriere sie als Policy und wende sie mit [Authorize] an.
On-Behalf-Of: Aufruf einer Downstream-API im Namen des Aufrufers
AzureDevOpsTool.ListProjects prüft nicht bloß eine Policy und gibt statische Daten zurück — es ruft Azure DevOps als der angemeldete Benutzer auf und nutzt dafür die On-Behalf-Of-Token-Beschaffung, die bereits in Program.cs eingerichtet wurde:
// Tools/AzureDevOpsTool.cs
using System.ComponentModel;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Identity.Abstractions;
using ModelContextProtocol.Server;
[McpServerToolType]
public class AzureDevOpsTool
{
private readonly ILogger<AzureDevOpsTool> _logger;
private readonly IDownstreamApi _downstreamApi;
public AzureDevOpsTool(ILogger<AzureDevOpsTool> logger, IDownstreamApi downstreamApi)
{
_logger = logger;
_downstreamApi = downstreamApi;
}
[Authorize(Policy = "AzureDevOps")]
[McpServerTool, Description("List all Azure DevOps projects by organization name")]
public async Task<ToolResponse<string>> ListProjects([Description("Organization name in Azure DevOps")] string orgName)
{
try
{
var response = await _downstreamApi.CallApiForUserAsync(
"AzureDevOps",
options => options.RelativePath = $"{orgName}/_apis/projects");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadAsStringAsync();
return ToolResponse<string>.Ok(result);
}
catch (Exception ex)
{
// Return a structured tool error so MCP clients can display failures consistently.
_logger.LogError(ex, "Failed to list projects for organization {OrgName}", orgName);
return ToolResponse<string>.Fail(ex.Message);
}
}
}CallApiForUserAsync führt einen OAuth2-On-Behalf-Of-Austausch durch: Microsoft.Identity.Web tauscht im Hintergrund das Token, das der Aufrufer an diesen MCP-Server geschickt hat, gegen ein neues, auf Azure DevOps zugeschnittenes Token (der .default-Scope, konfiguriert unter DownstreamApis:AzureDevOps:Scopes), legt es im Cache ab und hängt es an die ausgehende Anfrage an — der Tool-Code fasst niemals selbst ein Token an. Der Aufruf an Azure DevOps erfolgt als der angemeldete Benutzer und ist auf die Projekte beschränkt, die dieser Benutzer dort tatsächlich sehen darf. Genau deshalb ist devops.read eine Scope-Anforderung und nicht bloß eine App-Rolle: Der Benutzer willigt ein, dass dieser Server in seinem Namen mit einer bestimmten Downstream-API interagiert.
// appsettings.json
{
"DownstreamApis": {
"AzureDevOps": {
"BaseUrl": "https://dev.azure.com",
"Scopes": ["499b84ac-1321-427f-aa17-267ca6975798/.default"]
}
}
}Der Antwort-Envelope ist ein kleiner, gemeinsam genutzter Typ, den jedes Tool verwendet, das auf absehbare Weise fehlschlagen kann (falscher Org-Name, ein 404 des Downstreams, ein Timeout). So erhalten MCP-Clients unabhängig vom aufgerufenen Tool eine einheitliche Struktur aus Erfolg, Daten und Fehler:
// Models/ToolResponse.cs
public class ToolResponse<T>
{
public bool Success { get; set; }
public T? Data { get; set; }
public string? Error { get; set; }
public static ToolResponse<T> Ok(T data) => new() { Success = true, Data = data };
public static ToolResponse<T> Fail(string error) => new() { Success = false, Error = error };
}Was passiert, wenn eine Prüfung fehlschlägt
Ein nicht autorisierter Tool-Aufruf kommt nicht als HTTP 401/403 zurück — zu dem Zeitpunkt, an dem das Tool-Dispatching läuft, hat sich der Transport unter Umständen bereits auf eine 200-JSON-RPC-Antwort festgelegt. Stattdessen wirft das SDK eine McpException mit der Meldung „Access forbidden", die als JSON-RPC-Fehlerobjekt im Antwort-Body erscheint. Um einen Autorisierungsfehler bei einem tools/call zu erkennen, müssen Aufrufer also den JSON-RPC-Fehler auswerten, nicht den HTTP-Statuscode.
tools/list verhält sich anders: Es liefert keinen Fehler, wenn dem Aufrufer die Berechtigung für einzelne Tools fehlt, sondern reduziert die Liste stillschweigend auf das, was dieser Aufrufer tatsächlich ausführen darf. Ein Benutzer, dessen Token nur UseMCP und joke.tell erfüllt, sieht who_am_i und tell_joke, aber weder who_am_i_as_admin noch list_projects.
Agent Governance – was geschützt wird
Das Repo legt über all das bisher Beschriebene Microsoft.AgentGovernance / Microsoft.AgentGovernance.Extensions.ModelContextProtocol — das Agent Governance Toolkit — und bindet es über AddAuthorizationFilters() und .WithGovernance(...) an denselben MCP-Server-Builder:
builder.Services.AddMcpServer()
.WithTools<WhoAmITool>()
.WithTools<JokeTool>()
.WithTools<AzureDevOpsTool>()
.WithHttpTransport(options => { options.Stateless = true; })
.AddAuthorizationFilters()
.WithGovernance(options =>
{
options.PolicyPaths.Add("policies/mcp.yaml");
options.ServerName = "mcp-server";
options.ScanToolsOnStartup = true;
options.FailOnUnsafeTools = true;
options.SanitizeResponses = true;
options.GovernFallbackHandlers = true;
options.EnablePromptInjectionDetection = true;
options.EnableMetrics = true;
options.EnableAudit = true;
});| Feature | Wirkung |
|---|---|
Policy-Datei (Policies/mcp.yaml) | Deklarative Deny-/Allow-Regeln, die zur Laufzeit ausgewertet werden — etwa: do_blocked_action für eine bestimmte Agent-Identität verweigern. |
Scan beim Start (ScanToolsOnStartup, FailOnUnsafeTools) | Der Server startet gar nicht erst, wenn ein registriertes Tool gegen die Policy-Erwartungen verstößt, statt es stillschweigend anzubieten. |
| Bereinigung von Responses | SanitizeResponses entfernt gemäß Policy sensible Daten aus den Tool-Ausgaben. |
| Fallback-Handler | GovernFallbackHandlers wendet die Governance auch auf das Fallback-Verhalten an, das greift, wenn ein Tool blockiert oder bereinigt wird. |
| Erkennung von Prompt-Injection | EnablePromptInjectionDetection markiert verdächtige Eingabemuster. |
| Audit & Metriken | EnableAudit / EnableMetrics liefern über OpenTelemetry Telemetrie und Audit-Trails. |
Die Policy-Datei selbst ist reines YAML:
# Policies/mcp.yaml
apiVersion: governance.toolkit/v1
version: "1.0"
name: test-policy
default_action: allow
rules:
- name: deny-Test-do_blocked_action
condition: "tool_name == 'do_blocked_action' and agent_did == 'did:mesh:Test'"
action: deny
priority: 10
- name: deny-agent-do_blocked_action
condition: "tool_name == 'do_blocked_action' and agent_did == 'did:mesh:7a10bce4-3312-496a-a2e6-00f4a5cddf48'"
action: deny
priority: 10Der agent_did, gegen den die Regel prüft, stammt aus einem optionalen agent-id-Request-Header. Er wird im Zuge der Validierung des JWT-Bearer-Tokens auf einen agent_id-Claim (did:mesh:{value}) abgebildet — eine vom Aufrufer selbst gelieferte Identität, an der Governance-Regeln unabhängig von den eigentlichen Claims des Entra-ID-Tokens ansetzen können.
Trägt eine Anfrage den Header agent-id: Test und ruft do_blocked_action auf, geschieht Folgendes: Der Server bildet den Header auf agent_id = did:mesh:Test ab, wertet die Policy-Regel aus, verweigert den Aufruf, bereinigt die Antwort und hält ein Audit-Ereignis fest.
Der agent-id-Header wird hier nur ausgelesen, um zu zeigen, dass sich Agenten anhand einer bestimmten ID steuern lassen.
Für produktionsreifen Code würde ich dringend dazu raten, eigene Agent-Identitäten anzulegen oder Microsoft Entra Agent ID zu verwenden.
Das ist ein tatsächlich eigenständiges Anliegen, getrennt von der weiter oben behandelten Entra-ID-Autorisierung — eine Policy-Engine, die neben [Authorize] steht und es nicht ersetzt. Beide können denselben Server gleichzeitig schützen, und genau das tun sie hier.
Testen: API-Key
Datei: src/ECS2026.MCP.ApiKey/ECS2026.MCP.ApiKey.http (im REST Client von VS Code öffnen oder in Postman einfügen).
Tools auflisten:
POST http://localhost:5238/
X-Api-Key: Test1234!
Accept: application/json, text/event-stream
Content-Type: application/json
{"method":"tools/list","params":{"_meta":{"progressToken":3}},"jsonrpc":"2.0","id":1}Das echo-Tool aufrufen:
POST http://localhost:5238/
X-Api-Key: Test1234!
Accept: application/json, text/event-stream
Content-Type: application/json
{"method":"tools/call","params":{"name":"echo","arguments":{"message":"test"},"_meta":{"progressToken":4}},"jsonrpc":"2.0","id":2}Testen: Entra ID
MCP-Tool-Namen werden nach außen als snake_case übertragen, unabhängig vom Namen der C#-Methode — das SDK konvertiert automatisch. Aus WhoAmI wird who_am_i, aus WhoAmIAsAdmin wird who_am_i_as_admin, aus TellJoke wird tell_joke, aus ListProjects wird list_projects und aus DoBlockedAction wird do_blocked_action. Schreibst du das im Request-Body falsch, bekommst du einen „tool not found"-Fehler — keinen Autorisierungsfehler.
1. Ein delegiertes Benutzer-Token holen
UseMCP verlangt den Scope mcp.tools zusammen mit einer zugewiesenen App-Rolle Default oder Admin. Ein Client-Credentials-Token (App-Only) erfüllt das nicht — App-Only-Tokens tragen niemals einen scp-Claim, da es keinen Benutzer gibt, in dessen Namen delegiert werden könnte. Du brauchst also ein delegiertes Benutzer-Token von einem Benutzer, dem eine dieser App-Rollen zugewiesen ist. Der Device-Code-Grant ist ein bequemer Weg, ein solches Token über die Kommandozeile zu erhalten:
# 1. Request a device code
curl -X POST "https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/devicecode" \
-d "client_id={SERVER_CLIENT_ID}&scope=api://{SERVER_CLIENT_ID}/mcp.tools offline_access"
# Follow the printed verification URL, sign in as a user who has been
# assigned the "Default" or "Admin" app role, and enter the displayed code.
# 2. Poll for the token once sign-in is complete
curl -X POST "https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code&client_id={SERVER_CLIENT_ID}&device_code={DEVICE_CODE}"Die App-Registrierung setzt „Öffentliche Clientflows zulassen" = Ja voraus; andernfalls schlägt der Device-Code-Flow mit dem Fehler AADSTS7000218 fehl.
Notiere dir den Wert von access_token aus der Antwort.
2. Tools auflisten
curl -X POST "https://{HOST}/" \
-H "Authorization: Bearer {ACCESS_TOKEN}" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"method":"tools/list","params":{"_meta":{"progressToken":1}},"jsonrpc":"2.0","id":1}'Im Ergebnis erscheinen nur die Tools, die die Scopes und Rollen des Tokens abdecken.
3. Das who_am_i-Tool aufrufen
Ein Token, das allein die Baseline-Policy UseMCP erfüllt, genügt:
curl -X POST "https://{HOST}/" \
-H "Authorization: Bearer {ACCESS_TOKEN}" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"method":"tools/call","params":{"name":"who_am_i","arguments":{},"_meta":{"progressToken":2}},"jsonrpc":"2.0","id":2}'4. Die Governance-Deny-Regel auslösen
Füge einen agent-id-Header hinzu, der zur Bedingung in der Policy-Datei passt, und rufe anschließend das Tool auf, das dadurch blockiert wird:
curl -X POST "https://{HOST}/" \
-H "Authorization: Bearer {ACCESS_TOKEN}" \
-H "agent-id: Test" \
-H "Accept: application/json, text/event-stream" \
-H "Content-Type: application/json" \
-d '{"method":"tools/call","params":{"name":"do_blocked_action","arguments":{},"_meta":{"progressToken":3}},"jsonrpc":"2.0","id":3}'Die ausgewertete Policy-Bedingung:
condition: "tool_name == 'do_blocked_action' and agent_did == 'did:mesh:Test'"
action: denyErgebnis: Die Governance verweigert den Aufruf, die Antwort wird bereinigt und ein Audit-Ereignis wird festgehalten — unabhängig davon, ob das Entra-ID-Token selbst den Aufruf erlaubt hätte.
Hinweise für den Produktionsbetrieb
- Niemals Secrets in die Versionsverwaltung committen — nutze stattdessen
dotnet user-secrets, Umgebungsvariablen oder Azure Key Vault. - In Produktion
AllowAnyOrigin()-CORS deaktivieren und die erlaubten Origins ausdrücklich einschränken. - Die In-Memory-Token-Caches für horizontale Skalierung durch einen verteilten Cache ersetzen (z. B. Redis).
Policies/mcp.yamlregelmäßig überprüfen, wenn neue Tools hinzukommen — ein neues Tool ohne passende Regel fällt standardmäßig unterallow.OTEL_EXPORTER_OTLP_ENDPOINTsetzen, um OpenTelemetry-Daten in Produktion zu exportieren.
Wichtige Dateien
| Datei | Zweck |
|---|---|
src/ECS2026.MCP.ApiKey/Authentication/ApiKeyAuthenticationHandler.cs | Constant-Time-Validierung des API-Keys |
src/ECS2026.MCP.EntraID/Program.cs | Auth-Schemata, Autorisierungs-Policies, Governance-Setup |
src/ECS2026.MCP.EntraID/Tools/*.cs | Implementierungen von WhoAmITool, JokeTool und AzureDevOpsTool |
src/ECS2026.MCP.EntraID/Policies/mcp.yaml | Deny-/Allow-Regeln der Governance |
src/ECS2026.MCP.AppHost/AppHost.cs | Aspire-Orchestrierung und Dev-Tunnel |
src/ECS2026.MCP.ServiceDefaults/Extensions.cs | Gemeinsame Telemetrie, Health-Checks und Resilienz |
Referenzen
- Model Context Protocol
- MCP C# SDK
- RFC 9728 — OAuth 2.0 Protected Resource Metadata
- Microsoft.Identity.Web
- Agent Governance Toolkit
- Microsoft Entra ID App-Registrierung – Schnellstart
- .NET Aspire – Übersicht
- ASP.NET Core – Authentifizierung
- ecs2026-mcp-server (Quell-Repository)
Fazit
Scopes und App-Rollen in Entra ID sind keine Weggabelung, an der du dich je nach Beteiligung eines Menschen für das eine oder andere Modell entscheidest — ein einzelnes delegiertes Benutzer-Token kann beide zugleich tragen, und in der Baseline-Policy dieses Repos tut es das auch. Greif zum Scope, wenn ein Client eine konkrete Fähigkeit ausdrücklich anfordern (und ein Nutzer ihr zustimmen) soll — vor allem bei allem, was einen Downstream-Aufruf im Namen dieses Nutzers nach sich zieht. Greif zur App-Rolle, wenn du ein statischeres, vom Admin bereitgestelltes Gate dafür willst, wer die Anwendung überhaupt nutzen darf. Und leg eine Governance-Policy über beides, wenn du eine Durchsetzung brauchst, die sich in OAuth-Claims gar nicht sauber ausdrücken lässt — ein bestimmtes Tool für einen bestimmten Aufrufer blockieren, eine Antwort bereinigen oder einen Prompt-Injection-Versuch erkennen.
Nichts davon ist exotisch. Es ist dieselbe ASP.NET-Core-Pipeline für Authentifizierung und Autorisierung, die wir längst kennen — nur angewandt auf das Dispatching von MCP-Tools statt auf MVC-Actions, ergänzt um eine Policy-Engine für die Aspekte, die OAuth nie ausdrücken sollte.
Alles ab AddAuthorizationFilters() — und die darauf aufbauende Governance-Schicht — ist spezifisch für ModelContextProtocol.AspNetCore und den HTTP-Transport. Über stdio hat es keine Bedeutung: Ein per stdio gehosteter MCP-Server besitzt weder einen HttpContext noch einen ClaimsPrincipal, gegen den autorisiert werden könnte — er braucht daher ein völlig anderes Autorisierungskonzept (typischerweise vertraut er stattdessen auf die Grenze des lokalen Prozesses).
Zum Loslegen: Klone das Repo, konfiguriere die Secrets, starte AppHost und probiere die obigen Requests aus. Übernimm anschließend das Muster, das zu deinem eigenen MCP-Server passt — API-Key, Entra-ID-Scopes und -Rollen und/oder Governance.