Aller au contenu principal
Nicolas Cousin Tech SolutionsNicolas Cousin Tech Solutions
Module 9 sur 10

Exposer l'outil via un serveur MCP

Durée indicative : environ 18 min.

Le problème que ce module résout

Votre outil getOrder n'existe aujourd'hui qu'à l'intérieur de votre agent : il est déclaré dans agent.fsx et passé au modèle dans ChatOptions.Tools. Si un autre programme — un assistant de code dans votre éditeur, un autre agent, un outil d'une autre équipe — voulait l'utiliser, il faudrait recopier ce câblage dans chaque programme, avec sa propre façon de décrire l'outil.

Le Model Context Protocol (MCP) standardise cette rencontre entre des applications IA et des outils. Ce module expose le même getOrder, sans changer une ligne de commandes.fsx, via un serveur MCP écrit en F#. Pas besoin du modèle ici.

Ce que MCP standardise

Un serveur MCP propose des outils ; un client MCP (intégré à une application IA) les découvre et les appelle. Les messages sont du JSON-RPC, et pour un serveur local le transport le plus simple est stdio : le client démarre le serveur comme un sous-processus et lui parle par son entrée et sa sortie standard. Deux messages vous suffisent ici :

  • tools/list : « quels outils proposes-tu ? » — le serveur répond par le nom, la description et le schéma JSON des paramètres de chaque outil ;
  • tools/call : « appelle tel outil avec tels arguments » — le serveur exécute et renvoie le résultat.

Comparez avec ce que vous avez fait jusqu'ici :

Outil dans l'agent (modules 5–8) Outil derrière un serveur MCP
Qui l'utilise ? votre agent seulement tout client MCP
Où est la description de l'outil ? dans votre code, au format de Microsoft.Extensions.AI publiée par le serveur, au format du protocole
Qui exécute la fonction ? votre processus le processus du serveur
Pour changer l'outil modifier chaque application modifier le seul serveur

Le SDK C# officiel de MCP (ModelContextProtocol sur NuGet) est maintenu dans le dépôt modelcontextprotocol/csharp-sdk. Sa documentation montre des serveurs en C# ; aucun exemple officiel n'est écrit en F#. Les scripts de ce module ont été testés le 2026-10-27 avec ModelContextProtocol 2.3.0.

Une règle avant d'écrire : stdout appartient au protocole

Puisque le client lit la sortie standard du serveur comme du JSON-RPC, la spécification MCP est explicite : le serveur ne doit rien écrire sur stdout qui ne soit pas un message MCP valide ; il peut en revanche écrire ses journaux sur stderr. Un printfn de débogage, ou un journal envoyé sur la console, enfreindrait cette règle. C'est pour cela que commandes.fsx n'affiche rien depuis le module 3 : le serveur va le charger. Les journaux du serveur seront envoyés sur stderr, la sortie d'erreur, que le protocole n'utilise pas.

Exercice — écrire le serveur

Créez serveur-mcp.fsx. L'hôte générique .NET (Host.CreateApplicationBuilder) est le même que dans un projet C# moderne : il est fourni. La partie qui vous revient est la déclaration de l'outil :

// serveur-mcp.fsx — module 09 : exposer getOrder à n'importe quel client MCP.
// Testé le 2026-10-27 : ModelContextProtocol 2.3.0, Microsoft.Extensions.Hosting 10.0.14.
#r "nuget: ModelContextProtocol, 2.3.0"
#r "nuget: Microsoft.Extensions.Hosting, 10.0.14"
#load "commandes.fsx"

open System
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.Logging
open ModelContextProtocol.Server

// La fonction F# du module 04, inchangée, déclarée comme outil MCP.
let outilCommande =
    // À vous : McpServerTool.Create(délégué, McpServerToolCreateOptions(...))

// FOURNI — l'hôte générique .NET, comme dans un projet C#.
let builder = Host.CreateApplicationBuilder()

// stdout est réservé au protocole : tous les journaux partent sur stderr.
builder.Logging.AddConsole(fun options ->
    options.LogToStandardErrorThreshold <- LogLevel.Trace)
|> ignore

builder.Services.AddMcpServer().WithStdioServerTransport().WithTools([ outilCommande ])
|> ignore

builder.Build().RunAsync().GetAwaiter().GetResult()

Complétez outilCommande :

  • le délégué se construit comme au module 5, à partir de Commandes.getOrder ;
  • les options s'écrivent avec la syntaxe d'initialisation vue au module 2 : McpServerToolCreateOptions(Name = "getOrder", Description = "..."). Reprenez la description de l'agent : « Retourne le statut d'une commande à partir de son numéro. »

