MCP dans une API C#/.NET : bonnes pratiques d’architecture et de sécurité

3 août 2026

Le Model Context Protocol permet à un agent IA de découvrir et d’utiliser des capacités exposées par une application. Dans un environnement .NET, ces capacités prennent généralement la forme de tools exécutés par un serveur MCP hébergé dans une application console ou dans une API ASP.NET Core.

Le SDK C# officiel permet désormais d’implémenter des clients et des serveurs MCP directement dans des applications .NET. Il propose également une intégration avec ASP.NET Core pour exposer un serveur MCP à travers un transport HTTP.

Cette intégration reste relativement simple sur le plan technique. La principale difficulté concerne la manière dont les tools vont accéder au système d’information. Un tool MCP peut lire une base de données, appeler une API, déclencher un traitement ou modifier une ressource métier. Sa conception doit donc suivre les mêmes règles qu’un endpoint exposé à un utilisateur ou à une application externe.

La place du serveur MCP dans l’architecture

Une application utilisant MCP repose sur plusieurs composants. L’agent interprète la demande de l’utilisateur. Le client MCP découvre les capacités disponibles et transmet les appels au serveur. Le serveur MCP exécute ensuite le tool correspondant.

Le serveur MCP constitue une nouvelle interface de l’application. Il rejoint les controllers, les Minimal APIs, les consumers de messages ou les commandes exécutées par un job.

Cette position a une conséquence directe sur l’architecture : un tool MCP ne devrait pas contenir la logique métier de l’application. Il traduit une requête MCP en appel vers un cas d’usage existant.

Dans une architecture en couches ou hexagonale, le serveur MCP appartient à la couche d’entrée.

Cette organisation évite de créer une seconde implémentation du métier réservée à l’agent. Le même cas d’usage peut être appelé depuis une API HTTP, une interface d’administration ou un tool MCP.

Exposer des tools orientés métier

Un tool doit représenter une action claire et limitée. Son nom, sa description et ses paramètres servent directement au modèle pour comprendre dans quelles circonstances il peut l’utiliser.

Un tool nommé execute_query ou run_command laisse une surface d’interprétation trop importante. Un nom comme customers_get_by_id ou invoices_create_draft définit une intention plus précise.

using System.ComponentModel;
using ModelContextProtocol.Server;
[McpServerToolType]
public sealed class CustomerTools
{
[McpServerTool(Name = "customers_get_by_id")]
[Description(
"Returns the public information of a customer " +
"using its unique identifier.")]
public static async Task<CustomerResult> GetByIdAsync(
[Description("Unique customer identifier")]
Guid customerId,
IGetCustomerById useCase,
CancellationToken cancellationToken)
{
return await useCase.ExecuteAsync(
customerId,
cancellationToken);
}
}

Le tool reste ici très court. Il reçoit les paramètres issus de l’appel MCP, délègue le traitement à la couche Application et retourne un résultat structuré.

La description doit également préciser le périmètre fonctionnel. Un modèle choisit un tool à partir de son nom, de sa description et de son schéma. Une description ambiguë augmente le risque qu’un outil soit sélectionné dans un contexte inadapté.

Le guide de sécurité fourni recommande également de gérer les collisions de noms entre serveurs. Deux serveurs exposant un outil nommé run_analysis peuvent conduire un client à sélectionner le mauvais outil. Un nommage préfixé par le domaine fonctionnel permet de réduire ce risque.

Utiliser des contrats d’entrée fermés

Le langage naturel reste utile pour exprimer une intention. L’exécution applicative gagne à s’appuyer sur des contrats typés.

Une opération de création de facture peut recevoir un objet fortement typé plutôt qu’une chaîne demandant au modèle de produire une commande libre.

public sealed record CreateInvoiceDraftRequest(
Guid CustomerId,
decimal Amount,
string Currency,
string IdempotencyKey);

La validation intervient ensuite dans la couche Application.

public sealed class CreateInvoiceDraftValidator
: AbstractValidator<CreateInvoiceDraftRequest>
{
public CreateInvoiceDraftValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty();
RuleFor(x => x.Amount)
.GreaterThan(0)
.LessThanOrEqualTo(100_000);
RuleFor(x => x.Currency)
.Must(currency => currency is "EUR" or "USD" or "GBP");
RuleFor(x => x.IdempotencyKey)
.NotEmpty()
.MaximumLength(100);
}
}

Cette validation reste nécessaire même lorsque le schéma MCP décrit correctement les paramètres. Le schéma aide le modèle à produire un appel cohérent. La couche applicative conserve la responsabilité de vérifier que la demande respecte les règles du système.

Les contrats fermés facilitent aussi l’audit. Une propriété Currency ou CustomerId peut être enregistrée, filtrée et contrôlée. Une chaîne libre mélange plus facilement plusieurs intentions et peut contenir des instructions inattendues.

Séparer les outils de lecture et d’écriture

La distinction entre consultation et modification simplifie les politiques de sécurité.

Un serveur peut exposer plusieurs tools de lecture avec une identité limitée à la consultation. Les opérations d’écriture peuvent utiliser une autre identité, une autre policy ASP.NET Core ou un serveur séparé.

