29 min de lecture

WebMCP : Quand vos pages web parlent directement à l'IA

Et si votre page web pouvait parler directement à l'IA ? Avec WebMCP, c'est possible. On construit ensemble MiamMCP, une app Symfony de commande de nourriture pilotée par Claude via le navigateur — d'abord à la main, puis avec WebMcpBundle.

Et si votre page web pouvait parler directement à l'IA ? Avec WebMCP, c'est possible. On construit ensemble MiamMCP, une app Symfony de commande de nourriture pilotée par Claude via le navigateur — d'abord à la main, puis avec WebMcpBundle.
Mode de lecture :

Et si votre page web pouvait parler directement à l’IA ?

Aujourd’hui, quand un agent IA comme Claude interagit avec une page web, il doit parser du HTML brut, deviner la structure, interpréter des boutons et des formulaires. C’est du scraping déguisé, fragile et imprécis.

WebMCP (Web Model Context Protocol) change la donne. Ce standard émergent, en cours de discussion au W3C, permet à une page web d’exposer des outils structurés qu’un modèle IA peut appeler directement, comme une API, mais côté navigateur.

Dans cet article, on va construire ensemble MiamMCP, une application de commande de nourriture avec Symfony. Concrètement : une API REST classique, un premier outil WebMCP écrit à la main en JavaScript pour comprendre la mécanique, puis le bundle WebMcpBundle pour générer automatiquement les 4 outils. L’extension Claude dans Chrome Beta les détecte et les utilise : quand l’utilisateur demande “Commande 2 Ramen chez Sakura Sushi”, Claude chaîne les appels automatiquement. Pas de scraping, pas de parsing HTML.

Le code source complet est disponible sur GitHub.


C’est quoi WebMCP ?

De MCP à WebMCP

Vous connaissez peut-être déjà MCP (Model Context Protocol), le protocole créé par Anthropic qui permet aux agents IA de se connecter à des outils externes (bases de données, APIs, systèmes de fichiers). MCP est devenu un standard de fait pour les intégrations côté serveur.

WebMCP transpose cette idée dans le navigateur. Au lieu de connecter l’IA à un serveur MCP, c’est la page web elle-même qui déclare des outils que l’IA peut utiliser.

Deux approches : impérative et déclarative

WebMCP propose deux modes pour exposer des outils aux agents IA. Dans ce tutoriel, on utilisera l’approche impérative, mais il est important de connaître les deux.

L’approche impérative : navigator.modelContext

C’est l’approche qu’on va utiliser dans la suite de cet article. L’API JavaScript navigator.modelContext permet d’enregistrer dynamiquement des tools avec :

  • Un nom (search_restaurants)
  • Une description en langage naturel
  • Un schéma d’entrée (JSON Schema)
  • Une fonction execute (qui exécute l’action)

Note : le callback execute reçoit en réalité deux arguments : les paramètres d’entrée et un objet client donnant accès à des fonctions avancées comme requestUserInteraction (voir la section “Pour aller plus loin”). Dans notre démo, on n’utilise que le premier argument.

await navigator.modelContext.registerTool({
  name: 'search_restaurants',
  description: 'Rechercher des restaurants par cuisine',
  inputSchema: {
    type: 'object',
    properties: {
      cuisine: { type: 'string' }
    }
  },
  execute: async ({ cuisine }) => {
    const response = await fetch(`/api/restaurants?cuisine=${cuisine}`);
    const data = await response.json();
    return { content: [{ type: 'text', text: JSON.stringify(data) }] };
  }
});

Si vous avez déjà utilisé MCP, vous reconnaissez la structure : c’est exactement le même format de réponse content: [{ type, text }].

L’approche impérative est idéale pour les logiques métier dynamiques : l’outil exécute du JavaScript arbitraire, peut appeler des APIs, manipuler le DOM, etc. On peut aussi supprimer un outil à la volée avec navigator.modelContext.unregisterTool("search_restaurants").

L’approche déclarative : le fichier .wmcp

L’autre approche consiste à servir un fichier JSON statique (.wmcp) qui mappe des éléments DOM à des actions, sans JavaScript. Idéal pour les sites statiques ou CMS : les agents peuvent crawler et indexer les outils sans charger la page, comme un robots.txt pour l’IA.

Impératif (registerTool)Déclaratif (.wmcp)
Quand l’utiliserLogique dynamique, appels APISites statiques, CMS
Nécessite JavaScriptOuiNon
Indexable par les agentsNon (exécution requise)Oui (fichier statique)

On n’utilisera pas le mode déclaratif dans ce tutoriel car notre app a besoin de logique dynamique. Mais pour un site vitrine, c’est souvent suffisant.

État actuel : early preview

Soyons transparents : WebMCP est expérimental. Au moment où j’écris ces lignes :

  • Le standard est en draft au W3C (Web Machine Learning Community Group)
  • Seul Chrome Beta 146+ supporte l’API nativement (derrière un flag)
  • L’extension Claude pour Chrome est le principal client compatible
  • L’API navigator.modelContext peut encore évoluer

Ce n’est pas du production-ready. Mais c’est suffisamment concret pour comprendre où le web se dirige et préparer vos applications.

Alternative : si vous ne souhaitez pas activer le flag Chrome, un polyfill @mcp-b/global existe (voir la note en fin d’article).

Setup pour suivre ce tutoriel

Pour tester la partie WebMCP, vous aurez besoin de :

  1. Chrome Beta (version 146 ou supérieure)
  2. Le flag chrome://flags/#enable-webmcp-testing activé sur Enabled
  3. L’extension Claude officielle installée depuis le Chrome Web Store

La partie Symfony fonctionne avec n’importe quel navigateur. Seule l’interaction IA nécessite Chrome Beta.

Note : l’extension Claude nécessite un compte Anthropic.


Notre projet : MiamMCP

MiamMCP est une application de commande de nourriture. Elle expose 5 restaurants avec des cuisines différentes et permet de :

  1. Chercher des restaurants par type de cuisine
  2. Consulter le menu d’un restaurant
  3. Commander des plats
  4. Suivre les commandes en cours (accessible uniquement via l’IA !)

Modèle de données

Restaurant ──< Dish


  Order ──< OrderItem ──> Dish

4 entités : Restaurant, Dish, Order et OrderItem.