Dans le code fourni, |> ignore jette la valeur renvoyée par un appel dont on n'a pas besoin : F# vous avertit (FS0020) si vous ne le dites pas explicitement, là où C# l'ignore en silence.

Dans la documentation C#, vous verrez souvent une autre façon de déclarer les outils, avec des attributs [McpServerToolType] et [McpServerTool] sur une classe. Elle fonctionne aussi en F#, mais la déclaration explicite ci-dessus évite d'écrire une classe pour une seule fonction.

Solution

let outilCommande =
    McpServerTool.Create(
        Func<int, string>(Commandes.getOrder),
        McpServerToolCreateOptions(
            Name = "getOrder",
            Description = "Retourne le statut d'une commande à partir de son numéro."
        )
    )

Si vous lancez dotnet fsi serveur-mcp.fsx directement, rien ne s'affiche sur la sortie standard, et le serveur attend des messages : c'est normal. Arrêtez-le avec Ctrl+C. La vérification passe par un client.

Exercice — vérifier avec un client MCP

Un client de vérification est fourni. Il démarre lui-même votre serveur (dotnet fsi serveur-mcp.fsx), demande la liste des outils, puis appelle getOrder trois fois — dont une avec un argument du mauvais type. Créez client-mcp.fsx à côté du serveur :

// client-mcp.fsx — module 09, FOURNI : un client MCP de vérification.
// Il démarre serveur-mcp.fsx, liste ses outils, puis appelle getOrder trois fois.
#r "nuget: ModelContextProtocol, 2.3.0"

open System
open System.Collections.Generic
open ModelContextProtocol.Client
open ModelContextProtocol.Protocol

let cheminServeur = IO.Path.Combine(__SOURCE_DIRECTORY__, "serveur-mcp.fsx")

let optionsTransport =
    StdioClientTransportOptions(
        Name = "commandes",
        Command = "dotnet",
        Arguments = [| "fsi"; cheminServeur |]
    )

let transport = StdioClientTransport(optionsTransport)

let client = McpClient.CreateAsync(transport).GetAwaiter().GetResult()

for outil in client.ListToolsAsync().AsTask().GetAwaiter().GetResult() do
    printfn "Outil : %s — %s" outil.Name outil.Description
    printfn "Schéma : %s" (outil.JsonSchema.GetRawText())

let appeler (argument: obj) =
    let arguments = Dictionary<string, obj>(dict [ ("orderId", argument) ])
    let appel = client.CallToolAsync("getOrder", arguments)
    let resultat = appel.AsTask().GetAwaiter().GetResult()

    let texte =
        resultat.Content
        |> Seq.choose (fun bloc ->
            match bloc with
            | :? TextContentBlock as t -> Some t.Text
            | _ -> None)
        |> String.concat ""

    let enErreur = (resultat.IsError = Nullable true)
    printfn "getOrder(%A) -> IsError=%b : %s" argument enErreur texte

appeler (box 4512)
appeler (box 9999)
appeler (box "abc")

client.DisposeAsync().AsTask().GetAwaiter().GetResult()

Deux éléments du client sont nouveaux : box convertit une valeur en obj, comme le boxing implicite de C# ; __SOURCE_DIRECTORY__ est le dossier du script en cours, ce qui permet de trouver le serveur quel que soit le dossier d'où vous lancez le client.

Avant de lancer, prédisez la dernière ligne : que va renvoyer le serveur pour getOrder("abc"), alors que getOrder attend un entier ?

dotnet fsi client-mcp.fsx

Le premier lancement télécharge les packages du serveur et du client ; pour que le client n'attende pas ce téléchargement, lancez d'abord une fois le serveur seul (dotnet fsi serveur-mcp.fsx, puis Ctrl+C). Ensuite, la connexion prend environ une seconde (observé sur la machine de test).

Sortie attendue

Outil : getOrder — Retourne le statut d'une commande à partir de son numéro.
Schéma : {"type":"object","properties":{"orderId":{"type":"integer"}},"required":["orderId"]}
getOrder(4512) -> IsError=false : Commande 4512 : expédiée, suivi TRK-88
getOrder(9999) -> IsError=false : Commande 9999 introuvable
getOrder("abc") -> IsError=true : An error occurred invoking 'getOrder'.

