Quand je veux qu'un Agent IA comme Hermes interagisse avec une plateforme applicative, je ne veux pas lui donner un vieux couteau suisse rouille sous forme de prompt geant. Je veux lui exposer des outils precis, typables, securises, observables, et branches directement sur mes cas metier.

C'est exactement le role du Model Context Protocol, ou MCP. MCP permet a un agent de decouvrir des outils, de comprendre leurs schemas d'entree/sortie, puis de les appeler via un protocole standardise. En pratique, je peux transformer mon application ASP.NET Core en serveur d'outils IA, sans demander a l'agent de scraper une interface HTML ou de deviner comment appeler mes APIs REST.

Dans cet article, je vais expliquer comment je mets en place un serveur MCP dans une librairie C# pour permettre a Hermes d'interagir avec une plateforme comme mon blog Appliman : creation d'articles, upload de medias, lecture des auteurs, gestion des categories, recherche dans les contenus. Bref, je donne a l'agent les cles de l'atelier, mais pas le badge admin du datacenter. 😄

Objectif final : exposer un endpoint MCP HTTP sur /mcp/blog, decouvrir automatiquement des outils C#, et permettre a Hermes d'appeler des fonctions comme blog_posts_create, blog_media_upload ou blog_authors_list.

Pourquoi MCP plutot qu'une API REST classique ?

Une API REST est parfaite pour un client logiciel classique. Mais un agent IA n'est pas un client classique. Il doit decouvrir dynamiquement ce qu'il peut faire, comprendre les parametres attendus, choisir l'outil pertinent, puis enchainer plusieurs appels.

Avec une API REST pure, je dois souvent fournir :

  • une documentation OpenAPI ;
  • des instructions systeme pour expliquer quoi appeler ;
  • des exemples de payload ;
  • de la logique de mapping cote agent ;
  • et un petit sacrifice rituel quand l'agent confond authorId et bannerImageDocumentId.

Avec MCP, le serveur expose directement des tools. Chaque tool a :

  • un nom stable ;
  • une description ;
  • des parametres types ;
  • un schema exploitable par le client MCP ;
  • une implementation cote serveur.

Pour Hermes, cela change tout. Au lieu de dire :

"Va appeler telle route HTTP avec tel JSON, puis interprete la reponse, puis appelle une autre route..."

Je peux lui dire :

"Tu as un serveur MCP de blog. Utilise les outils disponibles pour publier un article."

Et Hermes peut orchestrer :

  1. blog_authors_list
  2. blog_media_upload
  3. blog_categories_list
  4. blog_posts_create
  5. blog_posts_get

Le tout avec des appels structures, tracables, et beaucoup moins de magie noire dans le prompt.

Architecture generale

Voici la vision d'ensemble.

flowchart LR
    H[Hermes<br/>Agent IA] -->|MCP Streamable HTTP| MCP[/Endpoint /mcp/blog/]
    MCP --> Tools[BlogMcpTools]
    Tools --> MediatR[IMediator]
    Tools --> EF[EF Core DbContext]
    Tools --> Storage[IStorageService]
    MediatR --> Domain[Handlers metier]
    EF --> SQLite[(SQLite)]
    Storage --> Documents[(Documents)]
    Domain --> SQLite

Je garde une separation volontaire :

  • le transport MCP reste une couche d'exposition ;
  • les outils MCP appellent le domaine applicatif existant ;
  • les regles metier restent dans les handlers, validateurs et services ;
  • la base de donnees n'est pas manipulee directement partout, sauf pour les operations simples et controlees.

En clair : MCP n'est pas une deuxieme application. C'est une nouvelle porte d'entree vers la meme logique metier.

Installer le package MCP pour ASP.NET Core

Dans ma librairie C#, j'ajoute le package NuGet Microsoft MCP pour ASP.NET Core :

dotnet add src\ApplimanBlog.Core\ApplimanBlog.Core.csproj package ModelContextProtocol.AspNetCore

Dans le .csproj, cela donne par exemple :

<ItemGroup>
  <PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
</ItemGroup>

Le package fournit l'integration ASP.NET Core :

  • l'enregistrement du serveur MCP dans le conteneur DI ;
  • le transport HTTP ;
  • la decouverte automatique des tools ;
  • le mapping d'un endpoint MCP avec MapMcp(...).

