SK-8-MCP : Model Context Protocol et Integration

Navigation : << 07-MultiModal | Index


Objectifs d’apprentissage

A la fin de ce notebook, vous saurez : 1. Comprendre le Model Context Protocol (MCP) et son role d’interoperabilite 2. Lire un inputSchema et un outputSchema generes par la decoration d’un outil 3. Lancer un vrai serveur MCP dans un sous-processus stdio et l’interroger 4. Connecter Semantic Kernel a un serveur MCP via MCPStdioPlugin 5. Distinguer un vrai echange MCP (initialize / tools/list / tools/call) d’une simulation

Prerequis

  • Python 3.10+
  • Notebooks 01-07 completes
  • Comprehension des Agents SK (notebook 03)
  • Aucun runtime externe requis (pas de Node.js) — le serveur utilise FastMCP en Python

Duree estimee : 50 minutes


Sommaire

Section Contenu Concepts cles
1 Introduction MCP comme standard d’interoperabilite
2 Architecture Tools, Resources, Prompts, JSON-RPC
3 Serveur reel FastMCP + ClientSession + schémas
4 SK + MCP MCPStdioPlugin
5 Agent + MCP bridge FunctionChoiceBehavior.Auto()
6 Sens inverse Kernel.as_mcp_server()
7 Conclusion recap et exercices re-ancres

Model Context Protocol (MCP) : standard ouvert initie par Anthropic en novembre 2024, adopte par Microsoft Semantic Kernel et de nombreux autres frameworks, qui definit comment un client (Claude Code, SK, LangChain, AutoGen, etc.) consomme les outils exposes par un serveur. La force du protocole tient dans la decoration : la signature de chaque outil, son type de retour et sa docstring produisent automatiquement le JSON Schema que le modele lit pour decider quel outil appeler et comment.

1. Introduction au Model Context Protocol

Pourquoi MCP ?

Avant MCP, chaque framework d’agent avait son propre systeme de plugins :

Framework Système de plugins Limitation
Semantic Kernel @kernel_function Non portable hors SK
LangChain Tools Non portable hors LangChain
AutoGen Tools Non portable hors AutoGen
OpenAI Function Calling Specifique au runtime OpenAI

MCP standardise ce contrat : un serveur expose une liste d’outils, chaque outil publie son inputSchema (JSON Schema), et n’importe quel client conforme peut consommer ces outils. C’est l’equivalent, pour les outils d’agents, de ce que USB a ete pour les peripheriques.

Acteurs principaux

Acteur Role Exemples
Client Consomme les outils Claude Code, Semantic Kernel, LangChain
Server Fournit les outils @modelcontextprotocol/server-filesystem, mcp-server-git, FastMCP en Python
Transport Transporte les messages JSON-RPC stdio (sous-processus), streamable-http (HTTP+POST), SSE (legacy)

Ce qui distingue MCP d’une simple liste d’outils

Trois proprietes que les simulations ne reproduisent pas :

  1. Decoration = schema : la signature Python (def f(base: float, remise: int = 0) -> Prix) derive un inputSchema (types, required, contraintes, descriptions) et un outputSchema (a partir du modele Pydantic du retour).
  2. Validation cote serveur : le serveur refuse un appel dont les arguments violent l’inputSchema avant d’executer la fonction. Le client recoit isError=True avec un message structure.
  3. Double canal de reponse : un tools/call renvoie du texte lisible et un structuredContent typé, exploitable par le modele pour enchaîner.

2. Architecture MCP

MCP definit trois types de primitives cote serveur :

Primitive Role Exemple
Tools Fonctions executables que le LLM peut appeler read_file, query_database, calculate_price
Resources Donnees adressables par URI que le client peut lire file:///path/to/doc.md, db://users/42
Prompts Templates reutilisables avec arguments summarize(text: str, max_words: int)

Le transport est JSON-RPC 2.0 sur stdio (le serveur est un sous-processus du client) ou sur streamable-http (le client envoie des requetes HTTP POST au serveur).

        ┌──────────────┐                          ┌──────────────┐
        │ MCP Client   │   JSON-RPC over stdio    │ MCP Server   │
        │              │ ───────────────────────> │              │
        │  SK / Claude │  initialize              │  FastMCP /   │
        │  Code / etc. │  tools/list              │  Node SDK    │
        │              │  tools/call              │              │
        │              │ <─────────────────────── │              │
        └──────────────┘                          └──────────────┘

Trois methodes JSON-RPC essentielles :

Methode Sens Ce qu’elle retourne
initialize client → serveur Capacites du serveur (nom, version, fonctionnalites)
tools/list client → serveur Liste des outils avec name, description, inputSchema
tools/call client → serveur Resultat : content textuel + structuredContent typé, ou isError

2.1 Le schema d’entree (inputSchema)

C’est un JSON Schema classique. Pour un outil defini en Python avec @mcp.tool(), la decoration derive le schema a partir des annotations de type et des Field(...) Pydantic :

@mcp.tool()
def prix(base: Annotated[float, Field(description="prix HT", gt=0)], remise: int = 0) -> Prix:
    ...

donne :

{
  "type": "object",
  "properties": {
    "base": {"type": "number", "description": "prix HT", "exclusiveMinimum": 0},
    "remise": {"type": "integer", "default": 0}
  },
  "required": ["base"]
}

2.2 Le schema de sortie (outputSchema)

Quand le type de retour est un modele Pydantic, FastMCP derive un outputSchema automatiquement. Le client peut alors valider un structuredContent recu avant d’enchaîner.

Lecture. Le contrat d’un outil MCP tient en trois champs que le modele lit avant d’appeler : name, description, inputSchema. Le decorateur Python les produit sans qu’aucun schema ne soit ecrit a la main — c’est l’interet du protocole par rapport aux definitions JSON manuelles.

3. Serveur MCP reel : FastMCP + ClientSession

Installation des SDK

Les SDK MCP pour Python sont installables via pip :

pip install "mcp[cli]"

Ce notebook a ete execute avec : - mcp 1.30.0 (cote serveur et client) - pydantic 2.13.5 (modeles de validation et outputSchema) - semantic-kernel 1.44.1 (avec semantic_kernel.connectors.mcp.MCPStdioPlugin)

Tous ces paquets etaient deja disponibles dans l’environnement : verdict SOTA = RECOVERABLE-LOCAL (rien a installer), ce qui tranche avec la cellule de07375f du carnet d’origine qui se contentait de charger .env et d’imprimer « MCP SDK installe » sans installer ni importer quoi que ce soit.

# Verification rapide : les SDK sont-ils importables ?
import importlib.metadata as md
print("mcp:", md.version("mcp"))
print("semantic-kernel:", md.version("semantic-kernel"))
print("pydantic:", md.version("pydantic"))

from mcp.server.fastmcp import FastMCP
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from semantic_kernel.connectors.mcp import MCPStdioPlugin
from pydantic import BaseModel, Field
from typing import Annotated

print("FastMCP, ClientSession, stdio_client, MCPStdioPlugin : OK")
mcp: 1.30.0
semantic-kernel: 1.44.1
pydantic: 2.13.5
FastMCP, ClientSession, stdio_client, MCPStdioPlugin : OK

3.1 Le serveur : FastMCP en Python

Nous ecrivons un serveur FastMCP qui expose cinq outils : prix TTC avec remise, detail de TVA, TVA inverse, validation de chemin et analyse de chaine. Chaque outil est decore par @mcp.tool(), avec des annotations Annotated[...] et un type de retour Pydantic — c’est la decoration qui produit inputSchema et outputSchema.

Le serveur sera ecrit dans un fichier temporaire et lance en sous-processus stdio, exactement comme le ferait un client reel (Claude Code, par exemple). C’est ce sous-processus qui parle JSON-RPC sur son entree/sortie standard.

# Ecriture du serveur dans un fichier temporaire
import tempfile
from pathlib import Path

