El W3C propone WebMCP para construir webs pensadas tanto para humanos como para agentes

mar. 15, 2026

En BetaZetaDev nos gusta llegar antes de que algo sea tendencia, y una de nuestras misiones es traerte el futuro de la navegación en internet antes de que se convierta en presente. Y esto que tienes entre manos es exactamente un adelanto: lo hemos probado, lo hemos roto y te contamos cómo funciona para que no tengas que esperar a que todo el mundo hable de ello.

Si llevas un tiempo siguiendo el mundo de los agentes de IA, ya sabes que uno de los grandes problemas sin resolver es cómo estos agentes interactúan con páginas web arbitrarias. Hasta ahora había dos aproximaciones dominantes, ninguna ideal. La primera es la actuación sobre la interfaz: el agente simula clics, rellena formularios y pulsa botones como si fuera un usuario humano. Funciona, pero es lenta, frágil y se rompe con cualquier cambio de diseño. La segunda es la integración backend: expones tus funcionalidades mediante un servidor MCP o una API OpenAPI para que el agente las consuma. Funciona muy bien, pero requiere implementación y mantenimiento en el lado del servidor.

El pasado 27 de febrero de 2026, el W3C Web ML Community Group publicó un borrador que propone una tercera vía: WebMCP. Y lo interesante es que la lógica vive en el frontend, reutilizando código que ya existe.

¿Qué es WebMCP exactamente?

WebMCP es una propuesta del W3C Web ML Community Group que permite a una página web registrar funciones JavaScript como “herramientas” accesibles por agentes de IA que corran en el navegador. En lugar de que el agente adivine cómo navegar por la interfaz o de que el servidor exponga una API, la propia página dice: “oye, tengo estas capacidades, úsalas”.

El documento fue publicado como Draft Community Group Report el 27 de febrero de 2026. No es aún un estándar oficial de la W3C —hay que subrayarlo—, pero sí una propuesta formal con editores de peso: Brandon Walderman de Microsoft y Khushal Sagar y Dominic Farolino de Google. Puedes probarlo hoy mismo a través del Chrome Early Preview Program, de momento Firefox y Safari no lo soportan.

Cómo funciona: registrar herramientas en el navegador

La API es sorprendentemente sencilla. La página accede a navigator.modelContext y llama a registerTool() una vez por cada herramienta que quiere exponer. Cada herramienta tiene:

  • Un nombre identificativo
  • Una descripción en lenguaje natural (la usa el agente para decidir cuándo llamarla)
  • Un esquema de entrada en formato JSON Schema
  • Una función execute que contiene la lógica real, que recibe el input del agente y un objeto client

  if ("modelContext" in navigator) {
    navigator.modelContext.registerTool({
      name: "add-item",
      description: "Añade un nuevo elemento a la lista",
      inputSchema: {
        type: "object",
        properties: {
          name: { type: "string", description: "Nombre del elemento" },
          description: { type: "string", description: "Descripción opcional" }
        },
        required: ["name"]
      },
      annotations: { readOnlyHint: false },
      execute(input) {
        addItem(input.name, input.description);
        // El formato de respuesta debe ser un objeto con array 'content'
        return {
          content: [{ type: "text", text: `"${input.name}" añadido correctamente.` }]
        };
      }
    });
  }
   

El agente descubre las herramientas disponibles al cargar la página, las invoca con los parámetros adecuados y recibe los resultados. Lo más elegante del diseño es que la función execute llama directamente al código JavaScript existente en la página. No hay capa de traducción, no hay servidor intermedio, no hay protocolo de transporte.

Cabe mencionar que también existe unregisterTool(name) para eliminar herramientas dinámicamente —útil si el estado de la página cambia y ciertas acciones ya no están disponibles.

Human-in-the-Loop: el diseño que pone al humano primero

Uno de los aspectos más interesantes —y prudentes— de la propuesta es cómo gestiona las acciones potencialmente destructivas. Antes de que un agente pueda realizar algo irreversible (hacer una compra, enviar un mensaje, borrar datos), la API ofrece requestUserInteraction(), que interrumpe la ejecución del agente y ejecuta un callback donde el desarrollador define qué interacción pedir al usuario.


  execute: async (input, client) => {
    // client.requestUserInteraction recibe un callback que realiza la interacción
    await client.requestUserInteraction(async () => {
      return confirm(`¿Confirmas la compra del producto ${input.productId}?`);
    });
    return await purchase(input.productId, input.quantity);
  }
   