Toute cette sortie est exacte : aucun modèle n'intervient. Trois constats :

  • les résultats sont exactement les textes de votre module 4 : c'est la même fonction F# qui s'exécute, dans le processus du serveur ;
  • le schéma publié est identique à celui que vous avez vu au module 5 : le SDK MCP le déduit de la même signature int -> string ;
  • pour "abc", le serveur ne plante pas : le protocole prévoit un résultat marqué en erreur (IsError=true) que le client peut traiter. C'est la version MCP de votre BadArgumentType.

Si votre sortie diffère

  • Le client reste bloqué ou signale une erreur de connexion : vérifiez que serveur-mcp.fsx est dans le même dossier que le client, et que le serveur seul démarre sans erreur de compilation (dotnet fsi serveur-mcp.fsx, puis Ctrl+C).
  • Vous avez ajouté un printfn dans le serveur ou dans commandes.fsx, et tout fonctionne quand même : lors d'un essai, le client du SDK C# a ignoré une ligne de texte parasite sur stdout. Ne comptez pas sur cette tolérance : la spécification l'interdit, et un autre client MCP peut échouer. Retirez la ligne.

Exercice — l'expliquer en deux phrases

Un collègue vous demande : « Ton agent appelait déjà getOrder. Qu'est-ce que le serveur MCP change ? » Répondez-lui par écrit, en deux phrases au plus, avant de lire la suite.

Une réponse possible

« Dans l'agent, l'outil n'est déclaré que pour cet agent, dans son propre code. Derrière un serveur MCP, il est décrit et appelé selon un protocole standard (tools/list, tools/call), donc n'importe quel client MCP — un assistant, un éditeur, un autre agent — peut le découvrir et l'utiliser sans rien recopier. »

Votre réponse est juste si elle contient les deux idées : un protocole standard, et la réutilisation par d'autres clients sans modifier l'outil.

Pour aller plus loin (facultatif)

  • Un autre client. Si Node.js 22.19 ou plus récent est installé, l'outil officiel MCP Inspector vérifie aussi votre serveur, en ligne de commande : npx @modelcontextprotocol/inspector@2.12.0 --cli dotnet fsi serveur-mcp.fsx --method tools/list. Cette version a été testée lors de la préparation du cours, sur un serveur de travail équivalent à celui de ce module.
  • Brancher MCP sur votre agent. Les outils renvoyés par ListToolsAsync sont des AIFunction de Microsoft.Extensions.AI : on peut les passer à ChatOptions.Tools comme votre outilCommande. Lors de la préparation, qwen3.5:4b a ainsi demandé getOrder à travers le serveur MCP et obtenu Commande 7781 : en préparation. Rappelez-vous alors le module 8 : le texte renvoyé par un serveur MCP que vous n'avez pas écrit est une donnée non fiable, et une surface d'injection de prompt.

Ce que vous venez de faire

Vous avez exposé une fonction F# existante, sans la modifier, par un serveur MCP construit avec le SDK C# officiel, dans un simple script. Vous l'avez vérifiée depuis un client MCP : l'outil apparaît dans la liste avec son nom, sa description et son schéma, et ses appels renvoient les résultats attendus, y compris une erreur propre pour un argument invalide. Vous savez ce que MCP apporte par rapport à la déclaration d'outils dans l'agent : une description et un appel standardisés, donc un outil écrit une fois et utilisable par tout client compatible.

Au dernier module, vous prenez du recul sur les trois types rencontrés en chemin — union discriminée, Option, Result — pour savoir les choisir hors de cet exemple, et vous vérifiez que changer de modèle ne touche qu'un seul fichier.

Vérifiez votre compréhension

Trois applications IA de votre équipe (un assistant dans l'éditeur, un chatbot interne, un agent de nuit) doivent consulter le stock. Qu'apporte un serveur MCP qui expose `getStock`, par rapport à la déclaration de l'outil dans chaque application ?

Pour déboguer, vous ajoutez `printfn "getOrder appelée"` dans `getOrder`, chargée par votre serveur MCP en transport stdio. Pourquoi est-ce une erreur, même si le client de vérification semble encore fonctionner ?

Depuis un client MCP, vous appelez `getOrder` sur votre serveur avec `orderId` = 3020, puis avec `orderId` = "trois". Qu'obtenez-vous ?

Envie d'être prévenu des prochains modules ?

L'Académie reste gratuite et en accès libre, sans inscription. Si vous voulez juste être averti par email à la sortie d'un nouveau module, c'est ici — aucune obligation, désinscription en un clic.