Declarer le serveur MCP dans la librairie

Je veux que la librairie expose une methode d'extension propre. Mon application web ne devrait pas connaitre tous les details internes du serveur MCP. Elle doit juste pouvoir dire :

app.UseApplimanBlog();

Dans StartupExtensions.cs, j'ajoute l'enregistrement du serveur :

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing;
using ModelContextProtocol.AspNetCore;

namespace ApplimanBlog;

public static class StartupExtensions
{
    public static async Task<ApplimanBlogConfiguration> AddApplimanBlog(
        this WebApplicationBuilder builder,
        Action<OpenAIConfiguration> openAiConfiguration,
        string[] args)
    {
        // Configuration existante de l'application :
        // - DbContext
        // - ChannelMediator
        // - services metier
        // - authentification API key
        // - etc.

        builder.Services.AddHttpContextAccessor();

        builder.Services
            .AddMcpServer()
            .WithHttpTransport()
            .WithToolsFromAssembly();

        return settings;
    }

    public static IEndpointRouteBuilder UseApplimanBlog(this IEndpointRouteBuilder app)
    {
        app.MapMcp("/mcp/blog")
            .RequireAuthorization(new AuthorizeAttribute
            {
                AuthenticationSchemes = ApiKeyAuthenticationHandler.SchemeName
            });

        return app;
    }
}

Les trois lignes importantes sont :

builder.Services
    .AddMcpServer()
    .WithHttpTransport()
    .WithToolsFromAssembly();

Elles signifient :

  • AddMcpServer() : j'active le serveur MCP ;
  • WithHttpTransport() : j'utilise le transport HTTP streamable ;
  • WithToolsFromAssembly() : je demande au SDK de scanner l'assembly courant pour trouver mes tools.

Puis :

app.MapMcp("/mcp/blog")
    .RequireAuthorization(new AuthorizeAttribute
    {
        AuthenticationSchemes = ApiKeyAuthenticationHandler.SchemeName
    });

Cette ligne expose le serveur MCP sur l'URL, tout en imposant l'authentification par cle API :

https://www.appliman.com/mcp/blog

Pour mon blog, l'interface humaine reste sur :

https://www.appliman.com/blog

Et l'interface agent vit sur :

https://www.appliman.com/mcp/blog

Deux mondes, une plateforme. Les humains lisent, Hermes agit. C'est presque poetique, mais avec des GUID. ✨

Brancher l'extension dans l'application web

Dans Program.cs, j'appelle l'extension apres le routing et le mapping des controllers :

var app = builder.Build();

app.UseRouting();

app.MapControllers();
app.UseApplimanBlog();

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

app.Run();

Selon la politique de securite choisie, je peux aussi proteger l'endpoint MCP avec une authentification dediee, une API key, OAuth, un reverse proxy, ou une restriction reseau. J'y reviens plus loin, parce que donner des outils d'ecriture a un agent sans controle d'acces, c'est comme laisser un stagiaire avec DROP DATABASE dans le presse-papiers. 😅

Creer une classe de tools MCP

Le SDK C# s'appuie sur des attributs. Je declare une classe avec [McpServerToolType], puis chaque methode exposable avec [McpServerTool].

using System.ComponentModel;
using ModelContextProtocol.Server;

namespace ApplimanBlog.McpServer;

[McpServerToolType]
public sealed class BlogMcpTools
{
    [McpServerTool]
    [Description("List available blog authors with their ids.")]
    public Task<object> blog_authors_list(
        [Description("Zero-based page index.")] int pageIndex = 0,
        [Description("Number of items per page.")] int pageSize = 100,
        CancellationToken cancellationToken = default)
    {
        // Implementation
    }
}

Je prefere nommer les tools avec un prefixe metier stable :

  • blog_posts_list
  • blog_posts_get
  • blog_posts_create
  • blog_posts_update
  • blog_posts_delete
  • blog_media_upload
  • blog_media_list
  • blog_authors_list
  • blog_categories_list
  • blog_categories_create
  • blog_categories_update
  • blog_categories_delete

Ce nommage aide enormement les agents. Hermes voit immediatement le domaine et l'action. Je ne l'oblige pas a choisir entre Create, Save, Upload2, DoStuffAsync et autres fossiles applicatifs.