Fíjate en que requestUserInteraction no está en navigator.modelContext sino en client, el segundo parámetro que recibe la función execute. Este objeto representa al agente que está ejecutando la herramienta, y delegar la confirmación en él es lo que cierra el bucle de control entre el agente y el usuario.

Este diseño parte de una premisa sensata: los agentes son herramientas potentes, pero el control final debe permanecer en manos del usuario. No es automatización ciega, hablamos de automatización supervisada.

WebMCP vs. MCP: complementarios, no competidores

Es fácil confundirlos porque comparten nombre y filosofía, pero son capas diferentes del ecosistema:

Aspecto MCP (Model Context Protocol) WebMCP
Dónde corre Servidor backend Navegador del usuario
Transporte JSON-RPC + stdio/HTTP Sin capa de transporte
Primitivas Tools, Resources, Prompts Solo Tools
Implementación Requiere servidor Reutiliza código frontend
Descubrimiento Configuración explícita en el cliente Al navegar a la página
Casos de uso APIs, bases de datos, herramientas del sistema Interacción con la UI actual

WebMCP se alinea con el formato de “tools” de MCP intencionalmente, lo que facilita que ambos convivan en el mismo ecosistema de agentes. La diferencia clave es que WebMCP no necesita servidor: el navegador actúa como mediador y la página es quien decide qué expone.

Ejemplo práctico: una lista de tareas controlada por agente

La mejor forma de entender WebMCP no es leer la especificación sino ver el código completo de una página real que lo implementa. Vamos a construir desde cero una pequeña app de gestión de tareas que un agente de IA puede manejar por completo usando lenguaje natural.

Requisitos previos

