Introduction
Un client d’IA ne devrait pas nécessiter une intégration personnalisée pour chaque application qu’il utilise. Le Model Context Protocol (MCP) fournit aux clients une méthode standard pour découvrir des outils, examiner leurs contrats d’entrée et les appeler. Dans ce lab, l’outil reste volontairement simple : il recherche un seul ticket d’assistance synthétique et ne peut rien modifier.
Vous allez créer le serveur avec le gestionnaire MCP sans état actuel de Cloudflare :
- Un espace de noms Cloudflare KV dédié contient l’enregistrement métier synthétique. KV est le magasin de données explicite de l’application ; il ne s’agit pas d’une mémoire de session MCP cachée.
- Un schéma Zod strict accepte uniquement un identifiant de ticket synthétique et rejette les champs supplémentaires.
McpServer.registerTool()publie un outil avec des annotations en lecture seule et non destructives.createMcpHandler()crée un serveur vierge pour chaque requête HTTP Streamable.- Le client MCP TypeScript officiel découvre et appelle l’outil depuis des connexions indépendantes.
- Des vérifications locales et déployées prouvent la recherche valide, le comportement sûr en cas d’enregistrement manquant, le rejet des arguments invalides et l’absence d’état de session partagé implicite.
Ce point d’accès ne nécessite volontairement aucune authentification, car il n’expose qu’un seul enregistrement synthétique, temporaire et en lecture seule. N’utilisez pas ce modèle pour publier des données privées de clients. Les serveurs de production doivent ajouter une authentification et une autorisation avant d’accéder aux données des locataires ; les fournisseurs OAuth externes sortent du cadre de ce lab pour débutants.
L’écosystème MCP utilisait auparavant des points d’accès SSE et du code standard pour les serveurs avec état. Ce lab n’enseigne pas cette ancienne conception. Il utilise Streamable HTTP et une fabrique de serveurs par requête, conformément aux recommandations actuelles de Cloudflare pour un nouveau serveur distant.
Avant d’accéder directement à ce cours, terminez Connect LabEx to Your Cloudflare Account. Chaque nouvelle VM LabEx doit disposer de sa propre autorisation Wrangler. Les labs précédents du cours sont recommandés, mais leurs VM et ressources ne sont jamais réutilisées ici.
Autoriser la VM et créer un catalogue dédié
Dans cette étape, vous allez autoriser cette nouvelle VM, choisir le compte d’apprentissage et créer un espace de noms KV temporaire. En séparant le catalogue, vous rendez clairement identifiables sa propriété et son nettoyage.
Accédez au projet préparé et vérifiez les outils épinglés :
cd /home/labex/project/read-only-mcp-tool
node --version
npx wrangler --version
Autorisez cette VM :
npx wrangler login
Ouvrez dans le navigateur le lien vers l’appareil affiché, vérifiez les autorisations demandées et autorisez votre compte d’apprentissage dédié. Revenez dans le terminal, attendez la fin de l’opération, puis examinez l’identité structurée :

