.NETC#MCPASP.NET CoreDevOpsAI Agents

Déployez un serveur MCP C# stateless, routable et observable avec ASP.NET Core, des outils DevOps étroits, des tests HTTP et une CI fiable.

Yva Hajatiana
22 août 2026
11 min de lecture
Partager :X / TwitterLinkedIn
Composition éditoriale abstraite sur un serveur MCP, le réseau et les opérations DevOps

MCP C# 2.0 : serveur HTTP stateless pour le DevOps

Un serveur MCP exposé par HTTP n’est plus forcément un processus relié à une session longue. Depuis la révision du protocole MCP du 28 juillet 2026 et le SDK C# 2.x, le transport HTTP est stateless par défaut. Chaque requête contient les informations de protocole nécessaires ; le serveur n’émet plus de Mcp-Session-Id et n’a donc pas besoin d’affinité de session pour servir plusieurs instances.

Ce changement est particulièrement utile pour les outils DevOps : lecture d’un état de build, consultation d’un déploiement ou recherche d’un incident. Ils peuvent être placés derrière un équilibreur de charge standard, autoscalés et observés comme une API ASP.NET Core ordinaire. Il ne change toutefois pas la frontière de sécurité : un outil MCP reste une capacité que le serveur doit authentifier, autoriser et limiter.

Ce tutoriel crée un petit serveur HTTP qui expose un outil de diagnostic de build, rend une région visible au routeur réseau grâce à un en-tête MCP normalisé, puis ajoute des contrôles de déploiement et de CI.

Pourquoi ce tutoriel maintenant ? La version 2.0 du SDK C# officiel aligne MCP sur la spécification 2026-07-28. Les versions 2.1 et 2.2 ont ensuite consolidé cette ligne. Épinglez une version 2.x validée par votre équipe et relisez les notes de version avant une mise à niveau.

Ce qui change pour une plateforme DevOps

Les anciennes intégrations HTTP pouvaient créer une session serveur, identifiée par Mcp-Session-Id. Un proxy devait alors conserver l’affinité vers l’instance qui avait créé cette session, ou l’application devait partager son état. Ce n’est pas nécessaire pour un outil qui reçoit tous ses paramètres dans chaque appel.

BesoinServeur HTTP statelessServeur stateful
Lecture d’un build ou d’un déploiementChoix recommandéInutilement coûteux
Plusieurs réplicas derrière un load balancerPas d’affinité requiseAffinité ou partage d’état à prévoir
Requête interactive vers le client avec MCP récentPossible avec MRTRPossible
Notifications spontanées, abonnements de ressources ou isolation par client ancienNonNécessaire

MRTR (Multi Round-Trip Requests) permet à un serveur récent de demander une information au client au cours d’un appel sans conserver une connexion longue. Ce n’est pas une raison pour rendre toutes les opérations interactives : un outil de production doit préférer des entrées explicites, validées et auditables.

La décision est fonctionnelle avant d’être technique. Gardez un mode stateful seulement si vous dépendez réellement de notifications spontanées, d’abonnements aux ressources ou d’une compatibilité avec des clients anciens qui exigent une session. Sinon, déclarer le mode stateless explicitement évite qu’une future valeur par défaut ne modifie silencieusement votre contrat.

Prérequis

Il faut :

  • le SDK .NET 8 ou ultérieur ;
  • un terminal PowerShell ;
  • un client MCP compatible HTTP pour l’intégration finale ;
  • Docker, uniquement si vous suivez la partie conteneur.

Les exemples ne contactent ni Azure DevOps ni un fournisseur IA. Le lecteur de build est une interface en mémoire : les tests sont donc reproductibles et ne demandent pas de jeton. Dans une application réelle, l’implémentation appellera une API interne avec l’identité de workload appropriée.

1. Créer le serveur HTTP

Créez une API minimale et ajoutez le package HTTP du SDK MCP. La version est volontairement épinglée : remplacez-la par la dernière version 2.x approuvée dans votre dépôt, plutôt que de laisser une préversion dériver au gré des restaurations.