Les 4 outils WebMCP

OutilEntréeAction
search_restaurantscuisine? (string)Recherche par cuisine ou liste tous
get_menurestaurant_id (number, requis)Carte complète du restaurant
place_orderrestaurant_id, customer_name?, items[]Passe une commande
get_pending_ordersaucuneListe les commandes en cours

Le dernier outil est particulier : aucun lien, aucun bouton sur la page ne permet de consulter les commandes en cours. Seule l’IA, via WebMCP, peut accéder à cette information. C’est une illustration concrète de ce que WebMCP rend possible : exposer des fonctionnalités qui n’existent pas dans l’interface utilisateur classique.

Stack technique


Installation

Créer le projet Symfony

symfony new MiamMCP --webapp
cd MiamMCP

Configurer SQLite

Ouvrez le fichier .env et remplacez la ligne DATABASE_URL par :

DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"

SQLite est parfait pour une démo : pas de serveur à installer, un seul fichier.

Installer DoctrineFixturesBundle

composer require --dev doctrine/doctrine-fixtures-bundle

Installer WebMcpBundle

composer require yoanbernabeu/webmcp-bundle
php bin/console assets:install

La commande assets:install copie le runtime JavaScript du bundle dans le dossier public/. Ce bundle fournit des attributs PHP pour déclarer vos outils WebMCP directement sur vos méthodes de contrôleur, et une fonction Twig pour les injecter automatiquement dans vos pages. Plus besoin d’écrire du JavaScript à la main.

Vérifier que ça tourne

symfony serve

Ouvrez http://localhost:8000, vous devriez voir la page d’accueil de Symfony. Arrêtez le serveur pour la suite.


Les entités Doctrine

4 entités qui suivent le modèle de données présenté plus haut :

  • Restaurant : nom, cuisine, description, adresse, rating + relation OneToMany vers Dish
  • Dish : nom, description, prix, catégorie, disponibilité
  • Order : restaurant, client, statut, montant total (table échappée `order` car mot réservé SQL)
  • OrderItem : plat, quantité, prix unitaire
Voir le code complet des 4 entités

Restaurant

Créez le fichier src/Entity/Restaurant.php :

<?php

namespace App\Entity;

use App\Repository\RestaurantRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: RestaurantRepository::class)]
class Restaurant
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $name = null;

    #[ORM\Column(length: 100)]
    private ?string $cuisine = null;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $description = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $address = null;

    #[ORM\Column(type: 'float', nullable: true)]
    private ?float $rating = null;

    /** @var Collection<int, Dish> */
    #[ORM\OneToMany(targetEntity: Dish::class, mappedBy: 'restaurant', cascade: ['persist', 'remove'])]
    private Collection $dishes;

    public function __construct()
    {
        $this->dishes = new ArrayCollection();
    }

    public function getId(): ?int { return $this->id; }

    public function getName(): ?string { return $this->name; }
    public function setName(string $name): static { $this->name = $name; return $this; }

    public function getCuisine(): ?string { return $this->cuisine; }
    public function setCuisine(string $cuisine): static { $this->cuisine = $cuisine; return $this; }

    public function getDescription(): ?string { return $this->description; }
    public function setDescription(?string $description): static { $this->description = $description; return $this; }

    public function getAddress(): ?string { return $this->address; }
    public function setAddress(?string $address): static { $this->address = $address; return $this; }

    public function getRating(): ?float { return $this->rating; }
    public function setRating(?float $rating): static { $this->rating = $rating; return $this; }

    /** @return Collection<int, Dish> */
    public function getDishes(): Collection { return $this->dishes; }

    public function addDish(Dish $dish): static
    {
        if (!$this->dishes->contains($dish)) {
            $this->dishes->add($dish);
            $dish->setRestaurant($this);
        }
        return $this;
    }
}

Dish

Créez src/Entity/Dish.php :

<?php

namespace App\Entity;

use App\Repository\DishRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: DishRepository::class)]
class Dish
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $name = null;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $description = null;

    #[ORM\Column(type: 'float')]
    private ?float $price = null;

    #[ORM\Column(length: 100, nullable: true)]
    private ?string $category = null;

    #[ORM\Column(type: 'boolean')]
    private bool $available = true;

    #[ORM\ManyToOne(targetEntity: Restaurant::class, inversedBy: 'dishes')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Restaurant $restaurant = null;

    public function getId(): ?int { return $this->id; }

    public function getName(): ?string { return $this->name; }
    public function setName(string $name): static { $this->name = $name; return $this; }

    public function getDescription(): ?string { return $this->description; }
    public function setDescription(?string $description): static { $this->description = $description; return $this; }

    public function getPrice(): ?float { return $this->price; }
    public function setPrice(float $price): static { $this->price = $price; return $this; }

    public function getCategory(): ?string { return $this->category; }
    public function setCategory(?string $category): static { $this->category = $category; return $this; }

    public function isAvailable(): bool { return $this->available; }
    public function setAvailable(bool $available): static { $this->available = $available; return $this; }

    public function getRestaurant(): ?Restaurant { return $this->restaurant; }
    public function setRestaurant(?Restaurant $restaurant): static { $this->restaurant = $restaurant; return $this; }
}

Order

Créez src/Entity/Order.php :

<?php

namespace App\Entity;

use App\Repository\OrderRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: OrderRepository::class)]
#[ORM\Table(name: '`order`')]
class Order
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: Restaurant::class)]
    #[ORM\JoinColumn(nullable: false)]
    private ?Restaurant $restaurant = null;

    #[ORM\Column(length: 50)]
    private string $status = 'pending';

    #[ORM\Column(type: 'float')]
    private float $totalAmount = 0;

    #[ORM\Column(length: 255)]
    private ?string $customerName = null;

    #[ORM\Column(type: 'datetime_immutable')]
    private ?\DateTimeImmutable $createdAt = null;

    /** @var Collection<int, OrderItem> */
    #[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order', cascade: ['persist', 'remove'])]
    private Collection $items;

    public function __construct()
    {
        $this->items = new ArrayCollection();
        $this->createdAt = new \DateTimeImmutable();
    }

    public function getId(): ?int { return $this->id; }

    public function getRestaurant(): ?Restaurant { return $this->restaurant; }
    public function setRestaurant(?Restaurant $restaurant): static { $this->restaurant = $restaurant; return $this; }

    public function getStatus(): string { return $this->status; }
    public function setStatus(string $status): static { $this->status = $status; return $this; }

    public function getTotalAmount(): float { return $this->totalAmount; }
    public function setTotalAmount(float $totalAmount): static { $this->totalAmount = $totalAmount; return $this; }

    public function getCustomerName(): ?string { return $this->customerName; }
    public function setCustomerName(string $customerName): static { $this->customerName = $customerName; return $this; }

    public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }

    /** @return Collection<int, OrderItem> */
    public function getItems(): Collection { return $this->items; }

    public function addItem(OrderItem $item): static
    {
        if (!$this->items->contains($item)) {
            $this->items->add($item);
            $item->setOrder($this);
        }
        return $this;
    }
}

