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 commeblog_posts_create,blog_media_uploadoublog_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
authorIdetbannerImageDocumentId.
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 :
blog_authors_listblog_media_uploadblog_categories_listblog_posts_createblog_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 --> SQLiteJe 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_listblog_posts_getblog_posts_createblog_posts_updateblog_posts_deleteblog_media_uploadblog_media_listblog_authors_listblog_categories_listblog_categories_createblog_categories_updateblog_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 creeHermes 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 : parentUn 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 :
blog_categories_createblog_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/blogavec 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/blogest 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
- Microsoft Learn, deploiement d'un serveur MCP .NET avec
ModelContextProtocol.AspNetCore: https://learn.microsoft.com/en-us/azure/container-apps/tutorial-mcp-server-dotnet - Blog .NET, construire un serveur MCP en C# : https://devblogs.microsoft.com/dotnet/build-a-model-context-protocol-mcp-server-in-csharp/
- Documentation API du SDK C# MCP : https://csharp.sdk.modelcontextprotocol.io/api/ModelContextProtocol.AspNetCore.html