builder.Services.AddAuthorization(options =>
{
options.AddPolicy("McpRead", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "mcp.read");
});
options.AddPolicy("McpWrite", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "mcp.write");
});
});

Les permissions de l’identité technique doivent correspondre aux besoins réels du serveur. Un outil qui consulte des commandes n’a pas besoin d’un compte disposant de droits de suppression sur l’ensemble de la base.

Les tokens courts, restreints à une audience et associés à un périmètre précis réduisent l’impact d’une compromission. Les comptes utilisés par les agents doivent également rester distincts des comptes administrateurs humains afin de conserver une traçabilité exploitable. Ces principes figurent parmi les recommandations centrales du guide MCP fourni.

Les annotations MCP comme readOnlyHint ou destructiveHint peuvent aider le client à présenter correctement un tool. Elles décrivent l’intention de l’outil. L’autorisation effective reste appliquée par l’application, la gateway et les services appelés.

Prévoir une validation humaine pour les actions sensibles

Certaines opérations peuvent être préparées par un agent tout en conservant une validation explicite avant leur exécution.

La suppression d’une ressource, la publication d’un contenu, le déclenchement d’un déploiement ou la création d’un paiement font partie des actions qui méritent ce niveau de contrôle.

Le premier tool prépare l’action et retourne un aperçu. Un second tool exécute l’opération à partir d’un jeton d’approbation court et à usage unique.

public sealed record ApprovalToken(
Guid ApprovalId,
Guid UserId,
string Operation,
DateTimeOffset ExpiresAtUtc);

Le jeton peut être associé à une empreinte des paramètres. Une modification du montant, de l’identifiant ciblé ou de l’opération rend alors l’approbation invalide.

Le document fourni recommande de présenter à l’utilisateur l’action exacte qui sera exécutée, avec ses paramètres, avant d’autoriser les appels en écriture ou les opérations destructrices.

Considérer les contenus externes comme non fiables

Un tool peut récupérer un document, une page web, un commentaire GitHub ou une donnée stockée dans une base. Ce contenu peut contenir des instructions destinées à influencer le modèle.

La donnée retournée par un outil doit rester identifiée comme une donnée. Elle ne doit pas devenir une instruction de contrôle pour l’agent.

La protection repose sur plusieurs couches applicatives. Les formats acceptés sont limités. Les réponses sont structurées. Les champs sensibles sont supprimés. Les tailles maximales sont contrôlées. Les chaînes utilisées dans une commande SQL, une URL, un chemin de fichier ou un appel système sont validées indépendamment du modèle.

Une implémentation qui reçoit directement une commande shell reste particulièrement fragile :

[McpServerTool]
public Task<string> ExecuteAsync(string command)
{
return shell.ExecuteAsync(command);
}

Une interface spécialisée réduit fortement la surface d’action :

[McpServerTool(Name = "reports_generate_monthly")]
public async Task<ReportResult> GenerateMonthlyReportAsync(
int year,
int month,
CancellationToken cancellationToken)
{
var request = new GenerateMonthlyReportRequest(
year,
month);
return await reportService.GenerateAsync(
request,
cancellationToken);
}

Le second tool expose une capacité métier déterminée. Il ne permet pas à l’agent de choisir librement un exécutable, un argument ou un chemin système.

Rendre les opérations idempotentes

Un client MCP peut relancer un appel après un timeout ou une interruption réseau. Une opération d’écriture doit supporter cette situation.

L’appel peut recevoir une clé d’idempotence générée pour l’action en cours.

public async Task<CreateInvoiceResult> ExecuteAsync(
CreateInvoiceDraftRequest request,
CancellationToken cancellationToken)
{
var existing = await idempotencyStore.FindAsync(
request.IdempotencyKey,
cancellationToken);
if (existing is not null)
{
return existing.Deserialize<CreateInvoiceResult>();
}
var result = await invoiceService.CreateDraftAsync(
request,
cancellationToken);
await idempotencyStore.SaveAsync(
request.IdempotencyKey,
result,
cancellationToken);
return result;
}

La base peut imposer une contrainte d’unicité sur cette clé. Une seconde exécution retourne alors le résultat initial sans recréer la ressource.

Cette pratique devient importante lorsque l’agent enchaîne plusieurs tools et tente de poursuivre son workflow après une erreur temporaire.

Centraliser les politiques dans une gateway MCP

Une architecture comportant plusieurs serveurs MCP devient rapidement difficile à superviser lorsque chaque client se connecte directement à chaque serveur.

Une gateway MCP fournit un point central pour l’authentification, la journalisation, le filtrage, le rate limiting et l’application des politiques.

Dans un environnement Microsoft, cette couche peut s’appuyer sur un reverse proxy .NET avec YARP, sur une solution dédiée ou sur une combinaison avec Azure API Management lorsque les flux HTTP et les politiques d’entreprise doivent être centralisés.

La gateway peut imposer une liste de serveurs autorisés, vérifier leur identité, limiter les appels par utilisateur et bloquer certaines opérations selon l’environnement.