OrderItem

Créez src/Entity/OrderItem.php :

<?php

namespace App\Entity;

use App\Repository\OrderItemRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: OrderItemRepository::class)]
class OrderItem
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: Order::class, inversedBy: 'items')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Order $order = null;

    #[ORM\ManyToOne(targetEntity: Dish::class)]
    #[ORM\JoinColumn(nullable: false)]
    private ?Dish $dish = null;

    #[ORM\Column(type: 'integer')]
    private int $quantity = 1;

    #[ORM\Column(type: 'float')]
    private float $unitPrice = 0;

    public function getId(): ?int { return $this->id; }

    public function getOrder(): ?Order { return $this->order; }
    public function setOrder(?Order $order): static { $this->order = $order; return $this; }

    public function getDish(): ?Dish { return $this->dish; }
    public function setDish(?Dish $dish): static { $this->dish = $dish; return $this; }

    public function getQuantity(): int { return $this->quantity; }
    public function setQuantity(int $quantity): static { $this->quantity = $quantity; return $this; }

    public function getUnitPrice(): float { return $this->unitPrice; }
    public function setUnitPrice(float $unitPrice): static { $this->unitPrice = $unitPrice; return $this; }
}

Les Repositories

Le seul repository non trivial est RestaurantRepository avec sa méthode de recherche par cuisine :

// src/Repository/RestaurantRepository.php
<?php

namespace App\Repository;

use App\Entity\Restaurant;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class RestaurantRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Restaurant::class);
    }

    /** @return Restaurant[] */
    public function findByCuisine(string $cuisine): array
    {
        return $this->createQueryBuilder('r')
            ->where('LOWER(r.cuisine) LIKE LOWER(:cuisine)')
            ->setParameter('cuisine', '%' . $cuisine . '%')
            ->orderBy('r.name', 'ASC')
            ->getQuery()
            ->getResult();
    }
}

La méthode findByCuisine utilise un LIKE insensible à la casse. Ainsi, chercher “japonaise”, “Japonaise” ou “japon” fonctionnera.

Créez aussi DishRepository, OrderRepository et OrderItemRepository, ce sont des repositories vides (juste le constructeur parent), Doctrine en a besoin pour fonctionner.

Migration et exécution

php bin/console make:migration
php bin/console doctrine:migrations:migrate

Répondez yes à la confirmation. La base SQLite var/data.db est créée avec les 4 tables.


Les fixtures

On veut 5 restaurants avec des cuisines variées et des plats réalistes. Créez src/DataFixtures/AppFixtures.php (le fichier est long, dépliez si besoin) :

Voir le code de AppFixtures.php
<?php

namespace App\DataFixtures;

use App\Entity\Dish;
use App\Entity\Restaurant;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;