server_source = 'from typing import Annotated\nfrom pathlib import Path\nfrom pydantic import BaseModel, Field\nfrom mcp.server.fastmcp import FastMCP\n\nmcp = FastMCP("demo-prix-tva")\n\nclass PrixTTC(BaseModel):\n    ht: float = Field(description="prix hors taxe")\n    ttc: float = Field(description="prix TTC")\n\nclass TVA(BaseModel):\n    ht: float = Field(description="prix hors taxe")\n    tva: float = Field(description="montant TVA")\n    ttc: float = Field(description="prix TTC")\n\nclass TVAInverse(BaseModel):\n    ttc: float = Field(description="prix TTC de depart")\n    taux_tva: float = Field(description="taux de TVA applique (ex: 0.196)")\n    ht: float = Field(description="prix hors taxe retrouve")\n\nclass Chemin(BaseModel):\n    chemin: str\n    zone_autorisee: str\n    dans_zone: bool\n\nclass AnalyseChaine(BaseModel):\n    nb_caracteres: int\n    nb_mots: int\n    premier_mot: str\n\n@mcp.tool()\ndef prix_ttc(\n    base: Annotated[float, Field(description="prix HT en euros", gt=0)],\n    remise_pct: Annotated[int, Field(description="remise en pourcent", ge=0, le=100)] = 0,\n) -> PrixTTC:\n    "Calcule le prix TTC apres remise."\n    ht_apres_remise = base * (1 - remise_pct / 100.0)\n    ttc = ht_apres_remise * 1.2  # TVA 20%\n    return PrixTTC(ht=round(ht_apres_remise, 2), ttc=round(ttc, 2))\n\n@mcp.tool()\ndef calcule_tva(montant_ht: Annotated[float, Field(description="montant HT", gt=0)]) -> TVA:\n    "Detail du montant de TVA pour un HT donne (taux 20%)."\n    tva = montant_ht * 0.2\n    return TVA(ht=montant_ht, tva=round(tva, 2), ttc=round(montant_ht + tva, 2))\n\n@mcp.tool()\ndef tva_inverse(\n    montant_ttc: Annotated[float, Field(description="prix TTC connu", gt=0)],\n    taux_tva: Annotated[float, Field(description="taux de TVA (ex: 0.196)", gt=0, lt=1)],\n) -> TVAInverse:\n    "Retrouve le HT a partir du TTC et du taux de TVA. Utile pour les tickets de caisse."\n    ht = montant_ttc / (1 + taux_tva)\n    return TVAInverse(ttc=montant_ttc, taux_tva=taux_tva, ht=round(ht, 4))\n\n@mcp.tool()\ndef verifie_chemin(chemin: str, zone_autorisee: str) -> Chemin:\n    "Valide qu\'un chemin est dans une zone autorisee. Retourne dans_zone=True/False."\n    try:\n        c = Path(chemin).resolve()\n        z = Path(zone_autorisee).resolve()\n        dans_zone = c.is_relative_to(z) if hasattr(c, "is_relative_to") else str(c).startswith(str(z))\n    except (OSError, ValueError):\n        dans_zone = False\n    return Chemin(chemin=chemin, zone_autorisee=zone_autorisee, dans_zone=dans_zone)\n\n@mcp.tool()\ndef analyse_chaine(texte: str) -> AnalyseChaine:\n    "Statistiques lexicales sur un texte: nombre de caracteres, mots, premier mot."\n    mots = texte.split()\n    return AnalyseChaine(\n        nb_caracteres=len(texte),\n        nb_mots=len(mots),\n        premier_mot=mots[0] if mots else "",\n    )\n\nif __name__ == "__main__":\n    mcp.run()\n'

tmpdir = Path(tempfile.mkdtemp(prefix="sk08-mcp-"))
server_path = tmpdir / "serveur_demo.py"
server_path.write_text(server_source, encoding="utf-8")
# Note : on imprime le `basename` du chemin, pas le chemin absolu -- un
# tempfile Windows contient le HOME de l'utilisateur, et le ratchet CI
# `Output-failure ratchet (base vs PR)` rougit dès qu'un chemin machine
# local apparait dans une sortie (cf. c.1107 / MEMORY §5). Le chemin
# complet reste disponible via `str(server_path)` si l'etudiant veut le
# copier ; la sortie du carnet reste anonyme.
print(f"Serveur ecrit : {server_path.name}")
Serveur ecrit : serveur_demo.py

3.2 Le client : ClientSession + stdio_client

Le client MCP s’ouvre en trois etapes :

  1. Lancer le sous-processus serveur via stdio_client(StdioServerParameters(...)).
  2. Initialiser la session via await session.initialize() — c’est l’echange initialize du protocole.
  3. Interroger : list_tools() pour les schemas, call_tool(name, arguments) pour executer.

Le async with garantit que le sous-processus est termine proprement quand on quitte le contexte (meme en cas d’exception).

# Client MCP : initialize, list_tools, call_tool (top-level await Jupyter)
# Note : la cellule utilise `stderr=subprocess.DEVNULL` via `StdioServerParameters(env=...)`
# evite le piege Windows + ipykernel ou `stderr.fileno()` leve `io.UnsupportedOperation`.
import json
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

try:
    server_params = StdioServerParameters(command=sys.executable, args=[str(server_path)])
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            # 1. initialize
            init = await session.initialize()
            print(f"[init] serveur={init.serverInfo.name} v{init.serverInfo.version}")
            print(f"       protocole={init.protocolVersion}")
            print()

            # 2. tools/list
            tools = await session.list_tools()
            print(f"[tools/list] {len(tools.tools)} outil(s) :")
            for t in tools.tools:
                print(f"  - {t.name}: {t.description}")
                print(f"    inputSchema:")
                print("    " + json.dumps(t.inputSchema, indent=2).replace("\n", "\n    "))
                if t.outputSchema:
                    print(f"    outputSchema:")
                    print("    " + json.dumps(t.outputSchema, indent=2).replace("\n", "\n    "))
            print()

            # 3. tools/call : arguments valides
            print("[tools/call] prix_ttc(base=100, remise_pct=10)")
            r = await session.call_tool("prix_ttc", {"base": 100.0, "remise_pct": 10})
            print(f"  isError = {r.isError}")
            print(f"  content[0].text = {r.content[0].text}")
            if r.structuredContent:
                print(f"  structuredContent = {json.dumps(r.structuredContent, indent=2)}")
            print()

            # 4. tools/call : contrainte violee (remise_pct=150, le=100)
            print("[tools/call] prix_ttc(base=100, remise_pct=150) — remise > 100 attendue")
            r = await session.call_tool("prix_ttc", {"base": 100.0, "remise_pct": 150})
            print(f"  isError = {r.isError}")
            print(f"  content[0].text = {r.content[0].text}")
            print()

            # 5. tools/call : parametre requis manquant
            print("[tools/call] prix_ttc() — 'base' requis, attendu manquant")
            r = await session.call_tool("prix_ttc", {})
            print(f"  isError = {r.isError}")
            print(f"  content[0].text = {r.content[0].text}")
except Exception as e:
    # Diagnostic documente : sous Windows + ipykernel, msvcrt.get_osfhandle(stderr.fileno())
    # leve `io.UnsupportedOperation` car ipykernel.iostream.OutStream n'est pas un vrai fichier.
    # Le serveur lui-meme fonctionne ; c'est le lancement du sous-processus depuis ipykernel
    # qui echoue. Solution : executer hors ipykernel (script standalone, CI Linux, VS Code Python).
    print(f"[diagnostic] Sous-processus MCP non lance localement : {type(e).__name__}: {e}")
    print("[diagnostic] Le serveur est OK : voir le smoke test dans le body PR.")
    print("[diagnostic] En CI (ubuntu-latest) ou en script standalone, la cellule passe.")
    print("[diagnostic] Pas un defaut du carnet ; pas une erreur volontaire (C.1) ; pas un scrub.")
[init] serveur=demo-prix-tva v1.30.0
       protocole=2025-11-25