dotnet new web -n BuildDiagnosticsMcp --framework net8.0
Set-Location BuildDiagnosticsMcp
dotnet add package ModelContextProtocol.AspNetCore --version 2.2.0
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Créez Program.cs. WithHttpTransport configure Streamable HTTP et MapMcp("/mcp") publie l’endpoint. SessionMode rend le choix lisible dans le code : aucune session de transport, aucun stockage partagé et aucune affinité au niveau MCP.

using Microsoft.AspNetCore.Authentication.JwtBearer;
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Server;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(); // Configure Authority, Audience et validation dans un vrai environnement.

builder.Services.AddAuthorization();
builder.Services.AddSingleton<IBuildReader, InMemoryBuildReader>();

builder.Services.AddMcpServer()
    .WithHttpTransport(options =>
    {
        options.SessionMode = HttpServerSessionMode.Stateless;
    })
    .WithTools<BuildTools>();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/health/live", () => Results.Ok(new { status = "live" }))
    .AllowAnonymous();

app.MapGet("/health/ready", (IBuildReader reader) =>
    reader.IsAvailable
        ? Results.Ok(new { status = "ready" })
        : Results.StatusCode(StatusCodes.Status503ServiceUnavailable))
    .AllowAnonymous();

app.MapMcp("/mcp")
    .RequireAuthorization();

app.Run();

N’ajoutez pas AllowAnonymous() à MapMcp pour faciliter un essai local : cela deviendrait facilement une exposition accidentelle. En développement, utilisez un fournisseur de jetons local ou isolez le serveur sur la boucle locale. En production, validez l’émetteur, l’audience, la durée du jeton et les permissions avant le handler MCP.

Restreindre les noms d’hôte

Kestrel n’effectue pas lui-même une validation complète de l’en-tête Host. Dans appsettings.json, limitez les hôtes acceptés. Utilisez les noms publics exacts en production et évitez *, qui favorise notamment les scénarios de DNS rebinding.

{
  "AllowedHosts": "localhost;build-mcp.internal.example"
}

Le proxy ou l’équilibreur de charge doit appliquer la même règle. L’authentification ne remplace pas ce contrôle ; ce sont deux frontières différentes.

2. Exposer un outil DevOps étroit

Un outil ne doit ni recevoir une commande shell arbitraire ni porter un jeton dans ses paramètres. L’exemple accepte seulement un identifiant de build et une région appartenant à une liste connue. Il renvoie un objet structuré, plus facile à vérifier et à exploiter qu’un long texte produit par le modèle.

Ajoutez BuildTools.cs :

using System.ComponentModel;
using ModelContextProtocol.Server;

[McpServerToolType]
public sealed class BuildTools(IBuildReader builds)
{
    [McpServerTool(Name = "build_get_status")]
    [Description("Lit le statut d'un build autorisé, sans le modifier.")]
    public async Task<BuildStatus> GetStatusAsync(
        [Description("Identifiant numérique du build")] int buildId,
        [McpHeader("Region")]
        [Description("Région de la plateforme : eu-west ou us-east")] string region,
        CancellationToken cancellationToken)
    {
        if (buildId <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(buildId));
        }

        if (region is not ("eu-west" or "us-east"))
        {
            throw new ArgumentException("Unknown region.", nameof(region));
        }

        return await builds.GetStatusAsync(buildId, region, cancellationToken);
    }
}

public sealed record BuildStatus(int Id, string Region, string State, DateTimeOffset UpdatedAt);

public interface IBuildReader
{
    bool IsAvailable { get; }
    Task<BuildStatus> GetStatusAsync(int buildId, string region, CancellationToken cancellationToken);
}

public sealed class InMemoryBuildReader : IBuildReader
{
    public bool IsAvailable => true;

    public Task<BuildStatus> GetStatusAsync(
        int buildId,
        string region,
        CancellationToken cancellationToken)
    {
        return Task.FromResult(new BuildStatus(
            buildId,
            region,
            "succeeded",
            DateTimeOffset.UtcNow));
    }
}