class AppFixtures extends Fixture
{
    public function load(ObjectManager $manager): void
    {
        $restaurants = [
            [
                'name' => 'Bella Napoli',
                'cuisine' => 'Italienne',
                'description' => 'Authentique cuisine italienne, pizzas au feu de bois et pâtes fraîches maison.',
                'address' => '12 rue de la Paix, 75002 Paris',
                'rating' => 4.5,
                'dishes' => [
                    ['name' => 'Pizza Margherita', 'description' => 'Tomate, mozzarella di bufala, basilic frais', 'price' => 12.50, 'category' => 'Pizza'],
                    ['name' => 'Pizza Quattro Formaggi', 'description' => 'Mozzarella, gorgonzola, parmesan, chèvre', 'price' => 14.00, 'category' => 'Pizza'],
                    ['name' => 'Spaghetti Carbonara', 'description' => 'Guanciale, pecorino romano, oeuf, poivre noir', 'price' => 13.50, 'category' => 'Pâtes'],
                    ['name' => 'Lasagnes Bolognaise', 'description' => 'Couches de pâtes fraîches, ragù de boeuf, béchamel', 'price' => 14.50, 'category' => 'Pâtes'],
                    ['name' => 'Risotto aux Champignons', 'description' => 'Riz arborio, cèpes, parmesan, truffe noire', 'price' => 16.00, 'category' => 'Risotto'],
                    ['name' => 'Tiramisu', 'description' => 'Mascarpone, café espresso, cacao amer', 'price' => 8.00, 'category' => 'Dessert'],
                    ['name' => 'Panna Cotta', 'description' => 'Crème vanillée, coulis de fruits rouges', 'price' => 7.50, 'category' => 'Dessert'],
                ],
            ],
            [
                'name' => 'Sakura Sushi',
                'cuisine' => 'Japonaise',
                'description' => 'Restaurant japonais traditionnel, sushis préparés minute et ramen artisanaux.',
                'address' => '45 boulevard Saint-Germain, 75005 Paris',
                'rating' => 4.7,
                'dishes' => [
                    ['name' => 'Ramen Tonkotsu', 'description' => 'Bouillon de porc 12h, nouilles fraîches, oeuf mollet, chashu', 'price' => 15.00, 'category' => 'Ramen'],
                    ['name' => 'Plateau Sushi Deluxe', 'description' => '12 pièces : saumon, thon, crevette, anguille', 'price' => 22.00, 'category' => 'Sushi'],
                    ['name' => 'Gyoza', 'description' => '6 raviolis grillés au porc et légumes', 'price' => 8.50, 'category' => 'Entrée'],
                    ['name' => 'Tempura de Crevettes', 'description' => '5 crevettes en beignet léger, sauce tentsuyu', 'price' => 13.00, 'category' => 'Entrée'],
                    ['name' => 'Chirashi Saumon', 'description' => 'Bol de riz vinaigré, saumon frais, avocat, sésame', 'price' => 16.50, 'category' => 'Bowl'],
                    ['name' => 'Mochi Glacé', 'description' => 'Assortiment de 3 mochis : matcha, mangue, fraise', 'price' => 7.00, 'category' => 'Dessert'],
                    ['name' => 'Edamame', 'description' => 'Fèves de soja vapeur, fleur de sel', 'price' => 5.00, 'category' => 'Entrée'],
                ],
            ],
            [
                'name' => 'Le Petit Bistrot',
                'cuisine' => 'Française',
                'description' => 'Bistrot parisien classique, cuisine française de terroir revisitée.',
                'address' => '8 rue du Faubourg Saint-Antoine, 75012 Paris',
                'rating' => 4.3,
                'dishes' => [
                    ['name' => 'Boeuf Bourguignon', 'description' => 'Boeuf mijoté au vin rouge, carottes, champignons', 'price' => 18.50, 'category' => 'Plat'],
                    ['name' => 'Confit de Canard', 'description' => 'Cuisse confite, pommes sarladaises, salade', 'price' => 19.00, 'category' => 'Plat'],
                    ['name' => 'Soupe à l\'Oignon', 'description' => 'Oignons caramélisés, bouillon, croûtons gratinés', 'price' => 9.50, 'category' => 'Entrée'],
                    ['name' => 'Tartare de Boeuf', 'description' => 'Boeuf haché au couteau, câpres, cornichons, frites', 'price' => 17.00, 'category' => 'Plat'],
                    ['name' => 'Crème Brûlée', 'description' => 'Vanille de Madagascar, caramel craquant', 'price' => 8.50, 'category' => 'Dessert'],
                    ['name' => 'Tarte Tatin', 'description' => 'Pommes caramélisées, pâte feuilletée, crème fraîche', 'price' => 9.00, 'category' => 'Dessert'],
                    ['name' => 'Salade de Chèvre Chaud', 'description' => 'Mesclun, chèvre gratiné, noix, miel', 'price' => 12.00, 'category' => 'Entrée'],
                ],
            ],
            [
                'name' => 'El Sombrero',
                'cuisine' => 'Mexicaine',
                'description' => 'Saveurs du Mexique, tacos artisanaux et guacamole préparé à la minute.',
                'address' => '23 rue Oberkampf, 75011 Paris',
                'rating' => 4.4,
                'dishes' => [
                    ['name' => 'Tacos al Pastor', 'description' => '3 tacos, porc mariné, ananas, coriandre', 'price' => 13.00, 'category' => 'Tacos'],
                    ['name' => 'Burrito Poulet', 'description' => 'Tortilla, poulet grillé, riz, haricots noirs, pico de gallo', 'price' => 12.50, 'category' => 'Burrito'],
                    ['name' => 'Guacamole Maison', 'description' => 'Avocat, citron vert, piment jalapeño, tortillas chips', 'price' => 9.00, 'category' => 'Entrée'],
                    ['name' => 'Quesadillas Mixtes', 'description' => 'Fromage fondu, poulet, poivrons, oignons caramélisés', 'price' => 11.50, 'category' => 'Plat'],
                    ['name' => 'Nachos Supreme', 'description' => 'Chips, cheddar, jalapeños, crème, salsa, guacamole', 'price' => 11.00, 'category' => 'Entrée'],
                    ['name' => 'Churros', 'description' => '6 churros croustillants, sauce chocolat noir', 'price' => 7.00, 'category' => 'Dessert'],
                ],
            ],
            [
                'name' => 'Taj Mahal Palace',
                'cuisine' => 'Indienne',
                'description' => 'Cuisine indienne raffinée, épices importées et pain naan au tandoor.',
                'address' => '56 rue du Château d\'Eau, 75010 Paris',
                'rating' => 4.6,
                'dishes' => [
                    ['name' => 'Butter Chicken', 'description' => 'Poulet tandoori dans sauce tomate crémeuse au beurre', 'price' => 15.50, 'category' => 'Curry'],
                    ['name' => 'Palak Paneer', 'description' => 'Épinards frais, fromage paneer, épices douces', 'price' => 13.50, 'category' => 'Curry'],
                    ['name' => 'Biryani Agneau', 'description' => 'Riz basmati parfumé au safran, agneau mijoté, raïta', 'price' => 17.00, 'category' => 'Riz'],
                    ['name' => 'Naan au Fromage', 'description' => 'Pain tandoor fourré au fromage fondu', 'price' => 4.50, 'category' => 'Pain'],
                    ['name' => 'Samosas Légumes', 'description' => '3 beignets croustillants, pommes de terre, petits pois', 'price' => 7.00, 'category' => 'Entrée'],
                    ['name' => 'Tikka Masala Crevettes', 'description' => 'Crevettes marinées, sauce masala onctueuse', 'price' => 18.00, 'category' => 'Curry'],
                    ['name' => 'Gulab Jamun', 'description' => '3 beignets de lait, sirop de cardamome et eau de rose', 'price' => 6.50, 'category' => 'Dessert'],
                ],
            ],
        ];

        foreach ($restaurants as $restaurantData) {
            $restaurant = new Restaurant();
            $restaurant->setName($restaurantData['name']);
            $restaurant->setCuisine($restaurantData['cuisine']);
            $restaurant->setDescription($restaurantData['description']);
            $restaurant->setAddress($restaurantData['address']);
            $restaurant->setRating($restaurantData['rating']);

            foreach ($restaurantData['dishes'] as $dishData) {
                $dish = new Dish();
                $dish->setName($dishData['name']);
                $dish->setDescription($dishData['description']);
                $dish->setPrice($dishData['price']);
                $dish->setCategory($dishData['category']);
                $dish->setAvailable(true);
                $restaurant->addDish($dish);
                $manager->persist($dish);
            }

            $manager->persist($restaurant);
        }

        $manager->flush();
    }
}

5 restaurants, 34 plats au total. Chargez-les :

php bin/console doctrine:fixtures:load --no-interaction

Les endpoints API

ApiController

Créez src/Controller/ApiController.php avec 4 routes. Voici les deux premières pour comprendre le pattern :

<?php

namespace App\Controller;

