WebMCP: convirtiendo tu sitio en una herramienta para agentes

Los agentes de IA cada vez están más integrados con sitios web. Hoy, cuando un agente quiere "usar" un sitio, tiene básicamente dos caminos: actuar como un usuario más (clicks, scrolls, leer screenshots y el DOM) o consumir un MCP server que la empresa publica aparte. El primero es frágil y lento; el segundo obliga a duplicar lógica que muchas veces ya vive en el frontend.
WebMCP intenta cerrar esa brecha. Es una propuesta del Web Machine Learning Community Group (con personas de Microsoft y Google) que define una API JavaScript donde el propio sitio declara sus tools y deja que el agente del navegador las invoque directamente. La idea, en esencia, es: tu pestaña abierta es el MCP server.
MCP vs WebMCP
Si ya venís usando MCP, conviene aclarar algo desde el principio: WebMCP no reemplaza a MCP, lo complementa. Atacan problemas diferentes.
| Aspecto | MCP | WebMCP |
|---|---|---|
| Propósito | Expone datos y acciones a agentes en cualquier lado | Vuelve un sitio vivo listo para interactuar con un agente |
| Implementación | Servidor (Python, Node, Rust) sobre JSON-RPC | API del browser invocada desde JavaScript o HTML |
| Ciclo de vida | Persistente, corre en background | Efímero, atado a la pestaña abierta |
| Conectividad | Global (desktop, mobile, cloud) | Solo el agente del navegador |
| Relación con la UI | Headless; el agente arma la experiencia | El agente es un "invitado" en tu plataforma |
| Auth y estado | Hay que reimplementarlos en el server | Reutiliza sesión, cookies y DOM del usuario en el sitio |
| Resiliencia | Tools acopladas a contratos del backend | Tools acopladas a la lógica, no al diseño visual |
La consecuencia práctica es que un agente puede usar MCP para tareas pesadas en backend (búsquedas, RAG, jobs largos) y WebMCP para todo lo que ocurre en la pestaña que el usuario tiene a la vista: filtrar resultados, completar un carrito, editar un diseño, marcar un pedido como devuelto. La recomendación oficial es pensarlos como socios, no como competidores: MCP como capa de servicio fundacional y WebMCP para la interacción contextual mientras el usuario tiene tu sitio abierto.
La API imperativa: document.modelContext
El núcleo de la propuesta es modelContext. Lo importante a destacar es que se registra un objeto con tres piezas: nombre, descripción en lenguaje natural y un inputSchema (JSON Schema, igual que MCP), más el execute() que termina haciendo el trabajo.
Estándar vs. implementación: vale aclarar de entrada que hay un desfase entre el explainer del WICG (el estándar en discusión) y lo que Chrome implementa hoy. El explainer expone la API en
navigator.modelContext, pero la implementación de Chrome la movió adocument.modelContext(y dejónavigator.modelContextdeprecada en Chrome 150). Como este post es para experimentar hoy, usodocument.modelContext, pero tené en cuenta que parte de lo que sigue (annotations,exposedTo,toolchange) todavía es específico de Chrome y no está en el explainer.
document.modelContext.registerTool({
name: 'add-todo',
description: 'Add a new todo item to the list',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string', description: 'The text of the todo item' },
},
required: ['text'],
},
execute: async ({ text }) => {
addTodoToState(text);
renderList();
return `Added "${text}". The list now has ${todos.length} items.`;
},
});
El execute puede devolver directamente un string, o el formato de content de MCP ({ content: [{ type: 'text', text: '...' }] }) cuando necesitás respuestas multimodales.
Dos formas de declarar tools
El explainer define dos métodos con roles distintos:
registerTool(tool, options): agrega un tool sin tocar los demás.provideContext({ tools }): reemplaza todo el set de tools de una sola llamada. Es ideal cuando el estado de la app cambia y querés actualizar varios tools juntos (por ejemplo, cuando el usuario se loguea y aparecen tools que requieren sesión).
// Reemplazar todo el set según el estado de la app
document.modelContext.provideContext({
tools: isLoggedIn ? [searchTool, checkoutTool] : [searchTool],
});
Para dar de baja un tool, registerTool acepta un AbortController, lo que es muy cómodo en SPAs cuando una vista se desmonta:
const controller = new AbortController();
document.modelContext.registerTool(editDesignTool, {
signal: controller.signal,
});
// Al desmontar la vista, el tool deja de estar disponible:
controller.abort();
Algunos detalles que vale la pena notar:
- Reuso de código: el
executepuede llamar a las mismas funciones que ya usa el botón o el form del sitio. No hace falta duplicar lógica. - Tools "dinámicos": registrá y desregistrá tools según el estado. Un editor puede exponer
editDesignsolo cuando hay un documento abierto. - Evento
toolchange:document.modelContextemitetoolchangecuando cambia el set de tools disponibles, útil para reflejarlo en la UI.
Annotations: dándole pistas al agente
El tool acepta annotations opcionales que ayudan al agente (y al browser) a tratar la herramienta correctamente:
document.modelContext.registerTool({
name: 'get-cart',
description: 'Returns the current contents of the shopping cart.',
inputSchema: { type: 'object', properties: {} },
annotations: {
readOnlyHint: true, // El tool no modifica estado
untrustedContentHint: true, // El output puede contener datos no confiables
},
execute: async () => JSON.stringify(getCart()),
});
readOnlyHint marca tools que solo leen (no requieren confirmación), y untrustedContentHint avisa que la respuesta puede traer contenido no confiable (por ejemplo, reseñas de usuarios), algo clave para mitigar prompt injection.
Confirmación del usuario con requestUserInteraction
Una de las cosas más interesantes de la propuesta es que reconoce explícitamente que algunos tools son destructivos o sensibles (comprar, borrar, enviar). Para esos casos, el segundo argumento del execute es un agent que expone requestUserInteraction(), que pausa la ejecución del agente hasta que el usuario confirma:
document.modelContext.registerTool({
name: 'buy-product',
description: 'Purchase a product given its unique product_id.',
inputSchema: {
type: 'object',
properties: {
product_id: { type: 'string', description: 'Product identifier.' },
},
required: ['product_id'],
},
async execute({ product_id }, agent) {
const confirmed = await agent.requestUserInteraction(async () => {
// Acá podés mostrar tu propio modal en vez de un confirm nativo
return confirm(`¿Comprar el producto ${product_id}?`);
});
if (!confirmed) {
throw new Error('Purchase cancelled by user.');
}
await executePurchase(product_id);
return `Producto ${product_id} comprado correctamente.`;
},
});
El patrón es importante: el agente pide ejecutar la acción, el sitio decide cuándo necesita confirmación humana, y el browser garantiza que ese pedido de input no quede secuestrado por el agente. Y como los tools corren visiblemente en tu página, el usuario ve lo que pasa y mantiene la confianza, todo sin negociarlo a nivel de protocolo.
Tools cross-origin e iframes
Por defecto, los tools quedan acotados al origen que los registra. Si tenés un iframe que necesita exponer tools al documento principal, hay que habilitarlo explícitamente con Permissions Policy:
<iframe src="https://widget.example" allow="tools"></iframe>
Y desde el lado del que registra, podés limitar a qué orígenes se exponen los tools con exposedTo:
document.modelContext.registerTool(tool, {
exposedTo: ['https://trusted.com', 'https://partner.org'],
});
La API declarativa: tools desde HTML
Mucho de lo que un usuario hace en un sitio ya está expresado en <form>. Reescribirlo todo como JavaScript para exponerlo como tool sería tedioso, así que la propuesta incluye una API declarativa que convierte forms en tools automáticamente.
<form
toolname="search-flights"
tooldescription="Search flights between two cities on a given date"
toolautosubmit
>
<input
type="text"
name="origin"
toolparamdescription="IATA code of the origin airport (e.g. EZE)"
required
/>
<input
type="text"
name="destination"
toolparamdescription="IATA code of the destination airport (e.g. MAD)"
required
/>
<input
type="date"
name="departure"
toolparamdescription="Departure date in YYYY-MM-DD format"
required
/>
<button type="submit">Buscar</button>
</form>
El browser "compila" ese form a un input schema equivalente al que escribirías a mano. Algunos atributos clave:
toolnameytooldescription: lo mismo quenameydescriptionen la versión imperativa.toolparamdescriptionen cada input: descripción en lenguaje natural del campo.toolautosubmit: si está presente, el agente puede submitear el form sin que el usuario lo apruebe. Si no está, el browser pone foco en el botón de submit y deja que el usuario revise.
Para devolverle algo al agente sin navegar, el submit event handler tiene un nuevo respondWith:
form.addEventListener('submit', event => {
if (!event.agentInvoked) return; // Submit "humano", comportamiento normal
event.preventDefault();
event.respondWith(
fetch('/api/flights', { method: 'POST', body: new FormData(form) })
.then(r => r.json())
.then(data => ({
content: [{ type: 'text', text: JSON.stringify(data) }],
}))
);
});
El flag agentInvoked es importante porque te permite distinguir entre un submit hecho por el usuario y uno hecho por el agente, y reaccionar distinto (por ejemplo, evitar redirecciones en el segundo caso).
También hay eventos (toolactivated cuando el agente termina de completar el form, toolcancel cuando se cancela o se hace reset()) y pseudoclases CSS pensadas para mostrar visualmente que un agente está actuando sobre un form: :tool-form-active (sobre el <form>) y :tool-submit-active (sobre el botón de submit). Útil para no perder al usuario cuando los inputs se llenan "solos".
Detección y fallback
Como cualquier API web emergente, lo primero es chequear soporte antes de usarla. Esto deja a la app funcionando para cualquier usuario, con o sin agente integrado al browser.
if ('modelContext' in document) {
document.modelContext.registerTool(addTodoTool);
}
Vale la pena pensar el sitio en dos modos: el "modo humano" (siempre disponible) y un "modo asistido" que se activa cuando hay un agente conectado.
Buenas prácticas
Los docs de Chrome dedican una sección entera a cómo diseñar tools, y vale la pena resumirlo porque es donde está la diferencia entre un tool que el agente usa bien y uno que lo confunde.
- Un tool, una función. Evitá tools que se solapan; si dos hacen cosas parecidas, el agente no sabe cuál elegir.
- Registro estático por defecto. Cada tool ocupa espacio en el context window del modelo. Menos tools = respuestas más rápidas y menos confusión. Registrá dinámicamente solo cuando el estado lo amerita.
- Nombres que distingan ejecutar de iniciar.
create-event(acción inmediata) no es lo mismo questart-event-creation-process(abre un form). El verbo importa. - Descripciones en positivo. Describí lo que el tool hace, no lo que no hace. "Crea un evento de calendario para una fecha y hora" funciona mejor que "no uses esto para el clima".
- Minimizá el cómputo del modelo. Aceptá el input crudo del usuario en vez de pedirle al agente que transforme o calcule. Para un rango "11:00 a 15:00", aceptá el string tal cual.
- Tipos específicos y lenguaje natural en el schema. Declará
string,numberoenum, y preferíshipping="Express"antes queshipping_id=1. - Validá estricto en código, flexible en schema. Las restricciones del schema ayudan pero no están garantizadas. Agregá errores descriptivos en tu código para que el modelo se auto-corrija y reintente con parámetros válidos.
- Sincronizá la UI después de cada tool. El agente suele mirar la interfaz para planear el próximo paso. Si el estado no se refleja, se pierde.
- Fallá con gracia. Ante un rate limit, devolvé un error con sentido o sugerí completar la tarea manualmente, en vez de fallar en silencio.
Trampa común con React: registrar tools dentro de un componente puede dispararse dos veces por el doble montaje de strict mode. Usá
useEffectcon un guard (useRef) y abortá el signal en el cleanup. Y si usásrespondWith()en la API declarativa, llamalo antes delawaitdel trabajo asíncrono; si lo hacés después, el canal de respuesta ya se cerró.
Evals: testear tools que hablan con un modelo
Un punto que los docs remarcan bien: testear tools que dependen de un LLM no es como testear funciones deterministas. El mismo input puede producir muchísimas respuestas distintas, así que además de los unit tests de toda la vida, conviene escribir evals que verifiquen que el modelo entiende y usa bien tus tools.
Las cosas que suelen fallar:
- El agente elige el tool equivocado o se saltea un paso.
- Ejecuta los tools en el orden incorrecto.
- Los llama con argumentos mal armados.
Un eval, en su forma más simple, define un input del usuario y la llamada esperada:
{
"messages": [{ "role": "user", "content": "I'd like a small pizza." }],
"expectedCall": [
{
"functionName": "set_pizza_size",
"arguments": { "size": "Small" }
}
]
}
La recomendación es no parchear errores puntuales del modelo con reglas rígidas, sino abstraer y ajustar el tool (por ejemplo, hacer un campo opcional y dejar que el agente le pregunte al usuario). Y para validar resultados subjetivos, combinar checks por código con técnicas de LLM-as-a-judge.
Estado actual y dónde probarlo
WebMCP es todavía una propuesta de estándar, pero Chrome ya lo está implementando. Los pasos para probarlo hoy:
- Habilitar el flag
chrome://flags/#enable-webmcp-testingen Chrome para desarrollo local. Hay un origin trial planeado para Chrome 149. - Sumarse al Early Preview Program para acceder a documentación, demos y novedades de la API.
- Probar las demos del repo GoogleChromeLabs/webmcp-tools, que cubren casos imperativos, declarativos e híbridos sobre apps reales (booking de hotel, e-commerce, búsqueda de vuelos, etc.).
El mismo repo incluye dos herramientas útiles para desarrollo:
- Model Context Tool Inspector: extensión de Chrome que muestra qué tools expone la página y permite invocarlos a mano para debug.
- WebMCP Evals CLI: CLI para correr evals sobre tools y validar que un LLM las invoque correctamente.
Consideraciones a tener en cuenta
WebMCP abre escenarios interesantes pero también trae cuestiones sin resolver:
- Confianza en los tools: el agente decide qué tool usar a partir de su descripción. Una página podría intentar engañarlo con descripciones manipuladas (prompt injection por la puerta de adelante), y el output de un tool puede traer contenido no confiable. Por eso existen hints como
untrustedContentHint, pero la mediación fina entre browser y agente sigue evolucionando. - Modelo de permisos: el control cross-origin ya tiene primitivas (
allow="tools",exposedTo), pero el modelo de consentimiento del usuario sobre qué sitios pueden registrar y ejecutar tools todavía se está definiendo. - API en movimiento: como vimos con el salto de
navigator.modelContextadocument.modelContext, la superficie cambia entre versiones de Chrome. Hoy es para prototipar, no para producción estable. - Discoverability: el agente solo ve los tools al cargar la página. Es posible que en el futuro aparezca un manifest declarativo para listar tools sin tener que navegar.
Ninguno de estos puntos es bloqueante para empezar a experimentar, pero sí son cosas a tener en mente si pensás soportar la API en producción.
Conclusión
WebMCP es una de esas propuestas que, si gana tracción, cambia bastante cómo pensamos la integración entre web y agentes. En vez de empujar a cada empresa a publicar y mantener un MCP server paralelo a su sitio, propone aprovechar el frontend que ya existe y extenderlo con un par de tools bien definidos.
Para quienes ya estamos trabajando con MCP del lado del backend, la combinación es particularmente potente: MCP para lógica de negocio y datos, WebMCP para todo lo que ocurre en la pestaña abierta y necesita el contexto del usuario. Es un buen momento para meterse, probar las demos y empezar a pensar qué partes de tus apps tendría sentido exponer como tools.