Exemple complet : lister les auteurs

Pour creer un article, Hermes a besoin d'un authorId. Je lui expose donc un outil dedie.

using Appliman.Datas.Enums;
using ApplimanBlog.Models.Members;
using ModelContextProtocol.Server;
using System.ComponentModel;

namespace ApplimanBlog.McpServer;

[McpServerToolType]
public sealed class BlogMcpTools(
    IMediator mediator,
    IHttpContextAccessor httpContextAccessor)
{
    [McpServerTool]
    [Description("List available blog authors with their ids.")]
    public async Task<McpListResponse<AuthorApiItem>> blog_authors_list(
        [Description("Zero-based page index.")] int pageIndex = 0,
        [Description("Number of items per page.")] int pageSize = 100,
        [Description("Optional search term matched against author name and email.")] string? search = null,
        CancellationToken cancellationToken = default)
    {
        var filter = new MemberListFilter
        {
            PageIndex = pageIndex,
            PageSize = pageSize,
            ComputeRowCount = ComputeFilteredList.OnlyInFirstPage,
        };

        if (!string.IsNullOrWhiteSpace(search))
        {
            filter.SearchList.Add(new SearchHint
            {
                ColumnName = "all",
                Term = search,
                Operator = SearchOperator.Contains,
            });
        }

        var page = await mediator.Send(filter, cancellationToken);
        var baseUrl = GetBaseUrl();

        return new McpListResponse<AuthorApiItem>
        {
            ItemList = page.List.Select(i => AuthorApiItem.Create(i, baseUrl)).ToList(),
            RowCount = page.Total.RowCount,
        };
    }

    private string GetBaseUrl()
    {
        var request = httpContextAccessor.HttpContext?.Request;
        return request is null
            ? string.Empty
            : $"{request.Scheme}://{request.Host}";
    }
}

public sealed class McpListResponse<T>
{
    public List<T> ItemList { get; set; } = [];
    public int RowCount { get; set; }
}

Le retour est volontairement simple. Hermes n'a pas besoin d'un objet ultra abstrait ; il a besoin d'une liste exploitable et d'un RowCount.

Exemple complet : uploader un media

Avant, mon API de creation d'article attendait un bannerImageDocumentId. C'est logique cote backend, mais penible cote agent. Hermes ne devrait pas devoir savoir qu'il faut uploader ailleurs, recuperer un document, puis recoller l'id.

Je cree donc blog_media_upload.

[McpServerTool]
[Description("Upload an image media file from base64 content and return its document id and public URLs.")]
public async Task<MediaApiItem> blog_media_upload(
    [Description("Original file name, including extension.")] string fileName,
    [Description("Image MIME type, for example image/png or image/jpeg.")] string contentType,
    [Description("Base64 encoded image content without a data URI prefix.")] string base64Content,
    [Description("Optional media title. Defaults to the file name without extension.")] string? title = null,
    [Description("Optional media description.")] string? description = null,
    CancellationToken cancellationToken = default)
{
    if (string.IsNullOrWhiteSpace(fileName))
    {
        throw new ArgumentException("FileName is required.", nameof(fileName));
    }

    if (string.IsNullOrWhiteSpace(contentType) || !contentType.StartsWith("image/"))
    {
        throw new ArgumentException("Only image media can be uploaded.", nameof(contentType));
    }

    byte[] bytes;
    try
    {
        bytes = Convert.FromBase64String(base64Content);
    }
    catch (FormatException ex)
    {
        throw new ArgumentException("Base64Content is not a valid base64 payload.", nameof(base64Content), ex);
    }

    var document = await mediator.CreateDocument(cancellationToken);
    document.FileName = fileName;
    document.Title = string.IsNullOrWhiteSpace(title)
        ? Path.GetFileNameWithoutExtension(fileName)
        : title;
    document.Description = description;
    document.ContentType = contentType;
    document.FileSize = bytes.LongLength;
    document.Base64String = base64Content;
    document.LastUpdate = dateTimeProvider.UtcNow;

    var saveResult = await mediator.SaveDocument(document, cancellationToken: cancellationToken);
    ThrowIfPersistFailed(saveResult);

    return MediaApiItem.Create(document, GetBaseUrl());
}

La reponse peut ressembler a ceci :