use App\Entity\Dish;
use App\Entity\Order;
use App\Entity\OrderItem;
use App\Repository\OrderRepository;
use App\Repository\RestaurantRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api')]
class ApiController extends AbstractController
{
    #[Route('/restaurants', name: 'api_restaurants', methods: ['GET'])]
    public function restaurants(Request $request, RestaurantRepository $repository): JsonResponse
    {
        $cuisine = $request->query->get('cuisine');

        $restaurants = $cuisine
            ? $repository->findByCuisine($cuisine)
            : $repository->findAll();

        $data = array_map(fn($r) => [
            'id' => $r->getId(),
            'name' => $r->getName(),
            'cuisine' => $r->getCuisine(),
            'description' => $r->getDescription(),
            'address' => $r->getAddress(),
            'rating' => $r->getRating(),
        ], $restaurants);

        return $this->json($data);
    }

    #[Route('/restaurants/{id}/menu', name: 'api_restaurant_menu', methods: ['GET'])]
    public function menu(int $id, RestaurantRepository $repository): JsonResponse
    {
        $restaurant = $repository->find($id);

        if (!$restaurant) {
            return $this->json(['error' => 'Restaurant non trouvé'], 404);
        }

        $dishes = array_map(fn(Dish $d) => [
            'id' => $d->getId(),
            'name' => $d->getName(),
            'description' => $d->getDescription(),
            'price' => $d->getPrice(),
            'category' => $d->getCategory(),
            'available' => $d->isAvailable(),
        ], $restaurant->getDishes()->toArray());

        return $this->json([
            'restaurant' => $restaurant->getName(),
            'cuisine' => $restaurant->getCuisine(),
            'dishes' => array_values($dishes),
        ]);
    }

}
Voir les deux routes restantes (orders et createOrder)
    #[Route('/orders', name: 'api_orders', methods: ['GET'])]
    public function orders(OrderRepository $orderRepository): JsonResponse
    {
        $orders = $orderRepository->findBy(['status' => 'pending'], ['createdAt' => 'DESC']);

        $data = array_map(fn(Order $o) => [
            'id' => $o->getId(),
            'restaurant' => $o->getRestaurant()->getName(),
            'customer_name' => $o->getCustomerName(),
            'status' => $o->getStatus(),
            'total_amount' => $o->getTotalAmount(),
            'created_at' => $o->getCreatedAt()->format('Y-m-d H:i:s'),
            'items' => array_map(fn(OrderItem $i) => [
                'dish' => $i->getDish()->getName(),
                'quantity' => $i->getQuantity(),
                'unit_price' => $i->getUnitPrice(),
            ], $o->getItems()->toArray()),
        ], $orders);

        return $this->json($data);
    }

    #[Route('/orders', name: 'api_create_order', methods: ['POST'])]
    public function createOrder(Request $request, EntityManagerInterface $em, RestaurantRepository $restaurantRepository): JsonResponse
    {
        $payload = json_decode($request->getContent(), true);

        $restaurant = $restaurantRepository->find($payload['restaurant_id'] ?? 0);
        if (!$restaurant) {
            return $this->json(['error' => 'Restaurant non trouvé'], 404);
        }

        $order = new Order();
        $order->setRestaurant($restaurant);
        $order->setCustomerName($payload['customer_name'] ?? 'Client');

        $total = 0;
        foreach ($payload['items'] ?? [] as $item) {
            $dish = $em->getRepository(Dish::class)->find($item['dish_id'] ?? 0);
            if (!$dish) {
                continue;
            }

            $orderItem = new OrderItem();
            $orderItem->setDish($dish);
            $orderItem->setQuantity($item['quantity'] ?? 1);
            $orderItem->setUnitPrice($dish->getPrice());
            $order->addItem($orderItem);
            $em->persist($orderItem);

            $total += $dish->getPrice() * $orderItem->getQuantity();
        }

        $order->setTotalAmount($total);
        $em->persist($order);
        $em->flush();

        return $this->json([
            'message' => 'Commande créée avec succès',
            'order_id' => $order->getId(),
            'total' => $total,
        ], 201);
    }

Le code complet est aussi disponible sur GitHub.

Note sécurité : les endpoints sont ouverts sans authentification pour la démo. En production, comme l’agent utilise le navigateur de l’utilisateur, les appels fetch() héritent automatiquement de sa session (cookies, tokens).

Vérifiez que l’API fonctionne en ouvrant http://localhost:8000/api/restaurants dans votre navigateur après avoir lancé symfony serve.


Un premier outil WebMCP à la main

Avant d’utiliser un bundle, construisons un outil WebMCP à la main pour comprendre la mécanique. On va enregistrer search_restaurants en JavaScript vanilla.

HomeController

Créez src/Controller/HomeController.php :

<?php

namespace App\Controller;

use App\Repository\RestaurantRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class HomeController extends AbstractController
{
    #[Route('/', name: 'app_home')]
    public function index(RestaurantRepository $restaurantRepository): Response
    {
        return $this->render('home/index.html.twig', [
            'restaurants' => $restaurantRepository->findAll(),
        ]);
    }
}

Le template avec un outil WebMCP