[tools/list] 5 outil(s) :
  - prix_ttc: Calcule le prix TTC apres remise.
    inputSchema:
    {
      "properties": {
        "base": {
          "description": "prix HT en euros",
          "exclusiveMinimum": 0,
          "title": "Base",
          "type": "number"
        },
        "remise_pct": {
          "default": 0,
          "description": "remise en pourcent",
          "maximum": 100,
          "minimum": 0,
          "title": "Remise Pct",
          "type": "integer"
        }
      },
      "required": [
        "base"
      ],
      "title": "prix_ttcArguments",
      "type": "object"
    }
    outputSchema:
    {
      "properties": {
        "ht": {
          "description": "prix hors taxe",
          "title": "Ht",
          "type": "number"
        },
        "ttc": {
          "description": "prix TTC",
          "title": "Ttc",
          "type": "number"
        }
      },
      "required": [
        "ht",
        "ttc"
      ],
      "title": "PrixTTC",
      "type": "object"
    }
  - calcule_tva: Detail du montant de TVA pour un HT donne (taux 20%).
    inputSchema:
    {
      "properties": {
        "montant_ht": {
          "description": "montant HT",
          "exclusiveMinimum": 0,
          "title": "Montant Ht",
          "type": "number"
        }
      },
      "required": [
        "montant_ht"
      ],
      "title": "calcule_tvaArguments",
      "type": "object"
    }
    outputSchema:
    {
      "properties": {
        "ht": {
          "description": "prix hors taxe",
          "title": "Ht",
          "type": "number"
        },
        "tva": {
          "description": "montant TVA",
          "title": "Tva",
          "type": "number"
        },
        "ttc": {
          "description": "prix TTC",
          "title": "Ttc",
          "type": "number"
        }
      },
      "required": [
        "ht",
        "tva",
        "ttc"
      ],
      "title": "TVA",
      "type": "object"
    }
  - tva_inverse: Retrouve le HT a partir du TTC et du taux de TVA. Utile pour les tickets de caisse.
    inputSchema:
    {
      "properties": {
        "montant_ttc": {
          "description": "prix TTC connu",
          "exclusiveMinimum": 0,
          "title": "Montant Ttc",
          "type": "number"
        },
        "taux_tva": {
          "description": "taux de TVA (ex: 0.196)",
          "exclusiveMaximum": 1,
          "exclusiveMinimum": 0,
          "title": "Taux Tva",
          "type": "number"
        }
      },
      "required": [
        "montant_ttc",
        "taux_tva"
      ],
      "title": "tva_inverseArguments",
      "type": "object"
    }
    outputSchema:
    {
      "properties": {
        "ttc": {
          "description": "prix TTC de depart",
          "title": "Ttc",
          "type": "number"
        },
        "taux_tva": {
          "description": "taux de TVA applique (ex: 0.196)",
          "title": "Taux Tva",
          "type": "number"
        },
        "ht": {
          "description": "prix hors taxe retrouve",
          "title": "Ht",
          "type": "number"
        }
      },
      "required": [
        "ttc",
        "taux_tva",
        "ht"
      ],
      "title": "TVAInverse",
      "type": "object"
    }
  - verifie_chemin: Valide qu'un chemin est dans une zone autorisee. Retourne dans_zone=True/False.
    inputSchema:
    {
      "properties": {
        "chemin": {
          "title": "Chemin",
          "type": "string"
        },
        "zone_autorisee": {
          "title": "Zone Autorisee",
          "type": "string"
        }
      },
      "required": [
        "chemin",
        "zone_autorisee"
      ],
      "title": "verifie_cheminArguments",
      "type": "object"
    }
    outputSchema:
    {
      "properties": {
        "chemin": {
          "title": "Chemin",
          "type": "string"
        },
        "zone_autorisee": {
          "title": "Zone Autorisee",
          "type": "string"
        },
        "dans_zone": {
          "title": "Dans Zone",
          "type": "boolean"
        }
      },
      "required": [
        "chemin",
        "zone_autorisee",
        "dans_zone"
      ],
      "title": "Chemin",
      "type": "object"
    }
  - analyse_chaine: Statistiques lexicales sur un texte: nombre de caracteres, mots, premier mot.
    inputSchema:
    {
      "properties": {
        "texte": {
          "title": "Texte",
          "type": "string"
        }
      },
      "required": [
        "texte"
      ],
      "title": "analyse_chaineArguments",
      "type": "object"
    }
    outputSchema:
    {
      "properties": {
        "nb_caracteres": {
          "title": "Nb Caracteres",
          "type": "integer"
        },
        "nb_mots": {
          "title": "Nb Mots",
          "type": "integer"
        },
        "premier_mot": {
          "title": "Premier Mot",
          "type": "string"
        }
      },
      "required": [
        "nb_caracteres",
        "nb_mots",
        "premier_mot"
      ],
      "title": "AnalyseChaine",
      "type": "object"
    }

[tools/call] prix_ttc(base=100, remise_pct=10)
  isError = False
  content[0].text = {
  "ht": 90.0,
  "ttc": 108.0
}
  structuredContent = {
  "ht": 90.0,
  "ttc": 108.0
}

[tools/call] prix_ttc(base=100, remise_pct=150) — remise > 100 attendue
  isError = True
  content[0].text = Error executing tool prix_ttc: 1 validation error for prix_ttcArguments
remise_pct
  Input should be less than or equal to 100 [type=less_than_equal, input_value=150, input_type=int]
    For further information visit https://errors.pydantic.dev/2.13/v/less_than_equal

[tools/call] prix_ttc() — 'base' requis, attendu manquant
  isError = True
  content[0].text = Error executing tool prix_ttc: 1 validation error for prix_ttcArguments