{
  "id": "019492f4-b85a-7c4f-aef1-34be64b54ad9",
  "title": "architecture-mcp",
  "fileName": "architecture-mcp.png",
  "contentType": "image/png",
  "fileSize": 184239,
  "url": "https://www.appliman.com/document/019492f4-b85a-7c4f-aef1-34be64b54ad9",
  "imageUrl": "https://www.appliman.com/image/s/lg/019492f4-b85a-7c4f-aef1-34be64b54ad9/architecture-mcp.png"
}

Maintenant Hermes peut uploader l'image, recuperer l'id, puis l'utiliser comme bannerImageDocumentId. Pas besoin de lui faire une chasse au tresor dans les endpoints REST.

Exemple complet : creer un article

Voici un outil de creation d'article. Je l'ai concu pour accepter directement les elements utiles a un agent :

  • auteur ;
  • slug ;
  • titre ;
  • SEO title ;
  • SEO description ;
  • contenu Markdown ;
  • date de publication ;
  • image de banniere ;
  • tags ;
  • categories.
[McpServerTool]
[Description("Create a blog post.")]
public async Task<PostApiItem> blog_posts_create(
    [Description("Author id.")] Guid authorId,
    [Description("Post slug. If empty, it is generated from the title.")] string? slug,
    [Description("Post title.")] string title,
    [Description("SEO title.")] string seoTitle,
    [Description("SEO description.")] string seoDescription,
    [Description("Markdown content.")] string content,
    [Description("Optional subtitle.")] string? subtitle = null,
    [Description("Optional publication date.")] DateTime? publishedDate = null,
    [Description("Optional banner image document id.")] Guid? bannerImageDocumentId = null,
    [Description("Optional tag ids.")] List<Guid>? tagIdList = null,
    [Description("Optional category ids.")] List<Guid>? categoryIdList = null,
    CancellationToken cancellationToken = default)
{
    var post = await mediator.Send(new CreatePostRequest(authorId), cancellationToken);

    var request = new SavePostApiRequest
    {
        AuthorId = authorId,
        Slug = string.IsNullOrWhiteSpace(slug) ? Slug.FromTitle(title) : slug,
        Title = title,
        SeoTitle = seoTitle,
        SeoDescription = seoDescription,
        Subtitle = subtitle,
        Content = content,
        PublishedDate = publishedDate,
        BannerImageDocumentId = bannerImageDocumentId,
        TagIdList = tagIdList ?? [],
        CategoryIdList = categoryIdList ?? [],
    };

    return await SavePostAsync(post, request, cancellationToken);
}

Le coeur du travail est dans SavePostAsync.

private async Task<PostApiItem> SavePostAsync(
    Post post,
    SavePostApiRequest request,
    CancellationToken cancellationToken)
{
    await ValidateCategoryIdListAsync(request.CategoryIdList, cancellationToken);

    post.AuthorId = request.AuthorId;
    post.Slug = request.Slug;
    post.Title = request.Title;
    post.SeoTitle = request.SeoTitle;
    post.SeoDescription = request.SeoDescription;
    post.Subtitle = request.Subtitle;
    post.PublishedDate = request.PublishedDate;
    post.BannerImageDocumentId = request.BannerImageDocumentId;
    post.Content = await postApiContentService.ReplaceEmbeddedImagesAsync(request, cancellationToken);
    post.TagItemList = CreateTagItemList(post, request.TagIdList);

    var saveResult = await mediator.Send(new SavePostRequest(post), cancellationToken);
    ThrowIfPersistFailed(saveResult);

    await SaveCategoryItemsAsync(post.Id, request.CategoryIdList, cancellationToken);

    var savedPost = await mediator.GetPostById(post.Id, cancellationToken)
        ?? throw new InvalidOperationException("Post was saved but could not be reloaded.");

    return PostApiItem.Create(savedPost);
}

Je garde ici un principe important : un tool MCP n'est pas une excuse pour court-circuiter les regles metier.

Le tool orchestre. Le domaine decide.

Cycle d'appel complet avec Hermes

Voici le scenario typique.