La liste des autorisations est plus large que nécessaire pour ce seul lab, car Wrangler est l’interface de ligne de commande générale de Cloudflare pour le développement. Avant de valider, vérifiez que la page mentionne Wrangler, que vous utilisez bien le compte d’apprentissage prévu et qu’aucun mot de passe ni jeton n’apparaît dans le terminal.
npx wrangler whoami --json
Vérifiez loggedIn: true ainsi que le nom du compte prévu, même si la sortie ne répertorie qu’un seul compte. Copiez l’id réel de ce compte. Générez un préfixe unique et enregistrez la configuration initiale du Worker, après avoir d’abord remplacé le paramètre fictif :
ACCOUNT_ID="paste-your-confirmed-account-id"
RUN="labex-c11-s07-$(openssl rand -hex 6)"
cat > wrangler.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/server.ts",
"compatibility_date": "2026-09-19",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true }
}
JSON
Créez l’espace de noms sans demander à Wrangler de modifier automatiquement le fichier :
npx wrangler kv namespace create "$RUN-cases" --update-config=false
Si Wrangler vous demande s’il doit ajouter automatiquement une liaison, choisissez No ; la modification suivante établira cette connexion explicitement. Copiez l’identifiant d’espace de noms de 32 caractères affiché dans la sortie et ajoutez une seule liaison :
NAMESPACE_ID="paste-the-created-namespace-id"
python3 - "$NAMESPACE_ID" <<'PY'
import json, sys
from pathlib import Path
path = Path('wrangler.jsonc')
data = json.loads(path.read_text())
data['kv_namespaces'] = [{'binding': 'SUPPORT_CASES', 'id': sys.argv[1]}]
path.write_text(json.dumps(data, indent=2) + '\n')
PY
npx wrangler kv namespace list
python3 .labex/verify.py authorization
Le nom de liaison SUPPORT_CASES est l’identifiant que votre code utilisera. L’identifiant d’espace de noms désigne la ressource réelle dans le compte confirmé. Rien n’a encore été déployé.
Alimenter les données métier synthétiques explicites
Dans cette étape, vous allez placer le même enregistrement fourni dans les magasins KV local et distant. Le magasin de données est explicite : une requête MCP peut être sans état tout en lisant des données métier persistantes par clé.
Examinez le fichier de données avant de le téléverser :
cat fixtures/case.json
Le préfixe T-SYNTH-101 et le marqueur synthetic: true rendent visible la limite de la démonstration. L’enregistrement ne contient aucun nom, e-mail, message ou identifiant réel de client.
Alimentez le magasin local utilisé par wrangler dev :
npx wrangler kv key put case:T-SYNTH-101 --path fixtures/case.json --binding SUPPORT_CASES --local
Alimentez l’espace de noms Cloudflare dédié :
npx wrangler kv key put case:T-SYNTH-101 --path fixtures/case.json --binding SUPPORT_CASES --remote
Lisez les deux copies via la liaison :
npx wrangler kv key get case:T-SYNTH-101 --binding SUPPORT_CASES --local --text
npx wrangler kv key get case:T-SYNTH-101 --binding SUPPORT_CASES --remote --text
python3 .labex/verify.py catalog
Le vérificateur contrôle l’espace de noms à partir de l’identifiant du compte, exige exactement une clé et compare le JSON distant avec le fichier de données synthétiques fourni. KV peut être cohérent avec un certain délai entre les emplacements. Si la première lecture distante ne trouve brièvement pas une valeur qui vient d’être écrite, attendez quelques secondes et réessayez au lieu d’écrire plusieurs copies.
Enregistrer un outil MCP strict en lecture seule
Dans cette étape, vous allez définir une fabrique de serveurs MCP et un outil de recherche en lecture seule.
McpServer décrit l’interface du protocole. La fabrique crée une nouvelle instance pour chaque requête HTTP, tandis que la liaison SUPPORT_CASES reste la source explicite des données métier. Créez src/server.ts :
cat > src/server.ts <<'TS'
import { createMcpHandler } from "agents/mcp/server";
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
interface Env {
SUPPORT_CASES: KVNamespace;
}
const lookupInput = z.object({
ticketId: z.string().regex(/^T-SYNTH-[0-9]{3}$/, "use a synthetic ticket ID")
}).strict();
const storedCase = z.object({
ticketId: z.string(),
subject: z.string(),
status: z.string(),
priority: z.string(),
product: z.string(),
synthetic: z.literal(true)
}).strict();
function buildServer(env: Env): McpServer {
const requestInstance = crypto.randomUUID();
const server = new McpServer({
name: "synthetic-support-catalog",
version: "1.0.0"
});
server.registerTool("lookup_support_case", {
title: "Look up a synthetic support case",
description: "Read one synthetic demonstration case by its T-SYNTH identifier.",
inputSchema: lookupInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}, async ({ ticketId }) => {
const raw = await env.SUPPORT_CASES.get(`case:${ticketId}`, "json");
if (raw === null) {
return {
isError: true,
content: [{ type: "text", text: `Synthetic case ${ticketId} was not found.` }]
};
}
const record = storedCase.parse(raw);
const result = { ...record, requestInstance };
return {
structuredContent: result,
content: [{ type: "text", text: JSON.stringify(result) }]
};
});
return server;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/health") {
return Response.json({
service: "synthetic-support-mcp",
transport: "streamable-http",
state: "stateless"
});
}
if (url.pathname !== "/mcp") return new Response("Not found", { status: 404 });
const handler = createMcpHandler(
() => buildServer(env),
{ route: "/mcp", corsOptions: false, legacy: "stateless" }
);
return handler(request, env, ctx);
}
};
TS
npm run check
python3 .labex/verify.py server
Trois limites sont importantes ici :
.strict()rejette les champs non déclarés au lieu de les accepter silencieusement.- Les annotations indiquent aux clients que l’outil lit un catalogue synthétique fermé et n’a aucun effet destructeur. Une annotation constitue une métadonnée utile, mais elle ne remplace pas la vérification du code confirmant qu’aucun
put()nidelete()n’existe. requestInstanceest généré lorsque la fabrique construit un serveur. Des requêtes de protocole différentes doivent renvoyer des marqueurs différents. Le cycle de vie sans état devient ainsi observable sans stocker de données de session.
La configuration de compatibilité legacy: "stateless" utilise toujours Streamable HTTP. Elle permet aux clients actuels qui négocient la famille de protocoles 2025 de fonctionner, tout en garantissant que chaque requête reçoit une nouvelle instance de serveur ; aucune route SSE ni session MCP durable n’est créée.
Créer une sonde MCP indépendante
Dans cette étape, vous allez utiliser la bibliothèque cliente officielle au lieu d’écrire vous-même du JSON-RPC. Un client réel effectue l’initialisation du protocole, la découverte des outils et leur appel via StreamableHTTPClientTransport.
Créez scripts/test-client.mjs :
cat > scripts/test-client.mjs <<'JS'
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
const endpoint = process.argv[2];
if (!endpoint) throw new Error("usage: node scripts/test-client.mjs <mcp-url>");
async function withClient(label, action) {
const transport = new StreamableHTTPClientTransport(new URL(endpoint));
const client = new Client({ name: `labex-${label}`, version: "1.0.0" });
try {
await client.connect(transport);
return await action(client);
} finally {
await client.close();
}
}
const tools = await withClient("discovery", (client) => client.listTools());
const tool = tools.tools.find((item) => item.name === "lookup_support_case");
if (!tool || tool.annotations?.readOnlyHint !== true) {
throw new Error("the read-only lookup tool was not discoverable");
}
console.log("DISCOVERED lookup_support_case");
async function lookup(ticketId) {
return withClient(`lookup-${ticketId.toLowerCase()}`, (client) => client.callTool({
name: "lookup_support_case",
arguments: { ticketId }
}));
}
const first = await lookup("T-SYNTH-101");
const second = await lookup("T-SYNTH-101");
const a = first.structuredContent;
const b = second.structuredContent;
if (!a || !b || a.synthetic !== true || a.status !== "investigating") {
throw new Error("the valid synthetic record was not returned");
}
console.log(`VALID synthetic=${a.synthetic} status=${a.status}`);
const missing = await lookup("T-SYNTH-404");
console.log(`MISSING isError=${missing.isError === true}`);
let invalidRejected = false;
try {
const invalid = await withClient("invalid", (client) => client.callTool({
name: "lookup_support_case",
arguments: { ticketId: "REAL-101", unexpected: "must-not-pass" }
}));
invalidRejected = invalid.isError === true;
} catch {
invalidRejected = true;
}
console.log(`INVALID_REJECTED ${invalidRejected}`);
const stateless = typeof a.requestInstance === "string"
&& typeof b.requestInstance === "string"
&& a.requestInstance !== b.requestInstance;
console.log(`STATELESS ${stateless}`);
if (missing.isError !== true || !invalidRejected || !stateless) process.exitCode = 1;
JS
python3 .labex/verify.py client
Chaque appel de fonction auxiliaire crée et ferme son propre transport client. La découverte prouve que le serveur publie le contrat de l’outil. Deux appels valides doivent lire le même enregistrement KV, mais renvoyer des marqueurs requestInstance différents. Le ticket manquant produit une erreur normale au niveau de l’outil, tandis qu’un identifiant invalide est rejeté par le schéma d’entrée avant que le gestionnaire ne lise KV.
Tester le contrat MCP localement
Dans cette étape, vous allez démarrer le Worker avec le magasin KV local et exécuter la sonde cliente complète avant d’utiliser le point d’accès déployé.
Démarrez le serveur de développement :
npx wrangler dev --ip 127.0.0.1 --port 8787
Laissez ce terminal ouvert. Ouvrez un deuxième terminal, accédez au même projet et vérifiez la petite route d’état :
cd /home/labex/project/read-only-mcp-tool
curl --fail --silent http://127.0.0.1:8787/health | python3 -m json.tool
La sortie doit contenir transport: "streamable-http" et state: "stateless". Exécutez maintenant le client du protocole :
node scripts/test-client.mjs http://127.0.0.1:8787/mcp
Les cinq lignes de preuve doivent afficher la découverte, le résultat synthétique valide, une erreur sûre pour le ticket manquant, le rejet de l’entrée invalide et STATELESS true. Revenez au premier terminal et appuyez sur Ctrl+C après la sonde.
Exécutez la vérification indépendante. Elle démarre un autre Worker local limité sur le port 8791, utilise le même code importé et l’arrête automatiquement :
python3 .labex/verify.py local
Déployer et tester le point d’accès MCP distant
Dans cette étape, vous allez déployer le Worker avec sa liaison KV explicite et exécuter le même client sur le véritable point d’accès workers.dev.
Déployez à partir de la configuration du projet :
npx wrangler deploy
Copiez l’URL de déploiement affichée et enregistrez-la sans la barre oblique finale :
WORKER_URL="https://your-generated-worker.your-subdomain.workers.dev"
Vérifiez la route d’état, puis connectez le client MCP à /mcp :
curl --fail --silent "$WORKER_URL/health" | python3 -m json.tool
node scripts/test-client.mjs "$WORKER_URL/mcp"
python3 .labex/verify.py deployed
Le vérificateur indépendant déduit le point d’accès à partir du compte sélectionné au lieu de faire confiance à la variable shell. Il vérifie également la liaison SUPPORT_CASES déployée, l’enregistrement distant exact et les cinq comportements MCP. Une route d’état accessible ne suffit pas : la découverte et l’appel doivent réussir via le client du protocole.
Ouvrez Workers & Pages et sélectionnez le Worker généré. Sa vue d’ensemble doit associer le domaine workers.dev au Worker et afficher une liaison KV SUPPORT_CASES. Les valeurs ci-dessous sont des exemples issus de l’exécution testée ; vos noms de ressources uniques et vos compteurs seront différents.