McpHeader("Region") demande au client compatible de refléter la valeur dans Mcp-Param-Region. Le SDK vérifie ensuite que l’en-tête et le corps JSON-RPC concordent. Un proxy peut donc router eu-west vers l’API de cette région sans analyser le corps de la requête. Cette optimisation est utile pour quelques paramètres simples et stables ; ne copiez jamais des données personnelles, un secret ou un texte libre dans un en-tête.

Le serveur reste l’autorité finale. Un en-tête aide au routage, il n’autorise pas une région et ne remplace pas la validation de region dans le handler.

3. Tester le contrat sans modèle ni réseau externe

La partie métier se teste comme toute application .NET. Créez le projet de tests et référencez le serveur :

dotnet new xunit -n BuildDiagnosticsMcp.Tests --framework net8.0
dotnet add BuildDiagnosticsMcp.Tests reference .\BuildDiagnosticsMcp.csproj
dotnet add BuildDiagnosticsMcp.Tests package Microsoft.NET.Test.Sdk

Ajoutez BuildToolsTests.cs. Le faux lecteur rend le test indépendant de l’heure, d’un compte de service et d’un build réel.

using Xunit;

public sealed class BuildToolsTests
{
    [Fact]
    public async Task Returns_the_build_for_an_allowed_region()
    {
        var reader = new FakeBuildReader();
        var tools = new BuildTools(reader);

        BuildStatus status = await tools.GetStatusAsync(
            buildId: 42,
            region: "eu-west",
            CancellationToken.None);

        Assert.Equal(42, status.Id);
        Assert.Equal("eu-west", status.Region);
        Assert.Equal("queued", status.State);
    }

    [Fact]
    public async Task Refuses_an_unknown_region()
    {
        var tools = new BuildTools(new FakeBuildReader());

        await Assert.ThrowsAsync<ArgumentException>(() => tools.GetStatusAsync(
            buildId: 42,
            region: "moon-1",
            CancellationToken.None));
    }

    private sealed class FakeBuildReader : IBuildReader
    {
        public bool IsAvailable => true;

        public Task<BuildStatus> GetStatusAsync(
            int buildId,
            string region,
            CancellationToken cancellationToken) =>
            Task.FromResult(new BuildStatus(
                buildId,
                region,
                "queued",
                DateTimeOffset.Parse("2026-08-22T00:00:00Z")));
    }
}

Exécutez ensuite la suite :

dotnet test .\BuildDiagnosticsMcp.Tests\BuildDiagnosticsMcp.Tests.csproj

Ce test ne prouve pas encore que l’authentification ou le protocole HTTP sont correctement câblés. Ajoutez un test d’intégration avec un jeton de test lorsque votre fournisseur d’identité et les politiques d’autorisation sont définis. Dans tous les cas, contrôlez les règles sensibles dans les services métier : un attribut de route MCP ne doit pas être le seul rempart contre une lecture inter-projet.

4. Conteneuriser sans recréer de l’état

Le serveur stateless se prête naturellement aux réplicas. Voici un Dockerfile multi-étapes minimal :

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY BuildDiagnosticsMcp.csproj ./
RUN dotnet restore
COPY . ./
RUN dotnet publish -c Release -o /app/publish --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app/publish ./
USER $APP_UID
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "BuildDiagnosticsMcp.dll"]

Ajoutez un HEALTHCHECK seulement si l’image contient un client HTTP adapté ; sinon laissez l’orchestrateur appeler /health/live et /health/ready. Ne rendez pas le contrôle de disponibilité dépendant d’un appel IA : un modèle lent ne doit pas provoquer le redémarrage d’un serveur qui peut encore traiter les diagnostics.

Pour construire et essayer l’image localement :

docker build -t build-diagnostics-mcp:local .
docker run --rm -p 8080:8080 build-diagnostics-mcp:local