sequenceDiagram
    participant U as Utilisateur
    participant H as Hermes
    participant MCP as /mcp/blog
    participant B as BlogMcpTools
    participant D as Domaine Appliman
    participant DB as SQLite

    U->>H: Publie un article sur MCP et Hermes
    H->>MCP: tools/list
    MCP-->>H: blog_authors_list, blog_media_upload, blog_posts_create...
    H->>MCP: call blog_authors_list
    MCP->>B: blog_authors_list()
    B->>D: MemberListFilter
    D->>DB: SELECT Member
    DB-->>D: auteurs
    D-->>B: page auteurs
    B-->>MCP: AuthorApiItem[]
    MCP-->>H: resultat structure
    H->>MCP: call blog_media_upload
    MCP->>B: blog_media_upload(...)
    B->>D: SaveDocument
    D->>DB: INSERT Document
    MCP-->>H: documentId + URLs
    H->>MCP: call blog_posts_create
    MCP->>B: blog_posts_create(...)
    B->>D: SavePostRequest
    D->>DB: INSERT Post + PostCategoryItem
    MCP-->>H: PostApiItem
    H-->>U: Article cree

Hermes n'a pas besoin de connaitre les tables SQL. Il voit des capacites metier.

Gestion des categories

Les tags sont utiles pour decrire un contenu. Les categories, elles, structurent le blog. Je prefere les separer.

Modele simplifie :