Examiner et supprimer les ressources détenues
Dans cette étape, vous allez examiner l’état observable dans le cloud, puis supprimer uniquement le Worker et l’espace de noms KV de cette exécution, tant que Wrangler est encore autorisé.
Ouvrez le tableau de bord Cloudflare et sélectionnez le même compte d’apprentissage. Sous Workers & Pages, ouvrez le Worker dont le nom commence par labex-c11-s07-. Vérifiez que son dernier déploiement est sain, que l’observabilité est activée et que la liaison SUPPORT_CASES pointe vers l’identifiant d’espace de noms indiqué dans wrangler.jsonc.
Ouvrez Storage & databases > KV, sélectionnez l’espace de noms correspondant en -cases et examinez case:T-SYNTH-101. La valeur est le fichier de données synthétiques ; n’ajoutez aucune information personnelle. Ces vues du tableau de bord sont utiles pour vous orienter, mais le client et le vérificateur restent les preuves fonctionnelles faisant autorité.
La vue KV Pairs affiche d’abord la clé exacte et un aperçu de sa valeur JSON :

Développez la ligne pour associer cette clé aux champs renvoyés par l’outil MCP. Le fichier de données testé utilise status: investigating, priority: medium et synthetic: true.

Revenez au Worker et ouvrez Observability. Les événements POST /mcp et de transport GET /mcp réussis montrent qu’un véritable client MCP distant a atteint le Worker déployé. Lors de l’exécution testée, les 42 événements capturés ont réussi et aucun n’a produit d’erreur du Worker ; votre nombre de requêtes peut être différent.