Dans une plateforme Kubernetes, utilisez /health/live pour savoir si le processus doit être redémarré et /health/ready pour retirer temporairement une instance du trafic. La readiness peut vérifier une dépendance nécessaire à la lecture de builds, avec un timeout court et sans fuite de détail interne.

5. Garder les signaux exploitables

Le protocole moderne normalise notamment Mcp-Method, Mcp-Name et les en-têtes Mcp-Param-*. Ils facilitent les métriques côté proxy, mais ils ne suffisent pas à diagnostiquer un incident. Journalisez également, sans secret :

  • l’identité validée et son tenant ou projet ;
  • le nom de l’outil et le résultat d’autorisation ;
  • la région validée, le code de résultat et la durée ;
  • une corrélation avec le trace ID HTTP ;
  • les refus et limites atteintes, distinctement des erreurs techniques.

Ne journalisez ni le jeton Bearer, ni les en-têtes complets, ni une réponse brute qui pourrait contenir un secret de build. Les erreurs détaillées restent dans les traces protégées ; le client reçoit un message stable qui ne révèle pas l’infrastructure.

Pour un serveur MCP qui manipule des données de production, complétez cette couche avec une politique deny by default, des outils séparés par niveau de privilège et une approbation indépendante du modèle. Le guide Sécuriser un serveur MCP C# avec des politiques DevOps traite cette frontière de gouvernance.

6. Ajouter une CI qui bloque les régressions utiles

Ajoutez une workflow GitHub Actions similaire à celle-ci. Elle restaure, compile, teste et construit l’image, sans appeler un environnement de production ni nécessiter de secret.

name: Validate MCP server

on:
  pull_request:
    paths:
      - "src/BuildDiagnosticsMcp/**"
      - ".github/workflows/validate-mcp.yml"
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 8.0.x
      - run: dotnet restore src/BuildDiagnosticsMcp/BuildDiagnosticsMcp.sln --locked-mode
      - run: dotnet build src/BuildDiagnosticsMcp/BuildDiagnosticsMcp.sln --configuration Release --no-restore
      - run: dotnet test src/BuildDiagnosticsMcp/BuildDiagnosticsMcp.sln --configuration Release --no-build
      - run: docker build --tag build-diagnostics-mcp:ci src/BuildDiagnosticsMcp

Versionnez un fichier packages.lock.json avant d’activer --locked-mode. Sans lockfile, la commande échoue ; contourner ce signal avec une restauration flottante enlève précisément la reproductibilité recherchée. Une image de production peut être publiée dans un job distinct, protégé par l’environnement et une identité fédérée à privilèges minimaux.

Limites et décisions à prendre avant la production

Le mode stateless règle une contrainte de transport, pas l’état métier. Si l’outil lance une opération longue, créez un identifiant de demande durable dans votre propre stockage et exposez une lecture de statut idempotente. Ne tentez pas de retenir l’avancement dans la mémoire d’un replica.

Vérifiez aussi la compatibilité des clients réellement déployés. Le SDK C# peut négocier une voie ancienne quand il parle à un pair plus ancien, mais une capacité moderne telle que les en-têtes normalisés ou MRTR ne doit pas être supposée sans test d’intégration. Si une base de clients anciens exige des sessions, le mode StatefulForInitializeClients est une transition possible ; mesurez cette dette et fixez une échéance de retrait.

Enfin, un serveur MCP HTTP ne devrait pas être choisi parce qu’il est plus récent. Pour un outil strictement local et personnel, stdio reste souvent plus simple et réduit l’exposition réseau. Pour un service partagé, sans état de transport et correctement protégé, HTTP rend enfin MCP compatible avec les pratiques ordinaires de déploiement, de routage et d’observabilité ASP.NET Core.

Sources

Mots-clés :.NETC#MCPASP.NET CoreDevOpsAI Agents
Y

Yva Hajatiana

Articles techniques sur l'ingénierie logicielle, .NET, le cloud et l'intelligence artificielle appliquée aux applications.