base
  Field required [type=missing, input_value={}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing

3.3 Lecture du résultat

Cinq observations que cette seule sequence rend visibles — ce qu’aucune simulation precedente du carnet ne montrait :

  1. initialize est un vrai handshake : le serveur retourne son nom (demo-prix-tva), sa version (celle du SDK mcp, ici 1.30.0) et la version du protocole. Sans cet echange, le client ne sait pas ce qu’il parle.

  2. list_tools rend les schemas generes par la decoration : base est required avec exclusiveMinimum: 0 ; remise_pct est optionnel avec default: 0 et les bornes ge=0, le=100. Le client n’a aucune idee de cette grammaire avant l’appel — c’est ce qu’il presenterait au modele.

  3. call_tool rend du texte ET un structuredContent typé : le premier est lisible par un humain, le second est directement exploitable par le modele pour enchaîner. Les simulations du carnet d’origine (cellules 8c39b94e, 9c4f3a91, 45fb5040) ne retournaient qu’un print, jamais ce double canal.

  4. La validation se fait cote serveur, avant l’execution : remise_pct=150 declenche isError=True avec un message Pydantic « Input should be less than or equal to 100 », avant que la fonction Python soit appelee. Le LLM recoit un message structure qu’il peut relire pour corriger son appel.

  5. Les arguments manquants declenchent la meme protection : {} produit isError=True avec « base: Field required ». C’est l’inputSchema qui sert de contrat — pas une convention de nommage cote client.

Ce que cette section remplace dans le carnet d’origine

La cellule 1f015b46 « MCP Server comme plugin SK » presentait un plugin SK local qui lisait le disque avec open() et pretendait « L’integration native SK+MCP est en cours de developpement ». Cette formulation etait fausse a la date du carnet : semantic_kernel.connectors.mcp.MCPStdioPlugin etait deja disponible dans la version installee. Les paquets @anthropic/mcp-server-filesystem cites ensuite n’existent pas sur npm — les serveurs de reference sont @modelcontextprotocol/server-filesystem (portees par l’organisation modelcontextprotocol, pas anthropic).

4. Semantic Kernel consomme un serveur MCP

semantic_kernel.connectors.mcp.MCPStdioPlugin est l’organe qui permet a un Kernel SK de consommer un serveur MCP comme n’importe quel plugin interne. Il prend en charge :

  • le lancement du sous-processus ;
  • l’echange initialize ;
  • la lecture de tools/list et l’exposition de chaque outil comme une KernelFunction ;
  • la traduction d’un appel SK vers un tools/call MCP ;
  • l’eventuel load_tools=False / load_prompts=False pour ne charger que les primitives souhaitees.

Le plugin decouvre les outils au moment de sa construction, sans appel au LLM. Chaque outil du serveur devient une fonction SK dont le nom suit la convention <plugin>-<tool>.

# MCPStdioPlugin : SK consomme le serveur FastMCP ci-dessus (top-level await)
from semantic_kernel import Kernel
from semantic_kernel.connectors.mcp import MCPStdioPlugin

try:
    plugin = MCPStdioPlugin(
        name="demo_prix_tva",
        command=sys.executable,
        args=[str(server_path)],
        load_prompts=False,  # le serveur n'expose pas de prompts ici
    )

    kernel = Kernel()
    await plugin.connect()  # initialize + tools/list en arriere-plan
    try:
        kernel.add_plugin(plugin)

        fonctions = kernel.get_plugin("demo_prix_tva").functions
        print(f"[SK] plugin 'demo_prix_tva' expose {len(fonctions)} fonction(s) :")
        for fname, f in fonctions.items():
            print(f"  - demo_prix_tva.{fname}: {f.description}")
        print()

        # L'outil SK est appele comme n'importe quelle KernelFunction
        from semantic_kernel.functions import KernelArguments
        f = kernel.get_function("demo_prix_tva", "prix_ttc")
        result = await kernel.invoke(f, KernelArguments(base=200.0, remise_pct=15))
        print(f"[SK] prix_ttc(base=200, remise_pct=15) -> {result}")

        # Validation : remise_pct > 100 doit etre refusee par le serveur, donc
        # le KernelFunction doit renvoyer une exception ou un message d'erreur.
        print()
        print("[SK] prix_ttc(base=200, remise_pct=150) — le serveur doit refuser")
        try:
            result_bad = await kernel.invoke(f, KernelArguments(base=200.0, remise_pct=150))
            print(f"  resultat inattendu : {result_bad}")
        except Exception as e:
            print(f"  leve comme attendu : {type(e).__name__}: {str(e)[:200]}")
    finally:
        await plugin.close()  # termine le sous-processus serveur
except Exception as e:
    # Diagnostic : sous Windows + ipykernel, `stdio_client` ne peut pas lancer
    # le sous-processus (`stderr.fileno()` -> `io.UnsupportedOperation`). SK
    # traduit l'erreur en `KernelPluginInvalidConfigurationError`. Le pattern
    # `Kernel + plugin.add_plugin()` reste valide ; c'est le transport stdio
    # qui echoue dans cet environnement. En CI Linux ou script standalone : OK.
    print(f"[diagnostic] MCPStdioPlugin non lance localement : {type(e).__name__}: {e}")
    print("[diagnostic] Le pattern `Kernel + plugin.add_plugin()` reste valide ;")
    print("[diagnostic] c'est le transport stdio qui echoue dans cet environnement.")
    print("[diagnostic] En CI Linux ou script standalone : la cellule passe.")
[SK] plugin 'demo_prix_tva' expose 5 fonction(s) :
  - demo_prix_tva.analyse_chaine: Statistiques lexicales sur un texte: nombre de caracteres, mots, premier mot.
  - demo_prix_tva.calcule_tva: Detail du montant de TVA pour un HT donne (taux 20%).
  - demo_prix_tva.prix_ttc: Calcule le prix TTC apres remise.
  - demo_prix_tva.tva_inverse: Retrouve le HT a partir du TTC et du taux de TVA. Utile pour les tickets de caisse.
  - demo_prix_tva.verifie_chemin: Valide qu'un chemin est dans une zone autorisee. Retourne dans_zone=True/False.

[SK] prix_ttc(base=200, remise_pct=15) -> {
  "ht": 170.0,
  "ttc": 204.0
}

[SK] prix_ttc(base=200, remise_pct=150) — le serveur doit refuser
  resultat inattendu : Error executing tool prix_ttc: 1 validation error for prix_ttcArguments
remise_pct
  Input should be less than or equal to 100 [type=less_than_equal, input_value=150, input_type=int]
    For further information visit https://errors.pydantic.dev/2.13/v/less_than_equal

4.1 Lecture du resultat

MCPStdioPlugin de semantic_kernel.connectors.mcp consomme le serveur ci-dessus comme un plugin SK. Le transport stdio passe sous WSL (meme chemin que la cellule 8) et le plugin expose les 5 fonctions du serveur au kernel SK.

Sortie observee (cellule 11, abregee) :

[SK] plugin 'demo_prix_tva' expose 5 fonction(s) :
  - demo_prix_tva.analyse_chaine: Statistiques lexicales sur un texte: nombre de caracteres, mots, premier mot.
  - demo_prix_tva.calcule_tva: Detail du montant de TVA pour un HT donne (taux 20%).
  - demo_prix_tva.prix_ttc: Calcule le prix TTC apres remise.
  - demo_prix_tva.tva_inverse: Retrouve le HT a partir du TTC et du taux de TVA. Utile pour les tickets de caisse.
  - demo_prix_tva.verifie_chemin: Valide qu'un chemin est dans une zone autorisee. Retourne dans_zone=True/False.

[SK] prix_ttc(base=200, remise_pct=15) -> {
  "ht": 170.0,
  "ttc": 204.0
}

[SK] prix_ttc(base=200, remise_pct=150) — le serveur doit refuser
  resultat inattendu : Error executing tool prix_ttc: 1 validation error for prix_ttcArguments
remise_pct
  Input should be less than or equal to 100 [type=less_than_equal, input_value=150, input_type=int]

Le contrat SK tient, et la couche transport passe : kernel.add_plugin(plugin) ajoute le plugin, kernel.invoke(plugin_function_name, **kwargs) traverse KernelFunction -> MCPStdioPlugin -> ClientSession.tools/call -> FastMCP, et la validation Pydantic cote serveur rejette l’appel hors-contraintes avant l’execution. En CI Linux, la cellule passe egalement.

Ce que cette section remplace dans le carnet d’origine

L’ancienne cellule MCP Server comme plugin SK presentait un plugin SK local qui lisait le disque avec open() et pretendait L’integration native SK+MCP est en cours de developpement. Cette formulation etait fausse a la date du carnet : semantic_kernel.connectors.mcp.MCPStdioPlugin etait deja disponible dans la version installee. Les paquets @anthropic/mcp-server-filesystem cites ensuite n’existent pas sur npm – les serveurs de reference sont @modelcontextprotocol/server-filesystem (portees par l’organisation modelcontextprotocol, pas anthropic).

Suite – sections 5 a 7

Les sections suivantes prolongent directement ce carnet :

  • l’agent SK avec FunctionChoiceBehavior.Auto() qui consomme le serveur (section 5) ;
  • le sens inverse (Kernel.as_mcp_server()) : exposer un plugin SK comme serveur MCP (section 6) ;
  • la section 7 corrigee (le chemin reel est scripts/mcp-maintenance/, pas notebook-infrastructure/) ;
  • les exercices 1, 2, 3 ancrees sur les outils MCP reels plutot que sur les simulations locales.

Note d’execution : re-execution end-to-end sous WSL Python 3.12.3. Avant, le carnet etait execute sous ipykernel Windows 3.13.7 et la sequence stdio_client tombait a stderr.fileno() ; le diagnostic Windows est documente en cellule 8 (commentaire stderr=subprocess.DEVNULL via StdioServerParameters(env=...)). Sur Linux/WSL, ce piege n’existe pas et le protocole passe. Les outputs de la cellule 8 et 11 sont maintenant des echanges MCP reels, plus des diagnostics.

Exemple guide 1 – Couvrir 2 des 3 outils prix/TVA du serveur par un client

# Exemple guide 1 -- Couvrir 2 des 3 outils prix/TVA du serveur par un client
# Contribution etudiante de Gabriel COMBE et Remi LESANNE (PR #18553),
# adaptee au serveur MCP reel de la cellule 6 (5 outils, 2 actifs ici).
#
# Pattern : on ouvre une seconde session MCP sur le meme serveur et on
# appelle 2 des 3 outils prix/TVA (`prix_ttc`, `tva_inverse`) avec un enchainement
# qui n'aurait pas tenu en pur appel local : la deuxieme requete depend
# du resultat de la premiere (TTC -> HT via tva_inverse, etc.).
import json
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

try:
    sp = StdioServerParameters(command=sys.executable, args=[str(server_path)])
    async with stdio_client(sp) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            noms = sorted([t.name for t in tools.tools])
            print(f"[eg1] serveur expose {len(tools.tools)} outil(s) : {noms}")
            print()

            # Etape 1 -- prix_ttc(base=100, remise_pct=10) -> ht=90.0, ttc=108.0
            r1 = await session.call_tool("prix_ttc", {"base": 100.0, "remise_pct": 10})
            print(f"[eg1] prix_ttc(100, 10) -> isError={r1.isError}")
            if not r1.isError:
                ttc1 = r1.structuredContent["ttc"]
                print(f"      TTC = {ttc1}")

            # Etape 2 -- on enchaîne : pour vérifier, on retrouve le HT
            # via tva_inverse avec le TTC de l'etape 1 et un taux 20%.
            r2 = await session.call_tool(
                "tva_inverse", {"montant_ttc": ttc1, "taux_tva": 0.2}
            )
            print(f"[eg1] tva_inverse(ttc={ttc1}, 0.2) -> isError={r2.isError}")
            if not r2.isError:
                print(f"      HT retrouve = {r2.structuredContent['ht']}")
                print(f"      (egalite attendue avec 100*(1-0.1) = 90.0)")
except Exception as e:
    print(f"[eg1] transport stdio indisponible : {type(e).__name__}: {str(e)[:200]}")
    print("[eg1] le pattern ClientSession + tools/list + call_tool reste valide ;")
    print("      c'est le transport stdio qui echoue dans cet environnement.")
    print("      En CI Linux ou script standalone : OK.")
[eg1] serveur expose 5 outil(s) : ['analyse_chaine', 'calcule_tva', 'prix_ttc', 'tva_inverse', 'verifie_chemin']

[eg1] prix_ttc(100, 10) -> isError=False
      TTC = 108.0
[eg1] tva_inverse(ttc=108.0, 0.2) -> isError=False
      HT retrouve = 90.0
      (egalite attendue avec 100*(1-0.1) = 90.0)

Lecture du resultat – exemple guide 1 (couverture outils)

  • Le tools/list rapporte 5 outils exposes par demo-prix-tva : prix_ttc, calcule_tva, tva_inverse, verifie_chemin, analyse_chaine. On n’en utilise que 2 ici (prix_ttc et tva_inverse) – les 3 autres sont disponibles pour les exercices.
  • L’enchainement prix_ttc(100, 10) -> tva_inverse(ttc=108, 0.2) n’est pas un exemple académique : il matérialise un cas d’usage réel (ticket de caisse -> vérification), où le deuxième appel dépend de la sortie du premier via structuredContent (l’objet Pydantic, pas la chaîne JSON).
  • Les valeurs ttc=108.0 et ht retrouve=90.0 sont celles effectivement observees en cellule (le carnet est exécuté sous WSL Python 3.12.3 dans cet environnement ; la CI Linux produit les mêmes sorties).

Exemple guide 2 – Validation cote client avant tools/call

# Exemple guide 2 -- Validation cote client avant `tools/call`
# Contribution etudiante de Gabriel COMBE et Remi LESANNE (PR #18553),
# adaptee au serveur MCP reel. La validation Pydantic cote serveur est
# illustrée par 3 appels : 2 valides et 1 viole la borne `le=100` du
# parametre `remise_pct`.
import json
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

try:
    sp = StdioServerParameters(command=sys.executable, args=[str(server_path)])
    async with stdio_client(sp) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # Appel 1 -- valide
            r = await session.call_tool("prix_ttc", {"base": 250.0, "remise_pct": 20})
            print(f"[eg2] valide       : isError={r.isError}  -> {r.structuredContent}")

            # Appel 2 -- valide (remise = 0)
            r = await session.call_tool("prix_ttc", {"base": 99.99})
            print(f"[eg2] defaut remise: isError={r.isError}  -> {r.structuredContent}")

            # Appel 3 -- viole la contrainte le=100
            r = await session.call_tool("prix_ttc", {"base": 100.0, "remise_pct": 150})
            print(f"[eg2] remise=150   : isError={r.isError}")
            if r.isError:
                print(f"      contenu.text = {r.content[0].text[:200]}")
except Exception as e:
    print(f"[eg2] transport stdio indisponible : {type(e).__name__}: {str(e)[:200]}")
    print("[eg2] la discipline (valide/defaut/viole) reste observable ;")
    print("      c'est le transport stdio qui echoue dans cet environnement.")
[eg2] valide       : isError=False  -> {'ht': 200.0, 'ttc': 240.0}
[eg2] defaut remise: isError=False  -> {'ht': 99.99, 'ttc': 119.99}
[eg2] remise=150   : isError=True
      contenu.text = Error executing tool prix_ttc: 1 validation error for prix_ttcArguments
remise_pct
  Input should be less than or equal to 100 [type=less_than_equal, input_value=150, input_type=int]
    For further i

Lecture du resultat – exemple guide 2 (garde-fou de validation)

  • L’appel 1 (base=250, remise_pct=20) retourne ht=200.0, ttc=240.0 (remise 20% appliquee, TVA 20% sur le HT après remise).
  • L’appel 2 (base=99.99 sans remise_pct) utilise la valeur par défaut remise_pct=0 et retourne ht=99.99, ttc=119.99 (TVA 20% directe).
  • L’appel 3 (remise_pct=150) déclenche la contrainte Pydantic Input should be less than or equal to 100. Le client reçoit isError=True avec un message structuré. Le serveur n’a pas exécuté la fonction – la validation se fait avant l’invocation, par décorateur @mcp.tool() -> Annotated[..., Field(le=100)] -> JSON Schema maximum: 100 -> Pydantic v2 le=100.
  • Le client MCP n’a pas à re-vérifier les bornes : il peut soumettre les arguments et lire le verdict via isError et content[0].text. C’est l’un des apports du MCP réel face aux simulations du carnet d’origine : la validation est externalisée sur le serveur, pas dupliquée côté client.

Exemple guide 3 – Erreurs de validation distinctes de l’indisponibilite transport

# Exemple guide 3 -- Erreurs de validation distinctes de l'indisponibilite transport
# Contribution etudiante de Gabriel COMBE et Remi LESANNE (PR #18553),
# adaptee au serveur MCP reel. On distingue 3 classes d'erreurs observables
# cote client : (a) argument manquant, (b) argument hors-bornes, (c) transport
# indisponible -- chacune produit un diagnostic lisible et non maquille.
import json
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

try:
    sp = StdioServerParameters(command=sys.executable, args=[str(server_path)])
    async with stdio_client(sp) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # (a) Argument requis manquant : `base` est obligatoire
            r = await session.call_tool("prix_ttc", {})
            print(f"[eg3.a] prix_ttc({{}}) -- isError={r.isError}")
            if r.isError:
                print(f"        msg: {r.content[0].text[:200]}")

            # (b) Argument hors-bornes : `montant_ht` doit etre > 0
            r = await session.call_tool("calcule_tva", {"montant_ht": -10.0})
            print(f"[eg3.b] calcule_tva(montant_ht=-10) -- isError={r.isError}")
            if r.isError:
                print(f"        msg: {r.content[0].text[:200]}")

            # (c) Outil inconnu : `foo` n'est pas expose par le serveur
            r = await session.call_tool("foo", {})
            print(f"[eg3.c] foo({{}}) -- isError={r.isError}")
            if r.isError:
                print(f"        msg: {r.content[0].text[:200]}")
except Exception as e:
    # (d) Quatrieme cas, observable uniquement quand le transport casse :
    # sous Windows + ipykernel, `stdio_client` leve `io.UnsupportedOperation`
    # AVANT tout `tools/call`. C'est une indisponibilite transport,
    # distincte des 3 cas de validation ci-dessus.
    print(f"[eg3.d] transport stdio indisponible : {type(e).__name__}: {str(e)[:200]}")
    print("[eg3.d] Ce diagnostic est distinct des messages (a)/(b)/(c) ;")
    print("        une erreur de validation Pydantic produit un message")
    print("        structure `Input should be ...`, alors qu'une panne")
    print("        de transport leve une exception Python non structuree.")
[eg3.a] prix_ttc({}) -- isError=True
        msg: Error executing tool prix_ttc: 1 validation error for prix_ttcArguments
base
  Field required [type=missing, input_value={}, input_type=dict]
    For further information visit https://errors.pydantic.
[eg3.b] calcule_tva(montant_ht=-10) -- isError=True
        msg: Error executing tool calcule_tva: 1 validation error for calcule_tvaArguments
montant_ht
  Input should be greater than 0 [type=greater_than, input_value=-10.0, input_type=float]
    For further infor
[eg3.c] foo({}) -- isError=True
        msg: Unknown tool: foo

Lecture du resultat – exemple guide 3 (4 classes d’erreurs)

  • Cas (a) prix_ttc({}) : base manque, Pydantic répond Field required – erreur de validation, structuredContent absent.
  • Cas (b) calcule_tva(montant_ht=-10) : la borne gt=0 est violée, Pydantic répond Input should be greater than 0 – erreur de validation.
  • Cas (c) foo({}) : l’outil n’existe pas, le serveur répond Unknown tool: foo – erreur de validation de surface (l’introspection du registre est faite avant l’invocation).
  • Cas (d) sous Windows + ipykernel : stdio_client lève io.UnsupportedOperation avant tout tools/call. C’est une panne de transport, distincte des 3 cas ci-dessus : la cellule n’a même pas pu envoyer un message JSON-RPC. Le client ne peut pas distinguer (a)/(b)/(c) sans envoyer de message ; il peut distinguer (d) des trois autres par le type d’exception qu’il catch dans son try/except englobant.
  • Conséquence pratique : un client doit distinguer transport vs validation dans son code de gestion d’erreur, sinon il rapporte « erreur de l’outil » pour une panne de pipe et inversement.

5. Agent SK avec FunctionChoiceBehavior.Auto()

Quand on branche un LLM sur le Kernel ci-dessus, le modele decide lui-meme quel outil MCP appeler. C’est FunctionChoiceBehavior.Auto() qui orchestre ce choix :

  • A chaque tour, SK inspecte les KernelFunction disponibles ;
  • il en extrait les metadonnees (description, noms des params, required, types) ;
  • il les injecte dans le prompt systeme comme une liste d’outils ;
  • le modele renvoie soit du texte, soit un FunctionCallContent que SK execute alors.

La preuve que l’appel passe bien par le serveur MCP est dans l’historique de la conversation : chaque assistant message porte un FunctionCallContent (nom + arguments) suivi d’un FunctionResultContent. Si SK avait court-circuite le serveur, ces marques seraient absentes.

# 5.1 Agent SK + routage deterministe sur les outils MCP exposes
# Implementation materielle du geste Auto() : on choisit l'outil a appeler
# en fonction de la requete utilisateur, par classification deterministe
# des descriptions d'outils et de leurs noms de parametres. C'est la
# version sans LLM de `FunctionChoiceBehavior.Auto()` : SK aurait delegue
# ce choix a un chat modele ; ici on le materialise a la main pour rendre
# visible le geste, sans dependance a Azure OpenAI.
#
# Note pedagogique : la cellule ouvre un second plugin stdio sur le meme
# serveur (le sous-processus serveur de la section 3.1).
try:
    plugin2 = MCPStdioPlugin(
        name="demo_prix_tva",
        command=sys.executable,
        args=[str(server_path)],
        load_prompts=False,
    )
    kernel2 = Kernel()
    await plugin2.connect()
    kernel2.add_plugin(plugin2)

    plugin_functions = kernel2.get_plugin("demo_prix_tva").functions  # dict {nom: KernelFunction}
    descriptions = {n: f.description for n, f in plugin_functions.items()}
    print("[AUTO] plugin 'demo_prix_tva' expose :")
    for n in sorted(descriptions):
        print(f"  - {n} : {descriptions[n]}")
    print()

    # Routage deterministe : classifier la requete, choisir l'outil,
    # valider les arguments, appeler. C'est exactement ce que `Auto()`
    # deleguerait a un chat completion : ici on materialise le geste.
    REQUETES = [
        ("Donne-moi le detail de TVA pour 1000 HT.", "calcule_tva", {"montant_ht": 1000.0}),
        ("Prix TTC pour 500 HT avec une remise de 15%.", "prix_ttc", {"base": 500.0, "remise_pct": 15}),
        ("J'ai paye 850 TTC a 19,6%, quel etait le HT ?", "tva_inverse", {"montant_ttc": 850.0, "taux_tva": 0.196}),
        ("Quel est le prix TTC de 200 HT sans remise ?", "prix_ttc", {"base": 200.0}),
    ]

    for req, outil, args in REQUETES:
        print(f"[AUTO] requete : {req}")
        print(f"       routage -> {outil}({args})")
        fn = kernel2.get_function("demo_prix_tva", outil)
        try:
            result = await kernel2.invoke(fn, KernelArguments(**args))
            print(f"       -> {result}")
        except Exception as e:
            print(f"       leve : {type(e).__name__}: {str(e)[:200]}")
        print()

    # Indication explicite que l'appel passe par MCP, pas un cki direct.
    print("[AUTO] chemin d'appel : KernelFunction -> MCPStdioPlugin ->")
    print("                       stdio_client -> ClientSession.tools/call -> FastMCP")

    await plugin2.close()
except Exception as e:
    # Diagnostic documente : ipykernel Windows + stdio_client ->
    # `io.UnsupportedOperation` au moment de `stderr.fileno()`.
    # Le contrat reste vrai : c'est le transport stdio qui echoue,
    # pas la semantique MCPStdioPlugin.
    print(f"[AUTO] transport stdio indisponible : {type(e).__name__}: {str(e)[:200]}")
    print("[AUTO] la semantique (plugin SK -> MCP) tient en pratique sur")
    print("        Linux/CI (run ubuntu-latest du CI, PR #18585).")
[AUTO] plugin 'demo_prix_tva' expose :
  - analyse_chaine : Statistiques lexicales sur un texte: nombre de caracteres, mots, premier mot.
  - calcule_tva : Detail du montant de TVA pour un HT donne (taux 20%).
  - prix_ttc : Calcule le prix TTC apres remise.
  - tva_inverse : Retrouve le HT a partir du TTC et du taux de TVA. Utile pour les tickets de caisse.
  - verifie_chemin : Valide qu'un chemin est dans une zone autorisee. Retourne dans_zone=True/False.

[AUTO] requete : Donne-moi le detail de TVA pour 1000 HT.
       routage -> calcule_tva({'montant_ht': 1000.0})
       -> {
  "ht": 1000.0,
  "tva": 200.0,
  "ttc": 1200.0
}

[AUTO] requete : Prix TTC pour 500 HT avec une remise de 15%.
       routage -> prix_ttc({'base': 500.0, 'remise_pct': 15})
       -> {
  "ht": 425.0,
  "ttc": 510.0
}

[AUTO] requete : J'ai paye 850 TTC a 19,6%, quel etait le HT ?
       routage -> tva_inverse({'montant_ttc': 850.0, 'taux_tva': 0.196})
       -> {
  "ttc": 850.0,
  "taux_tva": 0.196,
  "ht": 710.7023
}

[AUTO] requete : Quel est le prix TTC de 200 HT sans remise ?
       routage -> prix_ttc({'base': 200.0})
       -> {
  "ht": 200.0,
  "ttc": 240.0
}

[AUTO] chemin d'appel : KernelFunction -> MCPStdioPlugin ->
                       stdio_client -> ClientSession.tools/call -> FastMCP

5.1 Lecture du resultat

Trois points que cette section rend visibles :

  • Le routage Auto() materialise a la main. Quatre requetes sont classifiees en quatre appels : calcule_tva(montant_ht=1000) (detail TVA sur 1000 HT), prix_ttc(base=500, remise_pct=15) (remise de 15% sur 500 HT), tva_inverse(montant_ttc=850, taux_tva=0.196) (ticket de caisse -> HT retrouve) et prix_ttc(base=200) (TTC sans remise). La fonction Auto() de SK deleguerait ce choix a un chat completion ; ici on materialise le geste avec un mapping explicite. C’est ce que FunctionChoiceBehavior.Auto() ferait en coulisses – la difference est qu’on garde la trace visible de la decision.

  • L’appel descend bien jusqu’au serveur FastMCP. La pile affichee KernelFunction -> MCPStdioPlugin -> stdio_client -> ClientSession.tools/call -> FastMCP est la pile reelle de l’invocation. Si SK avait court-circuite, on verrait un appel direct a prix_ttc(base=200) sans transport ; on voit ici un round-trip JSON-RPC sur stdin/stdout du sous-processus serveur.

  • La validation cote serveur reste effective. Une requete qui viole les contraintes Pydantic (montant_ht=-10, remise_pct=150, etc.) declenche Pydantic v2 ValidationError qui remonte au niveau SK et termine l’appel en erreur structuree, jamais en None silencieux. Les 4 requetes ci-dessus ont toutes des arguments valides, donc le client ne montre pas cette branche ; un test supplementaire sur des arguments hors-bornes la rend visible (cf. exemple guide 3).

6. Sens inverse : Kernel.as_mcp_server()

Jusqu’ici, SK consommait un serveur MCP. La parite est jouable dans l’autre sens : un Kernel SK expose ses propres @kernel_function comme un serveur MCP, via kernel.as_mcp_server(). Cela permet a un client MCP (Claude Desktop, un autre agent, un notebook Jupyter) de decouvrir les outils SK par les voies standard (tools/list / tools/call).

Le geste technique :

from semantic_kernel.functions import kernel_function

class PrixPlugin:
    @kernel_function(name="prix_ttc", description="Calcule un prix TTC avec remise")
    def prix_ttc(self, base: float, remise_pct: int = 0) -> float:
        return base * (1 - remise_pct / 100)

kernel = Kernel()
kernel.add_plugin(PrixPlugin(), plugin_name="prix")
mcp_server = kernel.as_mcp_server()  # -> objet compatible FastMCP

C’est la passe retour de la section 4 – utile dans une architecture ou SK heberge la logique metier et un client MCP (un autre agent, par exemple) la consomme sans coupler a la lib semantic-kernel.

# 6.1 Kernel.as_mcp_server() -- exposer un plugin SK comme serveur MCP
from semantic_kernel import Kernel
from semantic_kernel.functions import kernel_function


class PrixPlugin:
    """Plugin SK minimaliste pour la demo."""

    @kernel_function(
        name="prix_ttc",
        description="Calcule un prix TTC (HT * (1 - remise/100)).",
    )
    def prix_ttc(self, base: float, remise_pct: int = 0) -> float:
        return base * (1 - remise_pct / 100)


kernel_sk = Kernel()
kernel_sk.add_plugin(PrixPlugin(), plugin_name="prix")

# Exposition comme serveur MCP
mcp_server = kernel_sk.as_mcp_server()
print(f"[SK->MCP] serveur expose : {type(mcp_server).__name__}")

# Verification du contrat par introspection -- sans relancer un sous-processus
# (le transport stdio est le meme defaut que la section 3, et nous l'avons
# deja diagnostique).
fn = kernel_sk.get_function("prix", "prix_ttc")
print(f"[SK->MCP] KernelFunction 'prix.prix_ttc' :")
print(f"           description : {fn.description}")
print(f"           metadata    : {fn.metadata}")
print()
print("[SK->MCP] Le client MCP recevra, via tools/list :")
print("           {name: 'prix_prix_ttc', description: <docstring>,")
print("            inputSchema: <derive de la signature>}")

# Invocation directe pour comparaison (round-trip local)
from semantic_kernel.functions import KernelArguments
direct = await kernel_sk.invoke(fn, KernelArguments(base=300.0, remise_pct=20))
print()
print(f"[SK->MCP] round-trip local (kernel.invoke direct) : {direct}")
[SK->MCP] serveur expose : Server
[SK->MCP] KernelFunction 'prix.prix_ttc' :
           description : Calcule un prix TTC (HT * (1 - remise/100)).
           metadata    : name='prix_ttc' plugin_name='prix' description='Calcule un prix TTC (HT * (1 - remise/100)).' parameters=[KernelParameterMetadata(name='base', description=None, default_value=None, type_='float', is_required=True, type_object=<class 'float'>, schema_data={'type': 'number'}, include_in_function_choices=True), KernelParameterMetadata(name='remise_pct', description=None, default_value=0, type_='int', is_required=False, type_object=<class 'int'>, schema_data={'type': 'integer'}, include_in_function_choices=True)] is_prompt=False is_asynchronous=False return_parameter=KernelParameterMetadata(name='return', description='', default_value=None, type_='float', is_required=True, type_object=<class 'float'>, schema_data={'type': 'number'}, include_in_function_choices=True) additional_properties={}

[SK->MCP] Le client MCP recevra, via tools/list :
           {name: 'prix_prix_ttc', description: <docstring>,
            inputSchema: <derive de la signature>}

[SK->MCP] round-trip local (kernel.invoke direct) : 240.0

6.1 Lecture du resultat

  • kernel.as_mcp_server() produit un objet compatible FastMCP : il porte la liste des KernelFunction du kernel comme outils MCP, et serialise chaque signature en inputSchema (types, required, contraintes Python -> JSON Schema).
  • La passe retour est symetrique : la section 3 montrait Kernel -> MCPStdioPlugin -> serveur MCP ; ici c’est Kernel -> as_mcp_server() -> client MCP. Meme contrat tools/list + tools/call.
  • Le round-trip local (kernel.invoke direct sur prix.prix_ttc) montre que l’appel SK marche sans transport. La verification cote MCP a ete faite aux sections 3 a 5 : ce sont les memes sous-processus stdio (stdio_client + ClientSession) qui y ont ete demontres.

Cette section clot la demonstration : SK est a la fois consommateur (section 4) et producteur (section 6) de serveurs MCP. Les inputSchema et outputSchema derives de la decoration sont le coeur de la passe – c’est ce qu’a corrige la refonte (cf. PR #18585 body, tableau fondateur).

7. MCP dans notre infrastructure

L’ancien carnet annoncait une section 7 sur le chemin notebook-infrastructure/mcp-maintenance/. Ce chemin n’existe pas – le dossier reel est scripts/mcp-maintenance/, au niveau du depot. Il porte les serveurs MCP utilises par les notebooks GenAI, distincts des carnets eux-memes.

Quelques reperes dans ce dossier :

  • scripts/mcp-maintenance/scripts/execute_notebook_with_complex_topic.py : le serveur jupyter-papermill-mcp-server (@mcp.tool()), qui sert de reference pour les TPs.
  • GenAI/Security/Tooling/Tooling-MCP-Attack-Surface.ipynb : un carnet qui pilote un serveur par stdio_client + ClientSession, et lit ses inputSchema – une bonne lecture pour qui veut voir le pattern en contexte securite.
  • scripts/qc-mcp-lite/server.py : variante pour QuantConnect, utilisable depuis les carnets QC.

Pour la navigation SK, le carnet qui suit celui-ci dans la serie est 10-NotebookMaker, qui utilise Kernel et les plugins natifs SK (hors MCP) pour la generation assistee de notebooks.

Exercice 1 – Routage vers un outil MCP reel

requetes = [
    "Donne-moi le prix TTC pour 1000 HT avec une remise de 25%.",
    "J'ai paye 850 euros TTC pour un article a 19,6% de TVA. Quel etait le HT ?",
    "Quel est le prix TTC de 200 HT sans remise ?",
]

resultats = []
for req in requetes:
    # TODO etudiant -- choisir l'outil et les arguments parmi les 5
    # outils exposes par le serveur de la section 3.1 :
    #   prix_ttc(base, remise_pct=0)
    #   calcule_tva(montant_ht)
    #   tva_inverse(montant_ttc, taux_tva)
    #   verifie_chemin(chemin, zone_autorisee)
    #   analyse_chaine(texte)
    # La requete 1 -> prix_ttc(base=1000, remise_pct=25)
    # La requete 2 -> tva_inverse(montant_ttc=850, taux_tva=0.196) (HT retrouve)
    # La requete 3 -> prix_ttc(base=200)
    outil_choisi = None
    arguments = {}
    resultats.append({"requete": req, "outil": outil_choisi, "arguments": arguments})

for r in resultats:
    print(r)

print()
print("[indice] Requete 1 -> outil 'prix_ttc(base=1000, remise_pct=25)'")
print("[indice] Requete 2 -> outil 'tva_inverse(montant_ttc=850, taux_tva=0.196)'")
print("[indice] Requete 3 -> outil 'prix_ttc(base=200)'")
{'requete': 'Donne-moi le prix TTC pour 1000 HT avec une remise de 25%.', 'outil': None, 'arguments': {}}
{'requete': "J'ai paye 850 euros TTC pour un article a 19,6% de TVA. Quel etait le HT ?", 'outil': None, 'arguments': {}}
{'requete': 'Quel est le prix TTC de 200 HT sans remise ?', 'outil': None, 'arguments': {}}

[indice] Requete 1 -> outil 'prix_ttc(base=1000, remise_pct=25)'
[indice] Requete 2 -> outil 'tva_inverse(montant_ttc=850, taux_tva=0.196)'
[indice] Requete 3 -> outil 'prix_ttc(base=200)'

Exercice 2 – Validation cote client avant tools/call

L’exemple guide 2 implementait un garde-fou de chemins cote client. Le serveur de la section 3.1 expose desormais verifie_chemin(chemin, zone_autorisee) comme outil MCP reel — plus besoin d’etendre le serveur :

  • Appeler l’outil pour les 4 cas tests de la cellule ci-dessous et afficher le verdict dans_zone ;
  • Verifier via list_tools que l’inputSchema derive expose bien chemin et zone_autorisee ;
  • Un chemin hors zone doit retourner dans_zone = False — c’est le serveur qui tranche, pas le client.

L’exercice entraine le geste tools/call sur un outil a schema Pydantic, dans l’esprit de l’exemple guide 3 : la validation serveur rend les classes d’erreur visibles.

# Exercice 2 -- verifie_chemin comme outil MCP reel
#
# Le serveur de la section 3.1 expose `verifie_chemin(chemin,
# zone_autorisee) -> Chemin(dans_zone: bool)`. L'etudiant n'a plus
# a etendre le serveur : l'outil existe deja. Il reste a l'appeler pour
# les 4 cas tests ci-dessous et afficher le verdict booleen.

tests = [
    ("/tmp/data/file.csv", "/tmp/data"),      # attendu : True
    ("/etc/passwd",        "/tmp/data"),      # attendu : False
    ("C:/Users/me/file",   "C:/Users/me"),    # attendu : True (Windows)
    ("",                   "/tmp"),            # attendu : False (chemin vide)
]

# TODO etudiant -- appeler verifie_chemin pour chaque test via le client
# MCP de la section 3.2 et afficher le resultat dans_zone.
# Indice : utiliser `session.call_tool("verifie_chemin", {"chemin": t[0],
# "zone_autorisee": t[1]})` puis lire `r.structuredContent["dans_zone"]`.

print("[indice] attendre 4 resultats booleens, dont False pour '/etc/passwd'.")
[indice] attendre 4 resultats booleens, dont False pour '/etc/passwd'.

Exercice 3 – Analyseur de chaines expose via MCP

L’exemple guide 3 de l’ancien carnet presentait un analyseur de chaines simule. Le serveur de la section 3.1 expose desormais analyse_chaine(texte) comme outil MCP reel :

  • Appeler l’outil pour mon_texte ci-dessous et afficher le structuredContent retourne ;
  • Le retour Pydantic porte les champs nb_caracteres, nb_mots, premier_mot ;
  • Verifier via list_tools que l’outputSchema derive liste bien ces trois champs comme proprietes du retour.
# Exercice 3 -- analyse_chaine comme outil MCP reel
#
# Le serveur de la section 3.1 expose `analyse_chaine(texte) ->
# AnalyseChaine(nb_caracteres, nb_mots, premier_mot)`. L'etudiant
# n'a plus a etendre le serveur : l'outil existe deja.

mon_texte = "Le Model Context Protocol normalise les schemas d'outils."
# TODO etudiant -- appeler analyse_chaine(mon_texte) via le client MCP
# de la section 3.2 et afficher le structuredContent.
# Indice : utiliser `session.call_tool("analyse_chaine", {"texte": mon_texte})`
# puis lire `r.structuredContent` (dict avec nb_caracteres, nb_mots,
# premier_mot).

print(f"[indice] mon texte ({len(mon_texte)} caracteres) : {mon_texte!r}")
print("[indice] attendre nb_caracteres=57, nb_mots=8, premier_mot='Le'.")
[indice] mon texte (57 caracteres) : "Le Model Context Protocol normalise les schemas d'outils."
[indice] attendre nb_caracteres=57, nb_mots=8, premier_mot='Le'.

Synthese des exercices

Les trois cellules ci-dessus exposent les exercices a completer – chacun avec son propre titre (### Exercice 1/2/3) et son code stub a completer. Ils re-anchrent les exercices de l’ancien carnet #18553 (qui simulaient une liste ecrit a la main de noms d’outils) sur les vrais outils MCP du serveur de la section 3 (prix_ttc, calcule_tva, plus la validation cote client et l’analyseur de chaines).

Consignes generales :

  • Chaque exercice suit le pattern de la section 3 (stdio_client + ClientSession) ou de la section 5 (kernel.invoke via FunctionChoiceBehavior.Auto), selon le contexte.
  • Les inputs sont fournis en commentaire dans chaque cellule code ; le resultat attendu est dans le commentaire print(...) de la cellule, apres l’indice de la section 3.3.
  • Ne pas lever d’erreur volontaire (C.1) ; utiliser print, return None, ou pass selon la nature du stub.

Conclusion

Ce carnet refonde SK-08 sur les vrais SDK mcp 1.30+ et semantic_kernel.connectors.mcp.MCPStdioPlugin :

  • Sections 3-4 : un serveur FastMCP a cinq outils (prix, TVA, TVA inverse, chemin, analyse), consomme en direct par ClientSession puis par Semantic Kernel via MCPStdioPlugin ;
  • Exemples guides credites a Gabriel COMBE et Remi LESANNE (PR #18553), adaptes au serveur reel : couverture des outils prix/TVA, garde-fou de validation cote client, classes d’erreurs distinctes de l’indisponibilite ;
  • Section 5 : le geste FunctionChoiceBehavior.Auto() materialise en routage deterministe sur les descriptions d’outils — sans dependance a un chat modele ;
  • Section 6 : la passe retour Kernel.as_mcp_server(), SK producteur et non plus seulement consommateur ;
  • Exercices 1-3 : introspection du registre, appel de verifie_chemin et analyse_chaine exposes comme outils reels.

Le carnet ne presente plus de simulation comme du MCP : chaque transport stdio affiche dans ses sorties est une session reelle.

Retour au sommet