Créez templates/home/index.html.twig. Le HTML affiche les restaurants, et le JavaScript enregistre un seul outil WebMCP pour comprendre le mécanisme :

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>MiamMCP - Commandez avec l'IA</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: system-ui, -apple-system, sans-serif; background: #f8fafc; color: #1e293b; }
        .header { background: linear-gradient(135deg, #7c3aed, #ec4899); color: white; padding: 2rem; text-align: center; }
        .header h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
        .header p { font-size: 1.1rem; opacity: 0.9; }
        .status { display: inline-block; margin-top: 1rem; padding: 0.5rem 1rem; border-radius: 9999px; font-size: 0.875rem; font-weight: 600; }
        .status.ok { background: rgba(255,255,255,0.2); }
        .status.error { background: rgba(239,68,68,0.3); }
        .container { max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
        .restaurants { display: grid; gap: 1.5rem; }
        .card { background: white; border-radius: 1rem; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); transition: transform 0.2s; }
        .card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
        .card h2 { font-size: 1.4rem; margin-bottom: 0.25rem; }
        .card .cuisine { color: #7c3aed; font-weight: 600; font-size: 0.9rem; }
        .card .description { color: #64748b; margin: 0.5rem 0; }
        .card .meta { display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem; font-size: 0.875rem; color: #94a3b8; }
        .card .rating { color: #f59e0b; font-weight: 600; }
    </style>
</head>
<body>
    <div class="header">
        <h1>MiamMCP</h1>
        <p>Commandez vos plats préférés avec l'aide de l'IA</p>
        <div id="webmcp-status" class="status">Vérification WebMCP...</div>
    </div>

    <div class="container">
        <div class="restaurants">
            {% for restaurant in restaurants %}            <div class="card">
                <h2>{{ restaurant.name }}</h2>
                <span class="cuisine">{{ restaurant.cuisine }}</span>
                <p class="description">{{ restaurant.description }}</p>
                <div class="meta">
                    <span>{{ restaurant.address }}</span>
                    <span class="rating">{{ restaurant.rating }}/5</span>
                </div>
            </div>
            {% endfor %}        </div>
    </div>

    <script>
    (async function() {
        const statusEl = document.getElementById('webmcp-status');

        if (!navigator.modelContext) {
            statusEl.textContent = 'WebMCP non disponible';
            statusEl.className = 'status error';
            return;
        }

        // Notre premier outil WebMCP !
        await navigator.modelContext.registerTool({
            name: 'search_restaurants',
            description: 'Rechercher des restaurants par type de cuisine (italienne, japonaise, française, mexicaine, indienne) ou lister tous les restaurants disponibles.',
            inputSchema: {
                type: 'object',
                properties: {
                    cuisine: {
                        type: 'string',
                        description: 'Type de cuisine à rechercher (ex: japonaise, italienne). Laisser vide pour tous les restaurants.'
                    }
                }
            },
            execute: async ({ cuisine }) => {
                const url = cuisine
                    ? `/api/restaurants?cuisine=${encodeURIComponent(cuisine)}`
                    : '/api/restaurants';
                const response = await fetch(url);
                const data = await response.json();
                return {
                    content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
                };
            }
        });

        statusEl.textContent = 'WebMCP actif — 1 outil enregistré';
        statusEl.className = 'status ok';
    })();
    </script>
</body>
</html>

Décortiquons ce code

L’appel à navigator.modelContext.registerTool() prend 4 éléments :

  • name : l’identifiant technique de l’outil
  • description : en langage naturel, c’est ce que l’IA lit pour décider quel outil utiliser. Soyez descriptif !
  • inputSchema : un JSON Schema qui décrit les paramètres. Ici cuisine est optionnel (pas dans required). Si l’utilisateur dit “montre-moi tous les restaurants”, l’IA appellera l’outil sans paramètre.
  • execute : la fonction qui s’exécute quand l’IA appelle l’outil. Elle fait un fetch() vers notre API Symfony et retourne le résultat au format MCP standard content: [{ type: 'text', text: ... }].

Si vous avez déjà utilisé MCP côté serveur, vous reconnaissez la structure : c’est exactement le même format.

Le flux en un coup d’œil

UtilisateurClaude (Extension)WebMCP (navigateur)Symfony (API)"Trouve-moi un restaurant japonais"Appelle search_restaurants({cuisine: "japonaise"})fetch(/api/restaurants?cuisine=japonaise)JSON [{id: 2, name: "Sakura Sushi", ...}]content: [{type: "text", text: ...}]"J'ai trouvé Sakura Sushi !"UtilisateurClaude (Extension)WebMCP (navigateur)Symfony (API)

Vérification

Lancez le serveur :

symfony serve

Ouvrez http://localhost:8000 dans Chrome Beta (avec le flag chrome://flags/#enable-webmcp-testing activé). Le badge dans le header devrait afficher “WebMCP actif — 1 outil enregistré”.


Tester avec Model Context Tool Inspector

Avant de brancher l’IA, vérifions que notre outil fonctionne. L’extension Model Context Tool Inspector (disponible sur le Chrome Web Store) permet d’inspecter et exécuter les outils WebMCP d’une page.

Installation

  1. Installez l’extension depuis le Chrome Web Store
  2. Assurez-vous que le flag chrome://flags/#enable-webmcp-testing est activé (Chrome Beta 146+)

Tester notre outil

  1. Ouvrez http://localhost:8000 dans Chrome Beta
  2. Cliquez sur l’icône de l’extension pour ouvrir le side panel
  3. L’extension détecte search_restaurants et l’affiche

Essayez :

  • Sans paramètre : exécutez l’outil tel quel, vous obtenez la liste des 5 restaurants
  • Avec {"cuisine": "japonaise"} : vous obtenez uniquement Sakura Sushi

Notre outil fonctionne parfaitement ! On a compris la mécanique. Maintenant, imaginons qu’on doive écrire les 3 autres outils (get_menu, place_order, get_pending_orders) de la même manière : ça ferait ~130 lignes de JavaScript à maintenir, avec des URLs hardcodées, du POST vs GET à gérer, des paramètres de route à interpoler…

C’est là qu’intervient le bundle.


Simplifier avec WebMcpBundle

Le problème de l’approche manuelle

Avec 4 outils, on aurait ~130 lignes de JavaScript à écrire et maintenir. Chaque outil nécessite :

  • Le nom, la description, le inputSchema complet
  • La fonction execute avec la bonne URL, la bonne méthode HTTP, la gestion des paramètres de route vs query string vs body

Et si une route Symfony change ? Il faut penser à mettre à jour le JS. C’est de la duplication entre le backend et le frontend.

La solution : des attributs PHP

Le bundle WebMcpBundle permet de déclarer les outils WebMCP directement sur les méthodes du contrôleur, avec des attributs PHP. Le bundle se charge de générer tout le JavaScript automatiquement.

Mise en place

Ajoutez les attributs #[AsWebMcpTool] à notre ApiController :

// src/Controller/ApiController.php
use YoanBernabeu\WebMcpBundle\Attribute\AsWebMcpTool;
use YoanBernabeu\WebMcpBundle\Attribute\WebMcpInput;

// ... (les use existants restent)

#[Route('/api')]
class ApiController extends AbstractController
{
    #[Route('/restaurants', name: 'api_restaurants', methods: ['GET'])]
    #[AsWebMcpTool(
        name: 'search_restaurants',
        description: 'Rechercher des restaurants par type de cuisine (italienne, japonaise, française, mexicaine, indienne) ou lister tous les restaurants disponibles.',
        inputs: [
            new WebMcpInput(name: 'cuisine', type: 'string', description: 'Type de cuisine à rechercher (ex: japonaise, italienne). Laisser vide pour tous les restaurants.'),
        ],
    )]
    public function restaurants(Request $request, RestaurantRepository $repository): JsonResponse
    {
        // ... le code ne change pas
    }

    #[Route('/restaurants/{id}/menu', name: 'api_restaurant_menu', methods: ['GET'])]
    #[AsWebMcpTool(
        name: 'get_menu',
        description: "Obtenir la carte complète (liste des plats avec prix) d'un restaurant spécifique à partir de son identifiant.",
        inputs: [
            new WebMcpInput(name: 'restaurant_id', type: 'number', description: "L'identifiant du restaurant dont on veut voir le menu.", required: true, mapTo: 'id'),
        ],
    )]
    public function menu(int $id, RestaurantRepository $repository): JsonResponse
    {
        // ...
    }

    #[Route('/orders', name: 'api_orders', methods: ['GET'])]
    #[AsWebMcpTool(
        name: 'get_pending_orders',
        description: "Récupérer la liste de toutes les commandes en cours (status pending). Cette information n'est pas visible sur la page, seul cet outil permet d'y accéder.",
    )]
    public function orders(OrderRepository $orderRepository): JsonResponse
    {
        // ...
    }

    #[Route('/orders', name: 'api_create_order', methods: ['POST'])]
    #[AsWebMcpTool(
        name: 'place_order',
        description: "Passer une commande de plats dans un restaurant. Nécessite l'ID du restaurant, le nom du client et la liste des plats avec leurs quantités.",
        inputs: [
            new WebMcpInput(name: 'restaurant_id', type: 'number', description: "L'identifiant du restaurant.", required: true),
            new WebMcpInput(name: 'customer_name', type: 'string', description: 'Le nom du client pour la commande.'),
            new WebMcpInput(name: 'items', type: 'array', description: 'Liste des plats à commander.', required: true),
        ],
    )]
    public function createOrder(Request $request, EntityManagerInterface $em, RestaurantRepository $restaurantRepository): JsonResponse
    {
        // ...
    }
}

