Si alguna vez has visto a un agente de IA intentando usar una web, sabrás que es como ver a alguien conducir con los ojos vendados. El agente hace una captura de pantalla, la envía a un modelo de visión, intenta adivinar dónde está el botón de "Enviar", hace clic... y reza. Si el diseño cambia un píxel, todo se rompe.
WebMCP viene a solucionar exactamente este problema. En lugar de que el agente tenga que adivinar cómo funciona tu web, ahora tu web le puede decir directamente qué puede hacer y cómo hacerlo.
Qué es WebMCP
WebMCP (Web Model Context Protocol) es una API JavaScript nativa del navegador que permite a las páginas web exponer su funcionalidad como tools estructurados que los agentes de IA pueden invocar directamente.
La idea central es simple: las páginas web que implementan WebMCP se comportan como servidores MCP del lado del cliente. Si ya conoces el Model Context Protocol de Anthropic, piensa en WebMCP como su equivalente para el frontend, ejecutándose directamente en el navegador en lugar de en un servidor backend.
El estándar está siendo desarrollado conjuntamente por Google y Microsoft dentro del W3C Web Machine Learning Community Group, con editores de ambas compañías. Actualmente se encuentra disponible como early preview en Chrome 146 (Canary) detrás de un flag experimental.
Requisitos para probarlo
Chrome Canary versión 146.0.7672.0 o superior.
Activar el flag
chrome://flags/#enable-webmcp-testing.Instalar la extensión Model Context Tool Inspector para depurar y probar los tools registrados.
El problema que resuelve
Hasta ahora los agentes de IA tenían dos formas de interactuar con una web:
Visualmente: el agente captura screenshots, los envía a un modelo multimodal y trata de interpretar la interfaz como lo haría un humano. Lento, costoso y frágil.
Semánticamente: el agente parsea el DOM o el árbol de accesibilidad para extraer información y simular eventos de click, scroll o escritura. Algo más fiable, pero sigue siendo ingeniería inversa sobre una interfaz diseñada para humanos.
Ambos enfoques comparten los mismos problemas: requieren múltiples ida y vuelta con el modelo, consumen muchos tokens, y cualquier cambio en el diseño puede romper todo el flujo.
WebMCP propone un tercer camino: que la propia web declare sus capacidades mediante un contrato estructurado. El agente ya no necesita adivinar nada, simplemente lee las tools disponibles, sus parámetros y las invoca como llamadas a funciones.
La API: navigator.modelContext
Todo gira en torno a un nuevo objeto en el navegador: navigator.modelContext. Esta interfaz expone cuatro métodos:
interface ModelContext {
provideContext(options?: ModelContextOptions): void;
clearContext(): void;
registerTool(tool: ModelContextTool): void;
unregisterTool(name: string): void;
}provideContext(options) registra un conjunto de tools de golpe, reemplazando cualquier registro previo. clearContext() elimina todos los tools registrados. registerTool(tool) añade un tool individual sin afectar a los existentes. unregisterTool(name) elimina un tool concreto por su nombre.
Las dos APIs: Declarativa e Imperativa
WebMCP ofrece dos caminos complementarios para exponer funcionalidad. Puedes usar ambos en la misma página según el caso de uso.
API Declarativa: formularios HTML con superpoderes
Esta es la vía más directa. Si tu web ya tiene formularios HTML bien estructurados, hacerlos compatibles con agentes es cuestión de añadir dos atributos.
Los atributos disponibles son:
toolname: obligatorio en el<form>. Identificador único del tool.tooldescription: obligatorio. Descripción en lenguaje natural de lo que hace el formulario.toolautosubmit: opcional. Si está presente, el agente puede enviar el formulario automáticamente sin esperar confirmación del usuario.toolparamdescription: opcional en los<input>. Describe el parámetro para que el agente entienda qué dato se espera.toolparamtitle: opcional en los<input>. Título alternativo del parámetro visible para el agente.
El navegador genera automáticamente un JSON Schema a partir de los campos del formulario, infiriendo tipos del atributo type, restricciones de required, min, max, pattern, etc.
Veamos un ejemplo práctico. Imagina que tienes un buscador de artículos en tu blog:
<form
toolname="search_articles"
tooldescription="Busca artículos del blog por palabra clave, categoría y rango de fechas"
action="/articles/search"
method="GET"
>
<label for="query">Buscar</label>
<input
type="text"
id="query"
name="query"
required
toolparamdescription="Palabra clave o frase para buscar en títulos y contenido"
/>
<label for="category">Categoría</label>
<select
id="category"
name="category"
toolparamdescription="Categoría del artículo: laravel, php, javascript, devops"
>
<option value="">Todas</option>
<option value="laravel">Laravel</option>
<option value="php">PHP</option>
<option value="javascript">JavaScript</option>
<option value="devops">DevOps</option>
</select>
<label for="from">Desde</label>
<input
type="date"
id="from"
name="from"
toolparamdescription="Fecha de inicio en formato ISO (YYYY-MM-DD)"
/>
<button type="submit">Buscar</button>
</form>Con esto, un agente de IA que aterrice en tu página puede descubrir que existe un tool llamado search_articles, entender qué parámetros acepta y llamarlo directamente. Sin hacer capturas de pantalla, sin parsear el DOM.
Eventos del lado del agente
Cuando un agente interactúa con un formulario declarativo, el navegador dispara varios eventos que puedes capturar:
// Detectar si el submit lo hizo un agente o un humano
document.querySelector('form').addEventListener('submit', (event) => {
if (event.agentInvoked) {
event.preventDefault();
const formData = new FormData(event.target);
const results = performSearch(formData);
// Devolver resultados estructurados al agente
event.respondWith(Promise.resolve(results));
}
});
// Saber cuándo el agente empieza a interactuar con el formulario
window.addEventListener('toolactivated', (event) => {
console.log('Un agente está rellenando el formulario');
});
// Saber cuándo el agente cancela o se resetea el formulario
window.addEventListener('toolcancel', (event) => {
console.log('El agente ha cancelado la operación');
});El SubmitEvent.agentInvoked es un booleano nuevo que te permite distinguir entre un envío humano y uno automatizado por un agente. Esto es muy útil para devolver JSON estructurado al agente en lugar de renderizar HTML.
También existen pseudo-clases CSS que se activan mientras el agente está interactuando con un formulario, permitiéndote dar feedback visual al usuario:
/* Se activa en el formulario mientras el agente lo está usando */
form:tool-form-active {
outline: 2px solid #4f46e5;
background-color: rgba(79, 70, 229, 0.05);
}
/* Se activa en el botón de submit durante la interacción del agente */
button:tool-submit-active {
animation: pulse 1.5s infinite;
}
API Imperativa: control total con JavaScript
Para interacciones más complejas que van más allá de un formulario, la API Imperativa permite registrar funciones JavaScript como tools con esquemas completos.
La estructura de un ModelContextTool es:
{
name: string, // Identificador único
description: string, // Descripción en lenguaje natural
inputSchema: object, // JSON Schema de los parámetros
execute: Function, // Callback que ejecuta la lógica
annotations: { // Metadatos opcionales
readOnlyHint: boolean // true si el tool solo lee datos
}
}Veamos un ejemplo completo. Supón que tienes un e-commerce y quieres que un agente pueda buscar productos, ver detalles y añadir al carrito:
// Tool de búsqueda de productos
navigator.modelContext.registerTool({
name: 'search_products',
description: 'Busca productos en el catálogo por texto, categoría y rango de precio. Devuelve un listado con id, nombre, precio, disponibilidad y URL de imagen.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Texto libre para buscar en nombre y descripción del producto'
},
category: {
type: 'string',
enum: ['electronics', 'clothing', 'home', 'sports'],
description: 'Categoría del producto'
},
min_price: {
type: 'number',
minimum: 0,
description: 'Precio mínimo en euros'
},
max_price: {
type: 'number',
minimum: 0,
description: 'Precio máximo en euros'
},
in_stock: {
type: 'boolean',
description: 'Si es true, solo devuelve productos con stock disponible'
}
},
required: ['query']
},
annotations: {
readOnlyHint: true
},
execute: async (input) => {
const params = new URLSearchParams();
params.set('q', input.query);
if (input.category) params.set('category', input.category);
if (input.min_price) params.set('min_price', input.min_price);
if (input.max_price) params.set('max_price', input.max_price);
if (input.in_stock) params.set('in_stock', '1');
const response = await fetch(`/api/products?${params}`);
const products = await response.json();
return {
total: products.length,
products: products.map(p => ({
id: p.id,
name: p.name,
price: p.price,
currency: 'EUR',
available: p.stock > 0,
image: p.image_url
}))
};
}
});El agente recibe datos JSON estructurados y puede razonar sobre ellos sin necesidad de interpretar HTML.
Ahora registremos un tool para añadir al carrito. Este caso es interesante porque requiere confirmación del usuario, ya que modifica estado:
navigator.modelContext.registerTool({
name: 'add_to_cart',
description: 'Añade un producto al carrito de compra del usuario. Requiere el ID del producto y opcionalmente la cantidad.',
inputSchema: {
type: 'object',
properties: {
product_id: {
type: 'integer',
description: 'ID del producto obtenido de search_products'
},
quantity: {
type: 'integer',
minimum: 1,
maximum: 99,
description: 'Cantidad a añadir. Por defecto 1.'
}
},
required: ['product_id']
},
execute: async (input, client) => {
const quantity = input.quantity ?? 1;
// Pedir confirmación al usuario antes de modificar el carrito
const confirmed = await client.requestUserInteraction(async () => {
return window.confirm(
`¿Añadir ${quantity} unidad(es) del producto #${input.product_id} al carrito?`
);
});
if (!confirmed) {
return { success: false, reason: 'El usuario canceló la operación' };
}
const response = await fetch('/api/cart/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
product_id: input.product_id,
quantity: quantity
})
});
const result = await response.json();
return {
success: true,
cart_total: result.total,
items_count: result.items_count
};
}
});requestUserInteraction: el humano en el loop
El objeto ModelContextClient que recibe el callback execute expone el método requestUserInteraction(). Este es el mecanismo que WebMCP proporciona para mantener al usuario informado y en control.
Cuando un tool necesita entrada del usuario o confirmación antes de ejecutar una acción sensible, puede pausar su ejecución y solicitar esa interacción:
execute: async (input, client) => {
// El agente se detiene aquí hasta que el usuario responde
const userChoice = await client.requestUserInteraction(async () => {
// Aquí puedes mostrar cualquier UI: modales, diálogos, formularios...
return showCustomDialog({
title: '¿Confirmar transferencia?',
message: `Se van a transferir ${input.amount}€ a ${input.recipient}`,
options: ['Confirmar', 'Cancelar']
});
});
if (userChoice === 'Cancelar') {
return { success: false, reason: 'Operación cancelada por el usuario' };
}
// Proceder con la operación
}Este patrón es fundamental en WebMCP. El estándar está diseñado explícitamente alrededor de flujos cooperativos donde el usuario y el agente colaboran, no flujos completamente autónomos.
Registrar tools de forma dinámica
Una de las ventajas de WebMCP frente a un servidor MCP tradicional es que los tools se pueden registrar y desregistrar dinámicamente según el estado de la aplicación:
// Registrar tools según la página actual
function registerToolsForPage(page) {
navigator.modelContext.clearContext();
if (page === 'catalog') {
navigator.modelContext.registerTool(searchProductsTool);
navigator.modelContext.registerTool(filterProductsTool);
}
if (page === 'cart') {
navigator.modelContext.registerTool(updateQuantityTool);
navigator.modelContext.registerTool(removeItemTool);
navigator.modelContext.registerTool(applyDiscountCodeTool);
}
if (page === 'checkout') {
navigator.modelContext.registerTool(selectShippingMethodTool);
navigator.modelContext.registerTool(reviewOrderTool);
}
}
// En una SPA, registrar tools al cambiar de ruta
router.afterEach((to) => {
registerToolsForPage(to.name);
});También puedes usar provideContext para registrar un conjunto completo de tools de una vez:
navigator.modelContext.provideContext({
tools: [
searchProductsTool,
getProductDetailsTool,
addToCartTool,
getCartTool
]
});La diferencia con registerTool es que provideContext limpia todos los tools previos antes de registrar los nuevos, mientras que registerTool los añade al conjunto existente.
WebMCP y Laravel: ejemplos prácticos
Si trabajas con Laravel y Blade, integrar WebMCP es directo ya que en la API Declarativa solo necesitas añadir atributos HTML a tus formularios existentes.
Formulario de contacto en Blade
<form
method="POST"
action="{{ route('contact.store') }}"
toolname="send_contact_message"
tooldescription="Envía un mensaje de contacto al equipo de soporte. Requiere nombre, email y mensaje."
toolautosubmit="true"
>
@csrf
<label for="name">Nombre</label>
<input
type="text"
id="name"
name="name"
required
toolparamdescription="Nombre completo del remitente"
value="{{ old('name') }}"
/>
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
required
toolparamdescription="Dirección de email para recibir la respuesta"
value="{{ old('email') }}"
/>
<label for="subject">Asunto</label>
<select
id="subject"
name="subject"
required
toolparamdescription="Categoría del mensaje: general, billing, technical, partnership"
>
<option value="general">Consulta general</option>
<option value="billing">Facturación</option>
<option value="technical">Soporte técnico</option>
<option value="partnership">Colaboraciones</option>
</select>
<label for="message">Mensaje</label>
<textarea
id="message"
name="message"
required
rows="5"
toolparamdescription="Contenido del mensaje. Sé lo más detallado posible."
>{{ old('message') }}</textarea>
<button type="submit">Enviar mensaje</button>
</form>Componente Blade reutilizable
Podemos crear un componente Blade que encapsule la lógica de WebMCP para reutilizarlo en cualquier formulario:
<?php
// app/View/Components/WebmcpForm.php
namespace App\View\Components;
use Illuminate\View\Component;
class WebmcpForm extends Component
{
public function __construct(
public string $toolname,
public string $tooldescription,
public string $action,
public string $method = 'POST',
public bool $autosubmit = false,
) {}
public function render()
{
return view('components.webmcp-form');
}
}<!-- resources/views/components/webmcp-form.blade.php -->
<form
method="{{ $method === 'GET' ? 'GET' : 'POST' }}"
action="{{ $action }}"
toolname="{{ $toolname }}"
tooldescription="{{ $tooldescription }}"
@if($autosubmit) toolautosubmit @endif
>
@unless($method === 'GET')
@csrf
@endunless
@if(in_array(strtoupper($method), ['PUT', 'PATCH', 'DELETE']))
@method($method)
@endif
{{ $slot }}
</form>Y su uso:
<x-webmcp-form
toolname="subscribe_newsletter"
tooldescription="Suscribe al usuario a la newsletter semanal sobre desarrollo web"
:action="route('newsletter.subscribe')"
:autosubmit="true"
>
<input
type="email"
name="email"
required
toolparamdescription="Email del suscriptor"
/>
<button type="submit">Suscribirme</button>
</x-webmcp-form>Responder al agente desde el frontend
La especificación de WebMCP no define ningún mecanismo para que el servidor distinga entre una petición de un agente y una de un humano. SubmitEvent.agentInvoked solo existe en el lado del cliente, en el evento de submit del navegador.
Por eso, la forma correcta de devolver datos estructurados al agente es interceptar el submit en JavaScript con event.respondWith(), evitando que la petición llegue al servidor como un submit tradicional:
document.querySelector('[toolname="search_articles"]').addEventListener('submit', async (event) => {
if (!event.agentInvoked) return;
event.preventDefault();
const formData = new FormData(event.target);
const params = new URLSearchParams(formData);
const response = await fetch(`/api/articles/search?${params}`, {
headers: { 'Accept': 'application/json' }
});
const data = await response.json();
event.respondWith(Promise.resolve(data));
});De este modo, cuando es un humano el formulario se envía normalmente y el Controller devuelve una vista. Cuando es un agente, el JavaScript intercepta, llama a un endpoint API y devuelve JSON estructurado al agente sin recargar la página.
Del lado de Laravel, simplemente expones un endpoint API estándar:
<?php
// routes/api.php
use App\Models\Article;
use Illuminate\Http\Request;
Route::get('/articles/search', function (Request $request) {
$validated = $request->validate([
'query' => ['required', 'string', 'max:255'],
'category' => ['nullable', 'string', 'in:laravel,php,javascript,devops'],
'from' => ['nullable', 'date'],
]);
$articles = Article::query()
->search($validated['query'])
->when($validated['category'] ?? null, fn ($q, $cat) => $q->where('category', $cat))
->when($validated['from'] ?? null, fn ($q, $date) => $q->where('published_at', '>=', $date))
->latest('published_at')
->limit(20)
->get();
return response()->json([
'total' => $articles->count(),
'articles' => $articles->map(fn ($a) => [
'id' => $a->id,
'title' => $a->title,
'excerpt' => $a->excerpt,
'category' => $a->category,
'published_at' => $a->published_at->toIso8601String(),
'url' => route('articles.show', $a),
]),
]);
});API Imperativa con Alpine.js
Si tu frontend usa Alpine.js (común en el ecosistema Laravel + Livewire), puedes registrar tools imperativos de forma organizada:
<div x-data="productCatalog()" x-init="registerWebMcpTools()">
<!-- Tu interfaz de catálogo -->
</div>
<script>
function productCatalog() {
return {
products: [],
filters: {},
registerWebMcpTools() {
if (!navigator.modelContext) return;
navigator.modelContext.registerTool({
name: 'filter_products',
description: 'Filtra los productos visibles por precio, valoración mínima y disponibilidad. La interfaz se actualiza automáticamente.',
inputSchema: {
type: 'object',
properties: {
min_rating: {
type: 'number',
minimum: 1,
maximum: 5,
description: 'Valoración mínima (1-5 estrellas)'
},
max_price: {
type: 'number',
minimum: 0,
description: 'Precio máximo en euros'
},
available_only: {
type: 'boolean',
description: 'Mostrar solo productos en stock'
}
}
},
annotations: { readOnlyHint: true },
execute: async (input) => {
this.filters = input;
await this.applyFilters();
return {
visible_products: this.products.length,
filters_applied: Object.keys(input)
};
}
});
},
async applyFilters() {
const params = new URLSearchParams(this.filters);
const response = await fetch(`/api/products?${params}`);
this.products = await response.json();
}
};
}
</script>WebMCP vs MCP: no son lo mismo
Es importante no confundir WebMCP con el Model Context Protocol de Anthropic aunque compartan nombre e ideas. Las diferencias son significativas:
MCP es un protocolo backend que usa JSON-RPC para comunicación cliente-servidor. Requiere un servidor dedicado (Python, Node, PHP...) que se ejecuta de forma independiente. Los agentes se conectan directamente al servidor sin necesidad de un navegador.
WebMCP opera enteramente en el navegador, del lado del cliente. No sigue la especificación JSON-RPC. Los tools se ejecutan como JavaScript en el contexto de la página, con acceso a la sesión del usuario, las cookies, el DOM y todo el estado de la aplicación.
Ambos son complementarios. Un e-commerce podría tener un servidor MCP para integraciones directas con plataformas como Claude o ChatGPT, y a la vez implementar WebMCP en su web para que los agentes de navegador interactúen con los flujos de compra del usuario.
Un caso interesante para desarrolladores Laravel: tu aplicación puede tener un servidor MCP con laravel/mcp para exponer herramientas a clientes de escritorio, y simultáneamente usar atributos WebMCP en tus vistas Blade para que agentes de navegador usen tus formularios de forma inteligente. Dos canales, misma lógica de negocio.
Seguridad y permisos
WebMCP se ejecuta dentro del modelo de seguridad existente del navegador (CORS, CSP, same-origin policy), lo que le da una base sólida. Pero introduce dos puntos de confianza que hay que tener en cuenta:
Cuando la web registra tools: está exponiendo información sobre sus capacidades al navegador y potencialmente a cualquier agente. El navegador debería solicitar permiso al usuario antes de compartir esa información.
Cuando un agente invoca un tool: la web recibe input no confiable en los parámetros, y el output puede contener información sensible del usuario. El navegador debería mediar esta comunicación y mostrar al usuario qué datos se están intercambiando.
Los tools heredan la sesión y permisos del usuario actual. Si el usuario está autenticado, el agente puede ejecutar acciones dentro de esa sesión. Esto es una ventaja (no necesitas un sistema de auth separado) pero también una responsabilidad.
Hay un problema de seguridad abierto que la especificación reconoce explícitamente: el aislamiento cross-origin. Si un usuario tiene dos pestañas abiertas y un agente tiene contexto de ambas, datos de una pestaña podrían filtrarse como parámetros a tools de otra. La especificación pide precaución aquí pero aún no tiene una solución definitiva.
Limitaciones actuales
WebMCP está en una fase muy temprana y tiene varias limitaciones que debes conocer:
Los tools solo se descubren cuando el navegador navega a la página y ejecuta el JavaScript. No hay un mecanismo de descubrimiento previo como podría ser un manifiesto. La especificación contempla esto como mejora futura, posiblemente integrando los tools en el Web App Manifest para PWAs.
Solo funciona en Chrome Canary con flags experimentales. No hay soporte en Firefox, Safari ni Edge por el momento, aunque la participación activa de Microsoft sugiere que Edge lo implementará pronto.
La API puede cambiar. Es un draft del community group, no un estándar W3C formal. Las interfaces, atributos y comportamientos podrían modificarse según el feedback de los desarrolladores.
No hay un sistema estándar para pasar imágenes u otros datos binarios en los resultados de los tools. Actualmente solo se trabaja bien con datos textuales y JSON.
Impacto en SEO y en el futuro de la web
WebMCP tiene implicaciones importantes para el futuro de la interacción web. Las descripciones de los tools se convierten en un nuevo tipo de contenido indexable. La calidad de esos textos determina si un agente elige tu servicio o el de un competidor, de forma similar a como las meta descriptions influyen en el CTR de los resultados de búsqueda.
Hoy un usuario busca "vuelos baratos a Nueva York" en Google. Mañana, le dirá a su agente que le encuentre y reserve el vuelo más barato. La web que tenga un tool search_flights bien descrito y funcional capturará esa interacción. La que no lo tenga, ni siquiera existirá en el espacio de decisión del agente.
Conclusión
WebMCP no reemplaza nada de lo que ya tienes. Tu web sigue funcionando exactamente igual para los usuarios humanos. Lo que hace es añadir una nueva capa de comunicación entre tu aplicación y los agentes de IA que cada vez son más prevalentes.
La barrera de entrada es mínima: dos atributos HTML en tus formularios. El coste de no hacerlo podría ser alto: que tu web sea invisible para la próxima generación de interfaces de usuario.
Si trabajas con Laravel, tus formularios Blade ya están a un paso de ser compatibles. Y si necesitas interacciones más complejas, la API Imperativa te da el control total con JavaScript que ya conoces.
El estándar está en pañales, sí. Pero que lo estén impulsando Google y Microsoft juntos, con Chrome ya ejecutándolo detrás de un flag, es una señal bastante clara de hacia dónde va la web.