Exécutez une dernière vérification indépendante de l’état observable avant la suppression :
python3 .labex/verify.py observed
cat wrangler.jsonc
Vérifiez le nom exact et unique du Worker ainsi que l’identifiant de l’espace de noms, puis supprimez le Worker :
npx wrangler delete
Si une confirmation s’affiche, vérifiez le nom du Worker indiqué et répondez y. Supprimez uniquement l’espace de noms sélectionné par la liaison SUPPORT_CASES :
npx wrangler kv namespace delete --binding SUPPORT_CASES
npx wrangler kv namespace list
python3 .labex/verify.py deleted
Actualisez les listes Worker et KV du tableau de bord. Les deux ressources labex-c11-s07-... doivent avoir disparu, tandis que les ressources sans lien restent présentes. L’échec d’une requête vers le point d’accès ne prouve pas la suppression ; le vérificateur contrôle directement les inventaires du compte autorisé.
Recherchez le nom exact du Worker généré. Un résultat vide confirme que le tableau de bord ne le répertorie plus :

Recherchez dans Workers KV l’espace de noms exact en -cases. L’état vide et le stockage actuel de 0 B confirment que le catalogue temporaire a également été supprimé de ce compte de test propre :

Révoquer l’autorisation de cette VM
Dans cette étape, vous allez révoquer l’autorisation temporaire de la VM après avoir prouvé l’absence des ressources.
npx wrangler logout
npx wrangler whoami --json || true
Le résultat structuré doit indiquer loggedIn: false, ou Wrangler peut renvoyer un résultat non authentifié avec un code différent de zéro. La déconnexion est volontairement la dernière opération : la vérification de la suppression nécessite un accès en lecture au compte sélectionné, contrairement à la VM temporaire.
Résumé
Vous avez publié puis supprimé un service MCP limité et en lecture seule sur Cloudflare. Vous avez :
- conservé les données métier synthétiques dans un espace de noms KV dédié au lieu d’utiliser un état de session MCP implicite ;
- enregistré un outil découvrable avec une validation stricte des entrées et des annotations en lecture seule ;
- servi cet outil via le gestionnaire Streamable HTTP sans état actuel ;
- utilisé un véritable client MCP pour la découverte et les tests de recherche valide, d’enregistrement manquant et d’entrée invalide ;
- prouvé que les requêtes indépendantes reçoivent de nouvelles instances de serveur tout en lisant les mêmes données explicites ;
- examiné l’état du Worker et de KV, supprimé les deux ressources détenues et révoqué l’autorisation de la VM.
La leçon essentielle de conception est la suivante : un transport sans état ne signifie pas qu’une application ne contient aucune donnée. Cela signifie que les requêtes du protocole ne dépendent pas d’une mémoire de session cachée. Les données métier persistantes restent explicites, délimitées et gérées indépendamment.