erDiagram
    Post {
        Guid Id PK
        string Slug
        string Title
        string Content
        Guid AuthorId
        Guid BannerImageDocumentId
    }

    Category {
        Guid Id PK
        string Name
        string Slug
        string Description
        Guid ParentCategoryId
        int Position
    }

    PostCategoryItem {
        Guid Id PK
        Guid PostId
        Guid CategoryId
        DateTime CreationDate
    }

    Post ||--o{ PostCategoryItem : has
    Category ||--o{ PostCategoryItem : classifies
    Category ||--o{ Category : parent

Un tool de creation de categorie peut etre tres simple :

[McpServerTool]
[Description("Create a hierarchical blog category.")]
public async Task<CategoryApiItem> blog_categories_create(
    [Description("Category name.")] string name,
    [Description("Category slug. If empty, it is generated from the name.")] string? slug = null,
    [Description("Optional category description.")] string? description = null,
    [Description("Optional parent category id.")] Guid? parentCategoryId = null,
    [Description("Optional category display position.")] int? position = null,
    CancellationToken cancellationToken = default)
{
    await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken);

    var category = new Category
    {
        Id = Guid.CreateVersion7(),
        CreationDate = dateTimeProvider.Now,
    };

    await ApplyCategoryRequestAsync(
        db,
        category,
        name,
        slug,
        description,
        parentCategoryId,
        position,
        cancellationToken);

    db.Categories.Add(category);
    await db.SaveChangesAsync(cancellationToken);

    return CategoryApiItem.Create(category);
}

Avec ca, Hermes peut organiser un contenu avant de le publier. Par exemple :

"Cree une categorie IA Generative, puis publie l'article dedans."

L'agent peut appeler :

  1. blog_categories_create
  2. blog_posts_create

Recherche globale d'articles

Pour qu'un agent travaille intelligemment sur un blog, il doit pouvoir retrouver les contenus existants. Sinon, il va recreer trois articles identiques avec des titres differents. Oui, les agents ont parfois une ame de stagiaire SEO sous cafeine.

J'ajoute donc un parametre search sur la liste des articles :

if (!filter.Search.IsNullOrTrimmedEmpty())
{
    var term = $"%{filter.Search!.Trim()}%";

    query = query.Where(p =>
        EF.Functions.Like(p.Title, term)
        || EF.Functions.Like(p.SeoTitle, term)
        || EF.Functions.Like(p.SeoDescription, term)
        || (p.Subtitle != null && EF.Functions.Like(p.Subtitle, term))
        || EF.Functions.Like(p.Content, term));
}

Et cote MCP :

[McpServerTool]
[Description("List blog posts with optional publication, author, slug, text search and category filters.")]
public async Task<PostApiListResponse> blog_posts_list(
    int pageIndex = 0,
    int pageSize = 20,
    bool? published = null,
    Guid? authorId = null,
    string? slug = null,
    string? search = null,
    List<Guid>? categoryIdList = null,
    CancellationToken cancellationToken = default)
{
    var filter = new PostListFilter
    {
        PageIndex = pageIndex,
        PageSize = pageSize,
        Published = published,
        AuthorId = authorId,
        Slug = slug,
        Search = search,
        CategoryIdList = categoryIdList ?? [],
        ComputeRowCount = ComputeFilteredList.OnlyInFirstPage,
    };

    var page = await mediator.Send(filter, cancellationToken);

    return new PostApiListResponse
    {
        ItemList = page.List.Select(PostApiItem.Create).ToList(),
        RowCount = page.Total.RowCount,
    };
}

Je peux ensuite demander a Hermes :

"Trouve les articles deja publies qui parlent de MCP, puis propose un angle complementaire."

Hermes appelle blog_posts_list(search: "MCP", published: true) et travaille sur des donnees reelles.

Securite : ne pas exposer un canon laser sur Internet

Un endpoint MCP peut donner acces a des operations sensibles :

  • creer un article ;
  • modifier un article ;
  • supprimer un article ;
  • uploader un media ;
  • manipuler des categories ;
  • lire des donnees internes.

Je dois donc penser securite des le depart.

flowchart TD
    Internet((Internet)) --> Proxy[Reverse proxy / WAF]
    Proxy --> Auth[Authentification<br/>API key / OAuth / Entra ID]
    Auth --> Policy[Policies d'autorisation]
    Policy --> MCP[/mcp/blog/]
    MCP --> Tools[Tools autorises]
    Tools --> Audit[Logs + audit trail]
    Audit --> SIEM[Monitoring]

Mes recommandations :

  • proteger /mcp/blog avec une authentification forte ;
  • appliquer l'autorisation directement sur MapMcp, et ne jamais supposer que le serveur MCP herite de l'authentification des controllers REST ;
  • separer les droits lecture/ecriture ;
  • tracer chaque appel tool avec son utilisateur ou agent ;
  • limiter la taille des uploads ;
  • valider les MIME types ;
  • appliquer les validateurs metier existants ;
  • refuser les suppressions dangereuses sans regle explicite ;
  • ajouter un rate limit ;
  • isoler les secrets ;
  • ne jamais exposer directement des operations SQL arbitraires.

Un bon tool MCP est un outil metier. Pas un tunnel vers la base.

Le point critique : verrouiller MapMcp

Dans une application ASP.NET Core, mes controllers REST peuvent etre proteges par des attributs comme :

[Authorize(AuthenticationSchemes = ApiKeyAuthenticationHandler.SchemeName)]
[Route("api/blog/posts")]
public sealed class PostApiController : ControllerBase
{
}

Mais ca ne protege pas automatiquement mon endpoint MCP. Le serveur MCP est mappe separement :

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

Si je laisse cette ligne telle quelle, je prends le risque d'exposer mes tools MCP sans cle API. Et comme certains tools peuvent creer ou modifier des articles, ce n'est pas un petit oubli cosmétique. C'est plutot le genre d'oubli qui transforme un endpoint en distributeur automatique de contenu. 😬

Je verrouille donc explicitement le endpoint MCP :

using ApplimanBlog.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing;
using ModelContextProtocol.AspNetCore;

public static IEndpointRouteBuilder UseApplimanBlog(this IEndpointRouteBuilder app)
{
    app.MapMcp("/mcp/blog")
        .RequireAuthorization(new AuthorizeAttribute
        {
            AuthenticationSchemes = ApiKeyAuthenticationHandler.SchemeName
        });

    return app;
}

L'ordre des middlewares compte aussi. Je m'assure que l'authentification et l'autorisation sont installees avant que les endpoints soient executes :

app.UseRouting();

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

app.MapControllers();
app.UseApplimanBlog();

Ensuite, je teste explicitement les deux chemins :

curl -i https://www.appliman.com/mcp/blog

Sans cle API, je dois obtenir un 401 ou un 403.

Puis :

curl -i https://www.appliman.com/mcp/blog \
  -H "X-Api-Key: ${APPLIMAN_BLOG_API_KEY}"

Avec une cle valide, le serveur MCP doit repondre normalement.

La regle que je garde en tete est simple : je protege le endpoint MCP lui-meme, pas seulement les controllers REST voisins. Les voisins sympas ne remplacent pas une serrure sur la porte.

Observabilite

Quand Hermes appelle un outil, je veux pouvoir repondre a ces questions :

  • Quel agent a appele quel outil ?
  • Avec quels parametres ?
  • Combien de temps l'appel a pris ?
  • Quelle entite a ete creee ou modifiee ?
  • L'appel a-t-il echoue ?
  • Est-ce une erreur fonctionnelle ou technique ?

Je peux instrumenter mes tools :

[McpServerTool]
public async Task<PostApiItem> blog_posts_get(Guid id, CancellationToken cancellationToken = default)
{
    logger.LogInformation("MCP blog_posts_get called for post {PostId}", id);

    var post = await mediator.GetPostById(id, cancellationToken)
        ?? throw new InvalidOperationException($"Post {id} was not found.");

    return PostApiItem.Create(post);
}

Et si l'application utilise OpenTelemetry, je peux aller plus loin avec des spans par tool :

using System.Diagnostics;

private static readonly ActivitySource ActivitySource = new("ApplimanBlog.Mcp");

public async Task<PostApiItem> blog_posts_get(Guid id, CancellationToken cancellationToken = default)
{
    using var activity = ActivitySource.StartActivity("mcp.blog_posts_get");
    activity?.SetTag("blog.post.id", id);

    var post = await mediator.GetPostById(id, cancellationToken)
        ?? throw new InvalidOperationException($"Post {id} was not found.");

    return PostApiItem.Create(post);
}

Le jour ou Hermes publie 42 brouillons a 3h17 du matin, je veux savoir si c'etait une decision produit, un bug, ou un prompt qui a mange trop de sucre.

Gestion des erreurs

Je transforme les erreurs de validation en exceptions explicites. Le client MCP pourra les remonter a l'agent.

private static void ThrowIfPersistFailed(PersistResult result)
{
    if (!result.HasError)
    {
        return;
    }

    var messageList = result.ErrorBrokenRuleList
        .SelectMany(i => i.MessageList)
        .Distinct()
        .ToList();

    throw new InvalidOperationException(messageList.Count == 0
        ? "Save failed."
        : string.Join(Environment.NewLine, messageList));
}

Je prefere une erreur claire :

La description SEO est obligatoire.

plutot qu'un vague :

500 Internal Server Error

Un agent peut corriger un champ manquant. Il ne peut pas deviner un crash opaque, sauf s'il a des talents chamaniques que je ne souhaite pas encourager.

Configuration cote client MCP

Selon le client, la configuration varie. Le principe reste le meme : je declare un serveur MCP distant avec son URL.

Exemple conceptuel :

{
  "mcpServers": {
    "appliman-blog": {
      "type": "http",
      "url": "https://www.appliman.com/mcp/blog",
      "headers": {
        "X-Api-Key": "${APPLIMAN_BLOG_API_KEY}"
      },
      "tools": [
        "blog_posts_list",
        "blog_posts_get",
        "blog_posts_create",
        "blog_posts_update",
        "blog_media_upload",
        "blog_media_list",
        "blog_authors_list",
        "blog_categories_list"
      ]
    }
  }
}

Pour Hermes, j'aime garder la convention suivante :

  • un serveur MCP par domaine fonctionnel ;
  • des tools prefixes par domaine ;
  • une configuration de droits explicite ;
  • des variables d'environnement pour les secrets ;
  • aucun secret dans le prompt, jamais.

Prompt systeme cote Hermes

Je peux ensuite donner a Hermes une instruction sobre :

Tu peux utiliser le serveur MCP "appliman-blog" pour interagir avec le blog.
Avant de creer un article, verifie les auteurs disponibles avec blog_authors_list.
Si une image est fournie, upload-la avec blog_media_upload et utilise l'id retourne.
Classe les articles dans les categories disponibles avec blog_categories_list.
Quand tu crees ou modifies un article, respecte les contraintes SEO et retourne un resume des actions realisees.

Le prompt reste declaratif. Les capacites sont dans MCP.

Checklist de production

Avant de considerer mon serveur MCP pret pour la production, je verifie :

  • l'endpoint /mcp/blog est expose uniquement sur les environnements voulus ;
  • les tools d'ecriture sont authentifies et autorises ;
  • les uploads ont une limite de taille ;
  • les types MIME sont valides ;
  • les erreurs de validation sont comprehensibles ;
  • les appels sont logges ;
  • les migrations EF sont appliquees ;
  • les tests couvrent les flux critiques ;
  • les noms de tools sont stables ;
  • les descriptions de tools sont claires ;
  • les DTOs ne fuitent pas d'informations sensibles ;
  • le monitoring distingue REST, UI et MCP.

Tests automatises

Je garde des tests sur les flux metier, pas seulement sur le transport MCP. Par exemple, pour la creation d'un article :

[TestMethod]
public async Task BlogMcpTools_CanCreatePost_WithCategory()
{
    var app = await TestHelper.GetCurrentWebApplication();
    var mediator = app.Services.GetRequiredService<IMediator>();
    var dbFactory = app.Services.GetRequiredService<IDbContextFactory<ApplimanBlogDbContext>>();
    var tools = app.Services.GetRequiredService<BlogMcpTools>();

    var author = await mediator.Send(new CreateMemberRequest());
    author.Name = "Hermes";
    author.Email = $"hermes-{Guid.NewGuid():N}@example.com";
    author.Slug = $"hermes-{Guid.NewGuid():N}";
    await mediator.Send(new SaveMemberRequest(author));

    await using var db = await dbFactory.CreateDbContextAsync();
    var category = new Category
    {
        Id = Guid.CreateVersion7(),
        Name = "IA",
        Slug = $"ia-{Guid.NewGuid():N}",
        CreationDate = DateTime.Now,
        LastUpdate = DateTime.UtcNow,
    };
    db.Categories.Add(category);
    await db.SaveChangesAsync();

    var post = await tools.blog_posts_create(
        authorId: author.Id,
        slug: null,
        title: "MCP avec Hermes",
        seoTitle: "Mettre en place MCP avec Hermes",
        seoDescription: "Guide technique pour connecter un agent IA a une plateforme via MCP.",
        content: "# Bonjour MCP",
        categoryIdList: [category.Id]);

    Assert.AreEqual(author.Id, post.AuthorId);
    CollectionAssert.Contains(post.CategoryIdList, category.Id);
}

Le but n'est pas de tester le SDK Microsoft. Le but est de tester mon contrat metier : est-ce que l'agent peut vraiment faire son travail ?

Pieges classiques

1. Exposer trop d'outils

Un agent avec 150 tools devient parfois moins fiable. Je prefere commencer petit :

  • lister ;
  • lire ;
  • creer ;
  • mettre a jour ;
  • uploader.

Puis j'ajoute les operations avancees.

2. Utiliser des noms ambigus

SaveThing n'est pas un bon nom pour un agent. blog_posts_update est beaucoup plus clair.

3. Renvoyer des objets enormes

Si un outil retourne tout le contenu de 500 articles, l'agent va remplir son contexte pour rien. Je pagine, je filtre, et je fournis des outils de detail.

4. Court-circuiter la validation

MCP doit reutiliser les validateurs existants. Sinon l'agent devient une porte d'entree moins stricte que l'UI. Mauvaise idee. Tres mauvaise idee. Genre "commit directement sur main un vendredi soir" mauvaise idee.

5. Oublier les migrations

Si un tool s'appuie sur une nouvelle table, la migration doit etre proprement generee et appliquee. Dans mon cas, si PostCategoryItem n'existe pas, l'appel blog_posts_create echoue. La base de donnees est tetue : elle ne cree pas les tables par empathie.

Conclusion

Mettre en place un serveur MCP dans une application C# n'est pas seulement une integration technique. C'est une facon de rendre une plateforme agent-compatible.

Avec /mcp/blog, je donne a Hermes une interface structuree pour :

  • comprendre les auteurs disponibles ;
  • uploader des images ;
  • organiser les contenus par categories ;
  • rechercher les articles existants ;
  • creer et maintenir des articles ;
  • travailler avec les memes regles metier que l'application web.

Le point cle, c'est de ne pas penser MCP comme un gadget IA. Je le pense comme une surface applicative officielle, au meme niveau que l'API publique ou l'interface d'administration.

Une bonne integration MCP, c'est :

  • des tools peu nombreux mais puissants ;
  • des noms explicites ;
  • des schemas propres ;
  • une securite serieuse ;
  • une observabilite solide ;
  • des validations metier ;
  • et juste assez d'humour dans les logs pour survivre a la prod. 🚀

Sources utiles