Le guide fourni recommande aussi l’utilisation de mTLS, d’une allowlist des serveurs, d’un contrôle des artefacts déployés et d’une journalisation complète au niveau de la gateway.

Corréler chaque appel avec OpenTelemetry

Un appel MCP traverse souvent plusieurs couches : le client, la gateway, le serveur MCP, le cas d’usage, la base de données et une API externe.

La trace doit permettre de reconstruire ce chemin.

public static class McpTelemetry
{
public static readonly ActivitySource ActivitySource =
new("CodeBuster.McpServer");
}
using var activity =
McpTelemetry.ActivitySource.StartActivity(
"mcp.tool.execute",
ActivityKind.Server);
activity?.SetTag("mcp.tool.name", toolName);
activity?.SetTag("mcp.request.id", requestId);
activity?.SetTag("enduser.id", userId);
activity?.SetTag("mcp.server.name", "customer-server");

Les logs structurés peuvent contenir le nom du tool, l’identité de l’appelant, la durée, le statut, la taille de la réponse et l’identifiant de corrélation.

logger.LogInformation(
"MCP tool {ToolName} completed for user {UserId} " +
"in {ElapsedMilliseconds} ms with status {Status}",
toolName,
userId,
elapsedMilliseconds,
status);
logger.LogInformation(
"MCP tool {ToolName} completed for user {UserId} " +
"in {ElapsedMilliseconds} ms with status {Status}",
toolName,
userId,
elapsedMilliseconds,
status);

Le prompt complet et les réponses métier ne doivent pas être enregistrés sans stratégie de classification et de masquage. Une adresse, un token, une donnée contractuelle ou une information personnelle peut facilement se retrouver dans ces contenus.

Isoler le runtime du serveur

Un serveur MCP exécute du code avec les permissions de son processus. Son environnement d’exécution doit limiter ce que ce processus peut lire, modifier et contacter.

L’image .NET peut fonctionner avec un utilisateur non-root.

FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
USER 10001
ENTRYPOINT ["dotnet", "CodeBuster.McpServer.dll"]

Le conteneur peut utiliser un système de fichiers en lecture seule, une limite de mémoire, une limite CPU et un réseau sortant restreint aux dépendances nécessaires.

Dans Kubernetes, une configuration minimale peut supprimer l’élévation de privilèges :

securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL

L’egress réseau doit être limité aux bases, APIs et services réellement appelés par le serveur. Cette approche réduit le rayon d’impact lorsqu’un tool, une dépendance ou le processus lui-même est compromis. Le document de référence insiste également sur les conteneurs non-root, les quotas de ressources, les profils seccomp et les allowlists réseau.

Sécuriser la chaîne de construction

Un serveur MCP peut être distribué comme package NuGet, image OCI, exécutable local ou extension intégrée à un environnement de développement.

Les versions doivent être verrouillées. Une image déployée en production doit être référencée par digest immuable. Le pipeline peut générer un SBOM, scanner les dépendances, signer l’image et bloquer le déploiement lorsqu’une vulnérabilité critique est détectée.

L’utilisation d’un tag mutable comme latest rend plus difficile la vérification de la version réellement exécutée. Le guide fourni recommande de pinner les artefacts, de vérifier leur hash et de refuser les composants non approuvés dans le pipeline.

Tester le comportement de l’agent autour des tools

Les tests d’un serveur MCP couvrent le contrat technique et les scénarios d’usage.

Les tests unitaires vérifient que chaque tool appelle le bon cas d’usage avec les bons paramètres. Les tests d’intégration valident le transport MCP, l’authentification, la sérialisation et les dépendances réelles. Testcontainers permet d’exécuter PostgreSQL, Redis ou un service simulé dans un environnement reproductible.

Les tests de sécurité doivent aussi vérifier les refus d’autorisation, les paramètres surdimensionnés, les identifiants invalides, les timeouts, les doubles appels et les tentatives d’exécution d’une action sensible sans approbation.

[Fact]
public async Task DeleteCustomer_ShouldBeRejected_WithoutApproval()
{
var request = new DeleteCustomerRequest(
CustomerId: Guid.NewGuid(),
ApprovalToken: null);
var result = await tool.DeleteAsync(
request,
CancellationToken.None);
result.Status.Should().Be("approval_required");
}

Un test utile ne vérifie pas uniquement que le tool fonctionne dans le cas nominal. Il confirme aussi que l’agent reste dans le périmètre autorisé lorsque ses paramètres sont incomplets, ambigus ou malveillants.

Une interface supplémentaire à gouverner

MCP rend une API .NET accessible à des agents capables d’enchaîner plusieurs opérations à partir d’une demande en langage naturel. Cette capacité s’intègre proprement dans une architecture existante lorsque les tools restent de simples adaptateurs vers la couche Application.

Les contrats typés, le moindre privilège, l’approbation humaine, l’idempotence, la gateway, l’observabilité et l’isolation du runtime constituent alors le socle d’une implémentation exploitable en production.

Le serveur MCP conserve une responsabilité limitée : exposer les capacités de l’application avec un contrat compréhensible par le modèle. Les règles métier, les autorisations et les garanties transactionnelles restent portées par les composants qui gouvernent déjà le système.