Points notables :

  • Chaque outil est déclaré là où la route est définie. Une seule source de vérité.
  • Le mapTo: 'id' sur get_menu mappe le paramètre restaurant_id au {id} dans la route /restaurants/{id}/menu. Le bundle gère automatiquement la substitution dans l’URL.
  • get_pending_orders n’a aucun paramètre d’entrée : pas de inputs.
  • place_order est en POST : le bundle enverra les paramètres dans le body JSON automatiquement.

Remplacer le JavaScript par un appel Twig

Maintenant, remplacez tout le bloc <script> du template par :

    {# Remplace les ~130 lignes de JavaScript #}
    {{ webmcp_tools() }}
    <script>
    (function() {
        const statusEl = document.getElementById('webmcp-status');
        if (!navigator.modelContext) {
            statusEl.textContent = 'WebMCP non disponible';
            statusEl.className = 'status error';
        } else {
            statusEl.textContent = 'WebMCP actif (via WebMcpBundle)';
            statusEl.className = 'status ok';
        }
    })();
    </script>

{{ webmcp_tools() }} génère automatiquement :

  1. Un <script type="application/json"> contenant la configuration des 4 outils
  2. Un <script> chargeant le runtime JavaScript du bundle qui appelle navigator.modelContext.registerTool() pour chaque outil

Le petit script qui suit ne fait que vérifier visuellement la disponibilité de l’API.

Ce que fait le bundle sous le capot

Quand Symfony compile le conteneur, le bundle :

  1. Scanne tous les contrôleurs à la recherche d’attributs #[AsWebMcpTool]
  2. Extrait les métadonnées : nom, description, paramètres, route Symfony, méthode HTTP
  3. Construit un registre d’outils disponible au runtime
  4. Génère via webmcp_tools() exactement le même JavaScript que vous auriez écrit à la main

C’est le même navigator.modelContext.registerTool() qu’on a écrit pour search_restaurants, mais généré automatiquement pour les 4 outils. Pas de magie, juste de l’automatisation.

Le tool “fantôme” : get_pending_orders

Parmi nos 4 outils, celui-ci mérite une mention spéciale. Les données qu’il expose (les commandes en cours) ne sont visibles nulle part sur la page. Pas de page “Mes commandes”, pas même un indice que cette information existe. Seule l’IA peut y accéder.

C’est une illustration concrète de ce que WebMCP rend possible : exposer des fonctionnalités qui n’existent pas dans l’interface utilisateur classique. Imaginez les possibilités : un dashboard analytique invisible, un outil de diagnostic, un résumé intelligent de l’activité… Tout ça sans ajouter un seul élément à l’interface.

Vérification avec Model Context Tool Inspector

Relancez le serveur et rouvrez le Tool Inspector. Cette fois, vous devriez voir 4 outils au lieu d’un seul. Testez get_menu avec {"restaurant_id": 1} pour vérifier que le mapping de paramètre de route fonctionne.


Tester avec Chrome Beta + Extension Claude

Prérequis

  1. Installez Chrome Beta (version 146 ou supérieure) depuis google.com/chrome/beta

  2. Activez le flag WebMCP : ouvrez chrome://flags/#enable-webmcp-testing dans Chrome Beta et passez-le sur Enabled. Relancez le navigateur.

  3. Installez l’extension Claude depuis le Chrome Web Store. C’est l’extension officielle d’Anthropic.

Tester

  1. Lancez le serveur Symfony : symfony serve
  2. Ouvrez http://localhost:8000 dans Chrome Beta
  3. Vérifiez dans la console (F12) : “4 outils WebMCP enregistrés”
  4. Ouvrez l’extension Claude (icône en haut à droite)

Essayez ces prompts :

  • “Trouve-moi un restaurant japonais” : Claude appellera search_restaurants avec cuisine: "japonaise" et affichera Sakura Sushi.

  • “Montre-moi la carte du restaurant Sakura Sushi” : Claude appellera get_menu avec l’ID du restaurant.

  • “Commande 2 Ramen Tonkotsu et 1 Gyoza chez Sakura Sushi pour Yoan” : Claude enchaînera plusieurs outils : recherche du restaurant, récupération du menu pour trouver les IDs des plats, puis passage de la commande.

  • “Quelles sont les commandes en cours ?” : Claude appellera get_pending_orders. Cette information n’est accessible nulle part sur la page : pas de lien, pas de bouton. Seule l’IA peut la récupérer.

Le workflow est visible : Claude annonce chaque outil qu’il appelle, affiche les résultats intermédiaires, et présente le résultat final.


Pour aller plus loin

Notre démo couvre l’essentiel, mais WebMCP va bien au-delà. Voici les aspects du standard qui méritent votre attention.

Des gains de performance mesurables

WebMCP n’est pas qu’une question d’ergonomie développeur : c’est aussi une optimisation drastique des coûts. Des benchmarks menés sur 1 890 appels API réels, détaillés dans l’étude webMCP: Efficient AI-Native Client-Side Interaction for Agent-Ready Web Design, montrent une réduction moyenne de 67,6 % de la consommation de tokens, avec des gains variant selon le scénario :

ScénarioRéduction de tokens
E-commerce (panier, checkout)-78,6 %
Contenu dynamique (AJAX)-70,9 %
Authentification-53,5 %

La raison est simple : au lieu de forcer l’IA à parser tout le DOM d’une page (souvent 10 000 à 100 000 tokens), WebMCP lui expose uniquement les points d’interaction. On passe d’une complexité O(taille_page) à O(éléments_interactifs). Le taux de réussite reste quasi identique (97,9 % contre 98,8 % en parsing traditionnel).

Pour une entreprise qui automatise des tâches à grande échelle, ça représente une réduction de 34 à 63 % des coûts d’API.

Sécurité : l’IA dans un bac à sable

Contrairement au scraping classique où l’agent navigue “à l’aveugle” dans le HTML, WebMCP intègre un modèle de sécurité natif :

  • Signatures Ed25519 : chaque fichier .wmcp peut être signé numériquement, avec la clé publique épinglée via DNS. L’agent ne peut exécuter que des outils dont l’intégrité est vérifiée.
  • URL Shielding : les endpoints sensibles sont masqués derrière des identifiants symboliques (@LOGIN_API) et ne sont résolus qu’après présentation d’un token éphémère.
  • Protection CSRF : chaque élément interactif peut intégrer un token CSRF que l’agent doit renvoyer.
  • Anti-injection de prompt : la spécification recommande de limiter les descriptions d’outils (160 caractères comme bonne pratique) et le schéma JSON strict empêche l’injection de Markdown ou de HTML.
  • Chiffrement JWE : les payloads contenant des données sensibles peuvent être chiffrés via JSON Web Encryption.

Human-in-the-loop : garder le contrôle

Pour les actions critiques (paiement, suppression de compte, validation réglementaire), WebMCP prévoit un mécanisme de reprise de contrôle par l’utilisateur via requestUserInteraction :

execute: async (args, client) => {
  // L'agent se met en pause et rend la main à l'utilisateur
  const confirmation = await client.requestUserInteraction(async () => {
    // Afficher une modale de confirmation, un formulaire de paiement...
    return await showPaymentConfirmation(args);
  });
  return confirmation;
}

L’agent suspend son exécution, l’utilisateur valide (ou refuse), puis l’agent reprend. C’est le pattern “Human-in-the-loop” appliqué directement dans le navigateur.

Annotations d’outils

Chaque outil peut porter des annotations qui guident le comportement de l’agent :

await navigator.modelContext.registerTool({
  name: 'get_menu',
  description: 'Consulter la carte d\'un restaurant',
  annotations: {
    readOnlyHint: true  // Indique à l'agent que cet outil ne modifie rien
  },
  // ...
});

readOnlyHint: true signale que l’outil est sans effet de bord : l’agent peut l’appeler librement sans craindre de modifier des données. À l’inverse, un outil de commande serait marqué comme ayant des effets de bord, incitant l’agent à demander confirmation.

Interaction bidirectionnelle via iframes

Un concept avancé de WebMCP : une application embarquée dans une iframe peut enregistrer dynamiquement de nouveaux outils auprès de l’IA via postMessage. La page parente et l’iframe n’ont aucune connaissance préalable l’une de l’autre — elles coopèrent uniquement via le protocole WebMCP.

Imaginez un site qui embarque un widget de réservation tiers dans une iframe. Ce widget peut déclarer ses propres outils (book_table, check_availability) que l’IA découvre automatiquement. C’est de la composition dynamique d’outils en temps réel, sans intégration technique entre les deux parties.


Conclusion

On a construit une application complète qui illustre le potentiel de WebMCP :

  • Côté mécanique : on a écrit un outil à la main pour comprendre navigator.modelContext.registerTool()
  • Côté productivité : WebMcpBundle automatise tout avec des attributs PHP et {{ webmcp_tools() }}
  • Côté IA : Claude détecte les outils, les chaîne intelligemment et exécute des actions concrètes

Ce qui est frappant, c’est la simplicité. Pas de serveur MCP à déployer, pas de SDK complexe. Que vous écriviez le JavaScript à la main ou que vous utilisiez le bundle, l’API WebMCP reste la même : quelques lignes suffisent pour donner à l’IA la capacité d’interagir avec votre application.

Ce que WebMCP change

Pour nous, développeurs web, WebMCP ouvre une nouvelle dimension. Au lieu de construire uniquement pour des humains qui cliquent, on peut aussi construire pour des agents IA qui appellent des outils. Et ces deux interfaces coexistent sur la même page.

Et maintenant ?

On l’a dit en début d’article : WebMCP est expérimental. Mais c’est clairement la direction que prend le web. Et le meilleur moment pour comprendre un standard, c’est quand il est encore simple.

Ressources

Note : si vous ne souhaitez pas activer le flag Chrome, vous pouvez utiliser le polyfill @mcp-b/global comme alternative (voir la section “WebMCP vs MCP-B” plus haut). Il suffit d’ajouter <script src="https://unpkg.com/@mcp-b/global"></script> avant l’appel {{ webmcp_tools() }}. L’API navigator.modelContext sera alors disponible même sans le flag natif.

Back to Blog

Comments (0)

Loading comments...

Leave a Comment