Al ser una tecnología experimental, Chrome impone restricciones de seguridad que debes cumplir para que el ejemplo funcione:

  1. Instala Chrome Canary: Necesitas una versión de desarrollo reciente (v146+). Puedes descargarlo en su página oficial.

  2. Activa los flags: Ve a chrome://flags/ y habilita los tres siguientes (busca cada uno por su nombre o por el identificador):

    • Experimental Web Platform features (#enable-experimental-web-platform-features)
    • WebMCP support in DevTools (#devtools-webmcp-support)
    • WebMCP for testing (#enable-webmcp-testing)

    Reinicia el navegador tras activarlos.

  3. Instala la extensión: Descarga e instala Model Context Tool Inspector, que actúa como agente para invocar las herramientas registradas.

  4. Entorno seguro: La página debe servirse por HTTPS o vía localhost. No funcionará si abres el archivo .html directamente desde el disco (file://). Desde la carpeta donde tengas el archivo, ejecuta:


  python3 -m http.server 8080
   

Luego abre http://localhost:8080 en Chrome Canary.

La web de ejemplo

La página es un simple gestor de tareas en HTML + JavaScript puro, sin dependencias externas. Puedes copiar este código, guardarlo como index.html y servirlo en local. Expone cuatro herramientas:

  • listar-tareas — devuelve las tareas actuales con su estado
  • crear-tarea — crea una nueva tarea
  • completar-tarea — marca una tarea como completada
  • eliminar-tarea — borra una tarea permanentemente

  <!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Task Manager — WebMCP Demo</title>
    <style>
      body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; line-height: 1.6; background-color: #f7fafc; }
      .container { background: white; padding: 30px; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
      h1 { border-bottom: 2px solid #edf2f7; padding-bottom: 15px; color: #2d3748; margin-top: 0; }
      ul { list-style: none; padding: 0; margin: 20px 0; }
      li { background: #fff; margin-bottom: 12px; padding: 15px; border-radius: 8px; display: flex; justify-content: space-between; border: 1px solid #e2e8f0; }
      .status-box { margin-top: 30px; padding: 15px; border-radius: 8px; font-size: 0.9rem; border: 1px solid transparent; }
      .detected { background-color: #e6fffa; border-color: #38b2ac; color: #234e52; }
      .not-detected { background-color: #fff5f5; border-color: #feb2b2; color: #742a2a; }

      /* ── MCP tool action animations ── */
      li { transition: background 0.3s, border-color 0.3s; }

      @keyframes glowCreate {
        0%   { box-shadow: 0 0 0 0 #48bb78; background: #f0fff4; border-color: #48bb78;
              transform: translateY(-6px); opacity: 0; }
        20%  { transform: translateY(0); opacity: 1; }
        40%  { box-shadow: 0 0 22px 6px rgba(72,187,120,0.55); }
        100% { box-shadow: none; background: #fff; border-color: #e2e8f0; }
      }
      @keyframes glowComplete {
        0%   { box-shadow: 0 0 0 0 #4299e1; }
        25%  { box-shadow: 0 0 22px 6px rgba(66,153,225,0.55); background: #ebf8ff; border-color: #4299e1; }
        100% { box-shadow: none; background: #ebf8ff; border-color: #bee3f8; }
      }
      @keyframes glowDelete {
        0%   { box-shadow: none; opacity: 1; max-height: 80px; margin-bottom: 12px; padding: 15px; }
        20%  { box-shadow: 0 0 22px 6px rgba(252,129,129,0.55); background: #fff5f5; border-color: #fc8181; opacity: 0.7; }
        65%  { opacity: 0; max-height: 80px; }
        100% { opacity: 0; max-height: 0; margin-bottom: 0; padding: 0 15px; border-width: 0; }
      }
      @keyframes glowList {
        0%   { box-shadow: none; }
        35%  { box-shadow: 0 0 16px 4px rgba(236,201,75,0.6); background: #fffff0; border-color: #ecc94b; }
        100% { box-shadow: none; background: #fff; border-color: #e2e8f0; }
      }

      .anim-create   { animation: glowCreate  2.2s ease-out forwards; }
      .anim-complete { animation: glowComplete 2.2s ease-out forwards; }
      .anim-delete   { animation: glowDelete   1.8s ease-out forwards; }
      .anim-list     { animation: glowList     1.4s ease-out forwards; }
    </style>
  </head>
  <body>
    <div class="container">
      <h1>My tasks</h1>
      <ul id="list"></ul>
      <div id="status" class="status-box">Checking WebMCP...</div>
    </div>

    <script>
      let tasks = [
        { id: 1, text: "Read the WebMCP specification", done: false },
        { id: 2, text: "Set up local demo", done: false },
      ];

      function renderItem(t) {
        const li = document.createElement("li");
        li.dataset.id = t.id;
        li.innerHTML = `<span style="text-decoration:${t.done ? 'line-through' : 'none'}">
            <strong>[${t.id}]</strong> ${t.text}
          </span>
          <span>${t.done ? '✅' : '⏳'}</span>`;
        return li;
      }

      function render() {
        const ul = document.getElementById("list");
        ul.innerHTML = "";
        tasks.forEach(t => ul.appendChild(renderItem(t)));
      }
      render();

      function getItemEl(id) {
        return document.querySelector(`#list [data-id="${id}"]`);
      }

      function pulseItem(el, cls) {
        el.classList.remove(cls);
        void el.offsetWidth; // force reflow so animation restarts
        el.classList.add(cls);
      }

      const createResponse = (txt) => ({ content: [{ type: "text", text: txt }] });
      const statusEl = document.getElementById("status");

      if ("modelContext" in navigator) {
        statusEl.className = "status-box detected";
        statusEl.innerHTML = "<strong>✅ WebMCP detected:</strong> The agent can interact with this page.";

        navigator.modelContext.registerTool({
          name: "list-tasks",
          description: "Returns all tasks with their ID, text and status (done or pending).",
          inputSchema: { type: "object", properties: {} },
          annotations: { readOnlyHint: true },
          execute() {
            const items = document.querySelectorAll("#list li");
            items.forEach((el, i) => {
              setTimeout(() => {
                pulseItem(el, "anim-list");
                el.addEventListener("animationend", () => el.classList.remove("anim-list"), { once: true });
              }, i * 80);
            });
            const txt = tasks.map(t => `[${t.id}] ${t.text} (${t.done ? 'Done' : 'Pending'})`).join('\n');
            return createResponse(txt || "No tasks.");
          }
        });

        navigator.modelContext.registerTool({
          name: "create-task",
          description: "Creates a new pending task in the list.",
          inputSchema: {
            type: "object",
            properties: { text: { type: "string", description: "Description of the task to create" } },
            required: ["text"]
          },
          execute(input) {
            const n = { id: Math.max(0, ...tasks.map(t => t.id)) + 1, text: input.text, done: false };
            tasks.push(n);
            const li = renderItem(n);
            document.getElementById("list").appendChild(li);
            requestAnimationFrame(() => requestAnimationFrame(() => {
              pulseItem(li, "anim-create");
              li.addEventListener("animationend", () => li.classList.remove("anim-create"), { once: true });
            }));
            return createResponse(`Task "${input.text}" added with ID ${n.id}.`);
          }
        });

        navigator.modelContext.registerTool({
          name: "complete-task",
          description: "Marks a task as done given its ID.",
          inputSchema: {
            type: "object",
            properties: { id: { type: "number", description: "ID of the task to complete" } },
            required: ["id"]
          },
          async execute(input, client) {
            const targetId = Number(input.id); // Needed: agents sometimes pass IDs as strings
            const t = tasks.find(x => x.id === targetId);
            if (!t) return createResponse(`Error: no task found with ID ${targetId}.`);

            if (client?.requestUserInteraction) {
              await client.requestUserInteraction(async () => {
                if (!confirm(`Mark as done: "${t.text}"?`)) throw "Cancelled by user";
              });
            }
            t.done = true;
            const el = getItemEl(targetId);
            if (el) {
              el.querySelector("span:first-child").style.textDecoration = "line-through";
              el.querySelector("span:last-child").textContent = "✅";
              pulseItem(el, "anim-complete");
              el.addEventListener("animationend", () => el.classList.remove("anim-complete"), { once: true });
            }
            return createResponse(`Task [${targetId}] marked as done.`);
          }
        });

        navigator.modelContext.registerTool({
          name: "delete-task",
          description: "Permanently deletes a task given its ID.",
          inputSchema: {
            type: "object",
            properties: { id: { type: "number", description: "ID of the task to delete" } },
            required: ["id"]
          },
          async execute(input, client) {
            const targetId = Number(input.id);
            const index = tasks.findIndex(x => x.id === targetId);
            if (index === -1) return createResponse(`Error: no task found with ID ${targetId}.`);

            const { text } = tasks[index];
            if (client?.requestUserInteraction) {
              await client.requestUserInteraction(async () => {
                if (!confirm(`Permanently delete "${text}"? This action cannot be undone.`)) throw "Cancelled by user";
              });
            }
            const el = getItemEl(targetId);
            if (el) {
              pulseItem(el, "anim-delete");
              el.addEventListener("animationend", () => {
                el.remove();
                tasks.splice(tasks.findIndex(x => x.id === targetId), 1);
              }, { once: true });
            } else {
              tasks.splice(index, 1);
            }
            return createResponse(`Task "${text}" deleted.`);
          }
        });

      } else {
        statusEl.className = "status-box not-detected";
        statusEl.innerHTML = "<strong>❌ WebMCP not detected.</strong> Make sure you are using Chrome Canary with the flag enabled and serving the page from localhost.";
      }
    </script>
  </body>
  </html>
   

Cómo interactuar con la página

Al abrir esta página en Chrome Canary con WebMCP habilitado, la extensión Model Context Tool Inspector detecta automáticamente las herramientas registradas. Tienes dos formas de probar el sistema:

1. Interacción Manual (Modo Debug)

En la parte inferior del panel de la extensión, verás un selector de Tool y un campo para Input Arguments. Esto es ideal para que el desarrollador verifique que las funciones responden correctamente. Puedes seleccionar crear-tarea, pasarle un JSON como {"texto": "Comprar pan"} y pulsar Execute Tool para ver el resultado técnico.

2. Interacción mediante lenguaje natural (User Prompt)

Aquí es donde WebMCP muestra su verdadero potencial. Si configuras una Gemini API Key en la extensión, podrás usar el campo User Prompt para hablarle a la página:

  • "¿Qué tareas tengo pendientes?" → el agente llama a listar-tareas y devuelve el listado.
  • “Añade una tarea para revisar el PR del lunes” → llama a crear-tarea, y la UI se actualiza al instante.
  • “Marca como completada la tarea 1” → llama a completar-tarea, pidiendo confirmación al usuario.
  • “Elimina todas las tareas completadas” → el agente razona y llama a eliminar-tarea secuencialmente por cada una.

En este modo, tú no le dices al agente qué función llamar; simplemente le dices qué quieres conseguir. El agente analiza las descripciones de las herramientas registradas y decide cuál(es) debe invocar para cumplir tu petición.

⚠️ Nota crítica sobre los nombres: Al utilizar la API de Gemini, los nombres de las herramientas deben ser estrictamente alfanuméricos (ASCII), permitiendo solo guiones, puntos o guiones bajos. Por eso hemos usado crear-tarea en lugar de añadir-tarea (véase la ñ); el uso de caracteres como la ñ o las tildes provocará un error de validación en la API (INVALID_ARGUMENT).

Por qué funciona así y no de otra forma

Fíjate en algo fundamental: el agente no sabe que hay una <ul> en el HTML, ni le importa. No está analizando el DOM para buscar botones ni simulando clics. Lo único que conoce es el contrato de las herramientas (nombres, descripciones y esquemas JSON). Esto hace que la integración sea robusta: puedes rediseñar toda la interfaz visual de tu web y, mientras no cambies el contrato de las herramientas WebMCP, el agente seguirá funcionando perfectamente.

La clave del ejemplo está en el uso de requestUserInteraction() en las operaciones que modifican estado. Sin esa llamada, completar-tarea se ejecutaría sin que el usuario pudiera intervenir. Esto es lo que diferencia a WebMCP de un simple script de automatización: el diseño asume que el humano sigue en el bucle para las decisiones que importan.

Para una acción de solo lectura como listar-tareas, no tiene sentido pedir confirmación —de ahí el readOnlyHint: true en sus annotations. Para modificar datos, sí. Esta distinción debe venir del desarrollador de la página —la especificación no la impone automáticamente—, lo que convierte el diseño de herramientas en una decisión de producto, no solo técnica.

Hay además un detalle práctico que descubrimos al probarlo: la conversión de tipos es crítica. Los agentes a veces pasan IDs como strings aunque el esquema declare type: "number". Usar Number(input.id) explícitamente evita que las herramientas fallen silenciosamente por una comparación "1" === 1.

Problemas actuales: aún es un borrador

Antes de emocionarnos demasiado, hay que ser honestos sobre el estado actual de la propuesta:

  • La especificación de seguridad está marcada como TODO. Esto es preocupante para una API que permite a agentes externos ejecutar código en nombre del usuario. Habrá que ver cómo se resuelve.
  • No funciona en modo headless. Si el agente no tiene interfaz de navegador visible, no puede usar WebMCP. Esto limita su uso en pipelines de automatización sin interfaz de usuario.
  • El descubrimiento solo ocurre al navegar a la página. El agente no puede “explorar” qué herramientas existen hasta que el usuario carga esa URL concreta.
  • Solo Chrome por ahora. Firefox y Safari no lo soportan aún, lo que limita drásticamente el alcance real hasta que haya adopción más amplia.

Dicho esto, que Microsoft y Google estén editando conjuntamente la especificación es una señal de que el interés es serio.

Por qué deberías seguirlo de cerca

La forma en que los agentes interactúan con la web está cambiando. Durante años, los agentes dependieron de scraping y automatización de UI —técnicas que funcionan pero que son inherentemente frágiles, como intentar conducir mirando por el retrovisor.

WebMCP apunta a algo diferente: webs que se diseñan para ser usadas tanto por humanos como por agentes. No como dos interfaces separadas, sino como una sola capa de lógica de negocio que ambos pueden invocar. Si esta propuesta madura y gana adopción, cambiará el modo en que pensamos la arquitectura frontend: ya no solo diseñamos componentes visuales, también diseñamos herramientas invocables.

Esto tiene implicaciones prácticas ya visibles:

  • Permisos por herramienta y auditoría: necesitarás pensar qué puede hacer un agente en tu web y qué no, con el mismo rigor con el que piensas los permisos de una API
  • “Webs amigables para agentes”: al igual que la accesibilidad o el SEO, la agent-friendliness podría convertirse en una ventaja competitiva real
  • Reutilización de lógica frontend: el código que ya funciona para tus usuarios puede funcionar para los agentes sin reescribirse

Conclusión

Hasta ahora, o enseñabas al agente a hacer clic como un humano, o montabas un servidor que expusiera tus capacidades. WebMCP añade una tercera opción: la página entrega sus propias herramientas, directamente desde el navegador, reutilizando el código JavaScript que ya tienes.

Sigue siendo un borrador —con problemas de seguridad sin resolver y soporte limitado a Chrome—, pero la dirección es clara y los actores que hay detrás son lo suficientemente relevantes como para tomárselo en serio. El ejemplo de la lista de tareas que hemos visto apenas araña la superficie: imagina una tienda online que expone buscar-producto, añadir-al-carrito o consultar-disponibilidad, o una app de banca que expone ver-saldo y hacer-transferencia con confirmación obligatoria. El patrón escala a cualquier dominio.

Si te interesa seguir el desarrollo de la propuesta, puedes hacerlo en el repositorio oficial: webmachinelearning/webmcp.

¡Happy Hacking!

¿Necesitas ayuda?

En BetaZetaDev transformamos ideas en soluciones digitales reales. Más de 10 años desarrollando aplicaciones móviles, web, automatizaciones y sistemas personalizados que impactan a miles de usuarios. Desde el concepto hasta el despliegue, creamos tecnología que resuelve problemas específicos de tu negocio con código limpio, arquitecturas escalables y experiencia probada.

Hablemos de tu proyecto

Artículos relacionados

Quizá te puedan interesar