WebMCP WebMCP

Tutorial de WebMCP: expón tu web a los agentes con un caso real

Tutorial de WebMCP con un caso real: registrar herramientas por página y por sesión, qué no exponer, anotaciones y cómo probarlo con Playwright y CDP.

Por Actualizado el

Datos a 3 de octubre de 2026

Este tutorial de WebMCP parte de un caso real: cómo desarrollomcp.com expone su temario, su blog y el progreso de cada alumno como herramientas para agentes del navegador, qué deja fuera a propósito y cómo lo probamos. WebMCP es la API del navegador con la que una página registra herramientas en document.modelContext para que un agente las descubra y las invoque mientras el usuario tiene la página abierta. Cada herramienta lleva nombre, descripción, JSON Schema de entrada y una función execute.

Si no conoces la API, empieza por qué es WebMCP. Todo el código de este artículo está copiado tal cual de los archivos del sitio, con su ruta debajo de cada bloque. Ese código pasó su batería de Playwright el 3 de octubre de 2026 con Chrome 154.0.8037.95 y el Chromium de Playwright 1.63. El 15 de octubre se añadió la excepción de la lección 01, que se lee sin cuenta; esa parte la cubren las pruebas unitarias y todavía no se ha repetido la batería con navegador.

Qué vamos a construir

desarrollomcp.com es un sitio Astro en Cloudflare Workers con un curso de 26 lecciones en tres niveles, un blog, sesiones y un checkout con PayPal. Queremos que un agente del navegador consulte el temario, los planes y los artículos, vea el progreso del alumno y le lleve a una lección, sin abrir ninguna vía nueva hacia contenido de pago, credenciales o pagos.

Tres decisiones dan forma a todo lo demás:

  1. Solo la API imperativa. La forma declarativa con atributos en <form> no tiene especificación (“This section is entirely a TODO”, dice el borrador) y ChatGPT Desktop no la admite: “Tools defined through HTML form attributes aren’t available as site tools”. Además, los formularios que encajarían (login, registro, quiz, pago) no se exponen.
  2. Un catálogo pequeño por página y por estado de sesión. La documentación de Chrome y el README del explicador recomiendan registrar según el estado de la página. El sitio tiene 17 herramientas, pero cada página registra entre 4 y 10, y el checkout ninguna.
  3. Las herramientas leen la misma fuente que la página. El servidor imprime el contexto con la misma decisión que pinta el HTML. Los catálogos públicos salen de JSON prerenderizados, y los datos privados, de endpoints que autorizan con la sesión.

Este es el catálogo completo. “Globales” quiere decir en todas las páginas salvo el checkout:

Herramienta Dónde se registra Qué hace
ir_a_pagina globales Navega a una sección principal
resumen_pagina globales Título, URL canónica y encabezados de la página
buscar_en_sitio globales Busca en artículos y fichas de lección
iniciar_registro globales, sin sesión Navega al registro; no crea la cuenta
ver_mi_cuenta globales, con sesión Nivel de acceso y siguiente paso
ver_temario inicio, curso, niveles, panel Lecciones, nivel, acceso y URL de la ficha
ver_ficha_leccion curso, niveles, lecciones Ficha pública de una lección
abrir_leccion curso, niveles, lecciones Navega a una lección
ver_planes inicio, curso, niveles, planes Niveles y precios
ir_a_checkout planes, niveles y fichas sin el nivel Navega a la página de pago; no paga
ver_mi_progreso curso, lección completa, panel (con sesión) Progreso por nivel y siguiente lección
ver_estado_quiz lección completa, panel (con sesión) Aprobado, intentos y bloqueo; sin respuestas
ver_indice_leccion lección completa Objetivos y secciones de la lección
ir_a_seccion lección completa, artículo Lleva el foco a una sección
listar_articulos inicio, blog Artículos publicados, por tema
ver_articulo artículo Fechas, secciones y relacionados
ver_opciones_acceso login, registro Explica cómo entrar; no inicia sesión

Detección de soporte y mejora progresiva

La mayoría de las visitas llegan sin la API, y para ellas la página tiene que funcionar igual. La documentación de Chrome lo plantea así: “it can be added as a progressive enhancement”.

OpenAI recomienda detectar el método, no solo el objeto (ejemplo literal):

if (typeof document.modelContext?.registerTool === "function") {
  await document.modelContext.registerTool({
    name: "get_page_title",
    description: "Read the title of the current page.",
    inputSchema: {
      type: "object",
      properties: {},
      additionalProperties: false,
    },
    annotations: { readOnlyHint: true },
    execute: async () => ({ title: document.title }),
  });
}

Meta usa if (document.modelContext) y añade “Always feature-detect, and always keep your UI usable without it”.

Un componente en el <head> de todas las páginas imprime dos cosas: el contexto en un JSON y un arranque que solo comprueba el soporte, con la forma de OpenAI. Si hay soporte, el arranque descarga el resto con import():

{pintar && <script type="application/json" id="webmcp-data" set:html={serializarParaScript(ctx)} />}
{
  pintar && (
    <script>
      // Fase 1 (D7): solo detección. La forma de comprobarlo es la que recomienda OpenAI.
      if (typeof (document as unknown as { modelContext?: { registerTool?: unknown } }).modelContext?.registerTool === "function") {
        import("../../lib/webmcp/cliente.ts")
          .then((m) => m.iniciar())
          .catch((e) => console.warn("[webmcp] no se pudo iniciar", e));
      }
    </script>
  )
}
Código del sitio: src/components/webmcp/WebMCP.astro.

En la build ese arranque ocupa 276 bytes. El cliente con todas las herramientas ocupa unos 25 KB (8,8 KB comprimido), y solo lo descarga un navegador con la API. La prueba sin soporte lanza Chrome sin flag y comprueba que no hay errores y que no se pide ni el cliente ni los datos.

El JSON lleva la página, su URL canónica, el nivel de la sesión y los precios que pagaría ese usuario. Nunca lleva email, id ni rol. Lo imprime una función que escapa <, para que un título con </script> no pueda cerrar la etiqueta:

export function serializarParaScript(datos: unknown): string {
  return JSON.stringify(datos).replace(/</g, "\\u003c");
}
Código del sitio: src/lib/webmcp/utilidades.ts.

Registrar herramientas públicas: temario y artículos

El núcleo registra las herramientas del documento con un único AbortController y se engancha al ciclo de vida de la navegación. El sitio navega con documentos completos, con transiciones de vista nativas y sin router de cliente. Por eso cada documento registra lo suyo, y sus herramientas mueren con él:

/**
 * Registra las herramientas del documento. Retira antes las anteriores y marca
 * el documento como registrado DESPUÉS de abortar, para que dos llamadas
 * solapadas nunca intenten registrar el mismo nombre (InvalidStateError).
 * Un fallo en una herramienta no impide registrar las demás.
 */
export async function registrarPagina(herramientas: HerramientaWebMCP[]): Promise<string[]> {
  const contexto = mc();
  if (!contexto) return [];
  retirarTodas();
  const ctrl = new AbortController();
  controlador = ctrl;
  registradoPara = document;
  for (const t of herramientas) {
    if (ctrl.signal.aborted) break;
    try {
      await contexto.registerTool(t, { signal: ctrl.signal });
      if (!ctrl.signal.aborted) titulos.set(t.name, t.title);
    } catch (e) {
      // pagehide (o una llamada solapada) abortó la señal mientras se
      // registraba: los registerTool pendientes rechazan con AbortError. Es
      // una cancelación esperada, no un fallo.
      if (ctrl.signal.aborted) break;
      if ((e as { name?: string } | null)?.name === "AbortError") continue;
      console.warn("[webmcp] no se pudo registrar", t.name, e);
    }
  }
  return registradas();
}

/**
 * Construye la lista de herramientas del documento. `motivo` es "restaurado"
 * cuando el documento vuelve de la bfcache: la sesión pudo cambiar mientras
 * estaba guardado (un logout en otra página), así que quien construye debe
 * volver a resolverla antes de decidir el catálogo.
 */
export type Construir = (motivo: "inicio" | "restaurado") => HerramientaWebMCP[] | Promise<HerramientaWebMCP[]>;

let enganchado = false;
/** Cada salida o nuevo registro invalida las construcciones en curso. */
let generacion = 0;

/** Retira todo e invalida cualquier construcción pendiente (pagehide, before-swap). */
function salir() {
  generacion++;
  retirarTodas();
}

/** Engancha el ciclo de vida del documento y registra de inmediato (idempotente). */
export function iniciar(construir: Construir) {
  if (!soportado()) return;
  const registrar = async (motivo: "inicio" | "restaurado") => {
    if (registradoPara === document) return;
    const gen = ++generacion;
    let herramientas: HerramientaWebMCP[];
    try {
      herramientas = await construir(motivo);
    } catch (e) {
      console.warn("[webmcp] no se pudo preparar el catálogo", e);
      return;
    }
    // Mientras se construía, el documento salió (pagehide) o empezó otro registro
    if (gen !== generacion || registradoPara === document) return;
    await registrarPagina(herramientas);
  };
  if (!enganchado) {
    enganchado = true;
    // Al salir (navegación completa o entrada en la bfcache) se retira todo.
    window.addEventListener("pagehide", salir);
    // Documento restaurado desde la bfcache: conserva el estado de pagehide
    // (todo retirado), así que se vuelve a registrar, con la sesión resuelta
    // de nuevo (no la que tenía el documento al entrar en la bfcache).
    window.addEventListener("pageshow", (e) => {
      if (e.persisted) {
        registradoPara = null;
        void registrar("restaurado");
      }
    });
    // Solo con <ClientRouter /> (hoy no se usa)
    document.addEventListener("astro:before-swap", salir);
    document.addEventListener("astro:page-load", () => void registrar("inicio"));
  }
  void registrar("inicio");
}
Código del sitio: src/lib/webmcp/core.ts.

Tres detalles salieron de pruebas que fallaban:

  • Marcar antes de registrar. El núcleo fija registradoPara justo después de abortar el registro anterior, no al terminar. Así dos llamadas solapadas nunca intentan registrar el mismo nombre. Registrar dos veces un nombre rechaza con InvalidStateError, según la especificación.
  • bfcache. Al volver atrás, el documento restaurado conserva el estado de pagehide, con todo retirado, y se quedaría sin herramientas. El pageshow con e.persisted lo arregla, pero no basta con volver a registrar lo de antes, porque mientras el documento estaba guardado el usuario pudo cerrar sesión en otra página. Por eso construir recibe el motivo "restaurado", y el cliente vuelve a pedir la sesión a /api/auth/me antes de decidir el catálogo. Si llega otro pagehide mientras tanto, el resultado tardío se descarta. Las pruebas que lo vigilan activan la bfcache (Playwright la desactiva por defecto) y cierran sesión con la página guardada.
  • Un fallo no tumba el resto. Cada registerTool() va en su propio try, con el prefijo [webmcp] en consola. Si pagehide aborta a mitad del registro, los registerTool() pendientes rechazan con AbortError. Es una cancelación esperada, así que no se anota como fallo.

Cada herramienta se crea con un ayudante, definir, que vuelve a validar la entrada contra el esquema antes de ejecutar. Nada garantiza que el agente lo respete. Las anotaciones se reparten en tres juegos:

/** Lectura: no cambia nada. */
export const RO: Anotaciones = { readOnlyHint: true, untrustedContentHint: false, consequentialHint: false };
/** Navegación: no modifica datos, pero cambia lo que ve el usuario. Nada irreversible (D5). */
export const NAV: Anotaciones = { readOnlyHint: false, untrustedContentHint: false, consequentialHint: false };
/** Solo desarrollo (Chrome ≥ 156 la reconoce; antes se ignora). */
export const DBG: Anotaciones = { ...RO, debugging: true };

export const SIN_PARAMETROS: EsquemaObjeto = { type: "object", properties: {}, additionalProperties: false };

export const URL_LOGIN = `${CANONICAL_ORIGIN}/login`;

/** URL absoluta en el host canónico conservando la query (destinos de navegación). */
export const urlAbsoluta = (ruta: string) => `${CANONICAL_ORIGIN}${ruta}`;

type Definicion = Omit<HerramientaWebMCP, "execute"> & {
  ejecutar(entrada: Record<string, any>, signal?: AbortSignal): unknown | Promise<unknown>;
};

/**
 * Crea una herramienta cuya ejecución revalida siempre la entrada contra el
 * esquema (con valores por defecto) antes de llamar a `ejecutar`.
 */
export function definir(def: Definicion): HerramientaWebMCP {
  const { ejecutar, ...meta } = def;
  return {
    ...meta,
    async execute(entrada, opciones) {
      const datos = validarEntrada(meta.inputSchema, entrada);
      return ejecutar(datos, opciones?.signal);
    },
  };
}
Código del sitio: src/lib/webmcp/herramientas/base.ts.

Con eso, una herramienta pública es una función que recibe el contexto y las dependencias del navegador. Así es ver_temario:

/** C1 */
export function verTemario(ctx: ContextoResuelto, deps: Dependencias): HerramientaWebMCP {
  return definir({
    name: "ver_temario",
    title: "Ver el temario",
    description:
      "Devuelve las lecciones del curso de MCP (26 en tres niveles) con número, título, descripción breve, nivel, si el usuario puede abrirla y la URL canónica de su ficha. Se puede filtrar por nivel. Si hay_mas es true, vuelve a llamar con desde = siguiente_desde.",
    inputSchema: {
      type: "object",
      properties: {
        nivel: { type: "string", enum: [...NIVELES_CURSO], description: "Filtra por nivel: basico, avanzado o experto." },
        desde: { type: "integer", minimum: 0, maximum: 25, default: 0, description: "Posición desde la que listar, para paginar." },
      },
      additionalProperties: false,
    },
    annotations: RO,
    async ejecutar({ nivel, desde }, signal) {
      const filtro: NivelCurso | undefined = nivel ?? (ctx.pagina === "nivel" ? ctx.nivelPagina : undefined);
      const { lecciones } = await deps.temario(signal);
      const lista = lecciones.filter((l) => !filtro || l.nivel === filtro);
      const pagina = lista.slice(desde).map((l) => ({
        slug: l.slug,
        num: l.num,
        titulo: l.titulo,
        descripcion: l.descripcion,
        nivel: l.nivel,
        acceso: accesoPara(l.nivel, ctx.sesion, l.slug),
        url: l.url,
      }));
      return ajustar(
        { ...(filtro && { nivel: filtro }), total: lista.length, desde, hay_mas: false, lecciones: pagina },
        { lista: "lecciones" },
      );
    },
  });
Código del sitio: src/lib/webmcp/herramientas/curso.ts.

El resultado es un objeto. La especificación serializa con JSON lo que devuelva execute, así que no hace falta el formato { content: [...] } de MCP. deps.temario descarga /datos/temario.json la primera vez que hace falta. Ese JSON se prerenderiza desde el mismo temario que pinta el curso y solo lleva campos públicos, nunca el cuerpo de una lección.

ajustar recorta las listas al presupuesto de 1.500 caracteres y marca hay_mas. Las 26 lecciones no caben, así que se pagina con desde.

Herramientas con sesión: registrar y retirar según el estado

El borrador da por hecho que el agente hereda la identidad del usuario (“Identity inheritance”): la herramienta corre con sus cookies. Por eso la regla es doble.

La comodidad está en el registro. Una función pura decide, a partir del contexto, qué herramientas registra cada documento. En una lección, por ejemplo:

    case "leccion": {
      const l = ctx.leccion;
      h.push(verFichaLeccion(ctx, deps), abrirLeccion(ctx, deps));
      if (l?.vista === "completa") {
        h.push(verIndiceLeccion(ctx, deps), irASeccion(ctx, deps));
        if (s) h.push(verEstadoQuiz(ctx, deps), verMiProgreso(ctx, deps));
      } else if (l && l.nivel !== "basico" && !tiene(s, l.nivel)) {
        h.push(irACheckout(ctx, deps));
      }
      break;
    }
Código del sitio: src/lib/webmcp/catalogo.ts.

Un anónimo en una lección de pago ve la ficha e ir_a_checkout. Un alumno con acceso ve el índice, su quiz y su progreso. Una prueba unitaria compara esta función con un contrato escrito a mano (20 tipos de página y 88 combinaciones de página y perfil: los cinco perfiles en 16 páginas, y solo los que pueden llegar a login, registro, panel y checkout). Una suite de Playwright está escrita para comprobar que el agente ve exactamente eso.

La protección está en el servidor. Que una herramienta no se registre no protege nada. execute llama al endpoint con la cookie de sesión y le pasa su signal, y el endpoint decide igual que para la interfaz. El README lo dice así: “The tool’s execute callback should still validate current inputs, authorization, and preconditions before running.”

Figura 1. Una herramienta con sesión delega la autorización en el endpoint, que decide como para la interfaz.

Esta es la herramienta de progreso:

/** C6 (con sesión) */
export function verMiProgreso(ctx: ContextoResuelto, deps: Dependencias): HerramientaWebMCP {
  return definir({
    name: "ver_mi_progreso",
    title: "Mi progreso",
    description:
      "Devuelve el progreso del alumno en el curso: lecciones completadas por nivel, porcentaje y la siguiente lección recomendada. Solo datos del propio usuario con sesión iniciada.",
    inputSchema: SIN_PARAMETROS,
    annotations: RO,
    async ejecutar(_e, signal) {
      if (!ctx.sesion) throw errorAgente(`No autorizado: inicia sesión para ver tu progreso (${URL_LOGIN})`);
      const p = await pedirJson<Progreso>(deps, "/api/user/progress", signal);
      return ajustar({
        nivel: p.nivel,
        por_nivel: p.por_nivel,
        porcentaje: p.porcentaje,
        siguiente_leccion: p.siguiente_leccion,
      });
    },
  });
}
Código del sitio: src/lib/webmcp/herramientas/curso.ts.

/api/user/progress no devuelve email, id ni fechas de pago. pedirJson traduce los códigos HTTP a mensajes que el agente pueda usar:

/** GET a un endpoint propio con la sesión; traduce los códigos HTTP a errores para el agente. */
export async function pedirJson<T>(deps: Dependencias, ruta: string, signal?: AbortSignal): Promise<T> {
  let r: Response;
  try {
    r = await deps.fetch(ruta, { credentials: "same-origin", signal, headers: { Accept: "application/json" } });
  } catch (e) {
    if ((e as Error)?.name === "AbortError") throw e;
    throw errorAgente("No se pudo contactar con desarrollomcp.com", "Comprueba la conexión y vuelve a intentarlo");
  }
  if (r.status === 401) throw errorAgente(`No autorizado: inicia sesión para usar esta herramienta (${URL_LOGIN})`);
  if (r.status === 403) throw errorAgente("No tienes acceso a esta lección con tu nivel actual", "Consulta los planes con ver_planes");
  if (r.status === 400) throw errorAgente("La lección indicada no existe", "Usa ver_temario para ver los slugs válidos");
  if (!r.ok) throw errorAgente(`El servidor respondió con un error (${r.status})`, "Vuelve a intentarlo más tarde");
  return (await r.json()) as T;
}
Código del sitio: src/lib/webmcp/herramientas/base.ts.

Por CDP, la excepción llega como status: "Error" con el mensaje. El cierre de sesión es una navegación completa, así que las herramientas privadas mueren con el documento. Retirar una herramienta no cancela una ejecución en curso. Desde el 19 de agosto de 2026, la especificación separa la señal de registro de la de ejecución, y Chrome lo aplica desde Chrome 153, según su guía de la API imperativa.

Herramientas que navegan

Si una herramienta navega antes de que Chrome entregue el resultado, el agente se queda sin él. Chrome documenta que executeTool() devuelve “the result of the tool execution, or null when a navigation is triggered”. La solución es devolver primero y navegar después, con deps.navegar:

function dependencias(): Dependencias {
  return {
    navegar(ruta) {
      // Navegación completa diferida: devolver el resultado antes de que empiece la descarga
      setTimeout(() => location.assign(ruta), RETRASO_NAVEGACION_MS);
    },
    fetch: (url, init) => fetch(url, { credentials: "same-origin", ...init }),
    doc: document,
    temario: cargador<TemarioPublico>("/datos/temario.json"),
    articulos: cargador<ArticulosPublicos>("/datos/articulos.json"),
  };
}
Código del sitio: src/lib/webmcp/cliente.ts.

La herramienta devuelve el destino sin esperar:

/** C3 */
export function abrirLeccion(ctx: ContextoResuelto, deps: Dependencias): HerramientaWebMCP {
  return definir({
    name: "abrir_leccion",
    title: "Abrir una lección",
    description:
      "Lleva al usuario a una lección del curso. Si no tiene acceso verá la ficha pública con las opciones para conseguirlo. Devuelve la URL de destino y si el usuario tiene acceso.",
    inputSchema: { type: "object", properties: { slug: PARAM_SLUG }, required: ["slug"], additionalProperties: false },
    annotations: NAV,
    async ejecutar({ slug }, signal) {
      const l = leccionDe(await deps.temario(signal), slug);
      const tiene = accesoPara(l.nivel, ctx.sesion, l.slug) === "abierta";
      deps.navegar(`/aprende/${l.slug}`);
      return { navegando_a: l.url, tienes_acceso: tiene, vista: tiene ? "leccion_completa" : "ficha_publica" };
    },
  });
}
Código del sitio: src/lib/webmcp/herramientas/curso.ts.

En el prototipo, con la navegación diferida 0 ms, la salida llegaba vacía (output: []). Con 250 ms, el agente recibe navegando_a y después la página llega al destino. Es un comportamiento observado en Chrome 154, no algo que garantice la especificación, así que una prueba lo vigila en cada versión de Chrome.

Las continuaciones, que permitirían a una herramienta sobrevivir a una navegación, son solo una propuesta. Su explicador se fusionó en el repositorio el 2 de octubre de 2026.

Lo que no se expone y por qué

Esta lista sale del modelo de amenazas del borrador y de las recomendaciones de Chrome:

No se expone Motivo
Login, registro con credenciales, cambio o restablecimiento de contraseña, OAuth La herramienta correría con la identidad del usuario; el propio borrador usa reset-password como ejemplo de objetivo de ataque
Cierre de sesión Una inyección de prompt podría sacar al usuario de su sesión
Crear o capturar el pedido de PayPal El patrón de Shopify, Stripe y el README es preparar y dejar el último paso al usuario; ir_a_checkout solo navega
Enviar el quiz, ver sus respuestas o marcar una lección como completada Es una evaluación: el agente la haría por el alumno
Texto de una lección El agente ya ve el DOM de la página abierta. Devolverlo multiplica el riesgo de fuga. Una prueba comprueba que el texto de pago no aparece en ningún resultado para quien no tiene el nivel
Email, id de usuario, rol, fechas de pago Innecesarios; el borrador advierte del riesgo de sobreparametrización
Exposición a otros orígenes con exposedTo Por defecto solo ven las herramientas el propio origen y el agente del navegador, y no hay ningún socio de confianza

Si quieres ver el patrón de “preparar y dejar el último paso al usuario” en tiendas reales, lo desarrollamos en WebMCP en pagos y comercio.

El checkout necesita una medida más, porque carga el SDK de PayPal en el documento principal. Desde el 15 de septiembre de 2026, la especificación documenta la cabecera Permissions-Policy: tools=() como mitigación: “The user agent enforces this before script runs on the page, protecting against malicious scripts or dependencies from using WebMCP APIs.” La pone el middleware:

  // WebMCP (docs/plan/webmcp.md D6 y §7.9): en el checkout ningún script (propio,
  // del SDK de PayPal o de una dependencia) puede registrar herramientas; el
  // navegador lo aplica antes de ejecutar ningún script. Con WEBMCP_ACTIVO=false,
  // lo mismo en todas las páginas HTML que sirve el Worker.
  if (ruta === "/checkout" || (env.WEBMCP_ACTIVO === "false" && esHtml(respuesta))) {
    respuesta = fijarCabecera(respuesta, "Permissions-Policy", "tools=()");
  }
Código del sitio: src/middleware.ts.

La prueba comprueba que:

  • la respuesta lleva la cabecera
  • el agente no ve ninguna herramienta
  • registerTool() rechaza con NotAllowedError
  • ir_a_checkout no llama a la API de pagos.

La misma cabecera sirve de interruptor. Con WEBMCP_ACTIVO=false en el Worker, ninguna página que sirva el Worker puede registrar herramientas, y las prerenderizadas (el blog) dejan de registrarlas al consultar la sesión.

Anotaciones y presupuestos de texto

Las anotaciones son pistas para el agente, no controles. Este es el criterio detrás de RO, NAV y DBG:

  • readOnlyHint: true en toda lectura. La guía de Chrome para agentes les pide “Assume WebMCP tools mutate state, unless the tool description or annotations (readOnlyHint) clearly state otherwise”.
  • Navegar no es leer. Las que navegan llevan readOnlyHint: false: no modifican datos, pero cambian lo que ve el usuario.
  • consequentialHint en ninguna. La especificación la reserva para acciones “significant, real-world, or non-reversible”, y las que lo serían no se exponen.
  • untrustedContentHint: false, declarado siempre. Los resultados son contenido propio: no hay comentarios, y no se devuelven títulos de fuentes de terceros. Contenido de usuarios o de terceros llevaría true.
  • debugging: true solo en una herramienta de diagnóstico que no llega a la build.
  • title corto, en español y único. El navegador lo muestra, y la especificación recomienda localizarlo.

Para los textos, Chrome propone en “WebMCP tool security” unos presupuestos no normativos: 500 caracteres por descripción de herramienta, 150 por descripción de parámetro, 30 por nombre de herramienta o de parámetro y 1.5K por resultado.

En el código son constantes:

/** Presupuestos de Chrome (no normativos, investigación §4.2). */
export const PRESUPUESTO = {
  nombre: 30,
  descripcion: 500,
  parametro: 150,
  resultado: 1500,
} as const;
Código del sitio: src/lib/webmcp/utilidades.ts.

Los esquemas llevan additionalProperties: false, enum o pattern y maxLength. Nombres, descripciones y esquemas son literales del código, nunca datos de la base de datos. Esa es la defensa contra lo que el borrador llama Tool Poisoning. Una prueba unitaria recorre todas las definiciones y comprueba presupuestos, esquemas, anotaciones explícitas y títulos únicos.

Probarlo: flag, Playwright y CDP

En local, la documentación de Chrome indica activar chrome://flags/#enable-webmcp-testing. En automatización, el equivalente es lanzar Chrome con --enable-features=WebMCP. http://localhost es contexto seguro, así que con el flag funciona sin HTTPS.

Para usuarios reales sin flag hace falta registrar el origen en el origin trial, que empezó en Chrome 149. El Intent to Extend Experiment del 28 de septiembre de 2026 pide ampliarlo hasta Chrome 162. A fecha de este artículo, desarrollomcp.com todavía no tiene el token. El estado navegador por navegador está en dónde funciona WebMCP hoy.

Un agente real no usa getTools(). Según el borrador, el agente del navegador “uses a different internal mechanism”. Para simularlo en una prueba, Chrome publica en el protocolo de DevTools un dominio experimental, WebMCP. Incluye WebMCP.enable, WebMCP.invokeTool, WebMCP.cancelInvocation y los eventos toolsAdded, toolsRemoved, toolInvoked y toolResponded. Este es nuestro agente simulado:

export async function agente(page: Page) {
  const cdp: CDPSession = await page.context().newCDPSession(page);
  const tools = new Map<string, any>();
  const respuestas = new Map<string, Respuesta>();
  cdp.on("WebMCP.toolsAdded" as any, (e: any) => e.tools.forEach((t: any) => tools.set(t.name, t)));
  cdp.on("WebMCP.toolsRemoved" as any, (e: any) => (e.tools ?? []).forEach((t: any) => tools.delete(t.name ?? t)));
  cdp.on("WebMCP.toolResponded" as any, (e: any) => respuestas.set(e.invocationId, e));
  // Chrome 154 no emite toolsRemoved de todas las herramientas del documento que
  // muere en una navegación completa: se vacía el mapa de ese frame.
  cdp.on("Page.frameNavigated" as any, (e: any) => {
    if (e.type === "Navigation" && !e.frame.parentId) for (const [k, t] of tools) if (t.frameId === e.frame.id) tools.delete(k);
  });
  await cdp.send("Page.enable" as any);
  await cdp.send("WebMCP.enable" as any);
  const nombres = () => [...tools.keys()].sort();
  return {
    cdp,
    tools,
    nombres,
    /** Espera a que el agente vea exactamente estas herramientas. */
    async esperar(esperado: string[], timeout = 10_000) {
      await expect.poll(nombres, { timeout }).toEqual([...esperado].sort());
    },
    async invocar(name: string, input: object = {}): Promise<Respuesta> {
      await expect.poll(() => tools.has(name), { message: `herramienta ${name} registrada` }).toBe(true);
      const t = tools.get(name);
      const { invocationId } = (await cdp.send("WebMCP.invokeTool" as any, { frameId: t.frameId, toolName: name, input })) as any;
      await expect.poll(() => respuestas.has(invocationId), { timeout: 15_000, message: `respuesta de ${name}` }).toBe(true);
      return respuestas.get(invocationId)!;
    },
    /** Invoca y devuelve la salida ya parseada; falla si no es Completed. */
    async salida<T = any>(name: string, input: object = {}): Promise<T> {
      const r = await this.invocar(name, input);
      expect(r.status, `${name}: ${errorDe(r)}`).toBe("Completed");
      return salidaDe<T>(r);
    },
  };
}
Código del sitio: tests/webmcp/agente.ts.

Hay dos cosas que no dice ninguna documentación:

  • En una navegación entre documentos, Chrome 154 no emitió toolsRemoved para todas las herramientas del documento que muere. De ahí el Page.frameNavigated.
  • El registro es asíncrono, así que invocar nada más cargar falla de forma intermitente. De ahí el expect.poll.

Esta es la prueba que vigila las herramientas que navegan:

test("P9b: abrir_leccion e ir_a_pagina devuelven navegando_a y navegan", async ({ page }) => {
  await page.goto("/aprende");
  const a = await agente(page);
  const r1 = await a.invocar("abrir_leccion", { slug: "03-ciclo-vida" });
  expect(r1.status).toBe("Completed");
  expect(r1.output).not.toEqual([]);
  const o1 = typeof r1.output === "string" ? JSON.parse(r1.output) : r1.output;
  expect((o1 as any).navegando_a).toBe("https://desarrollomcp.com/aprende/03-ciclo-vida");
  await page.waitForURL("**/aprende/03-ciclo-vida");

  const r2 = await a.invocar("ir_a_pagina", { destino: "blog" });
  expect(r2.status).toBe("Completed");
  const o2 = typeof r2.output === "string" ? JSON.parse(r2.output) : r2.output;
  expect((o2 as any).navegando_a).toBe("https://desarrollomcp.com/blog");
  await page.waitForURL((u) => u.pathname === "/blog");
});
Código del sitio: tests/webmcp/navegar.spec.ts.

En CDP las anotaciones no usan los nombres de la API de JavaScript (readOnlyHint), sino readOnly, untrustedContent y consequential. Además, el propio protocolo avisa de que la salida que recibe el agente “is untrusted and poses a prompt injection risk”.

La configuración separa tres proyectos:

  • el Chromium que trae Playwright
  • el Chrome estable instalado, que detectará los cambios de Chrome 155 y 156 el día que se actualice
  • el caso sin soporte.
  projects: [
    { name: "chromium-webmcp", testIgnore: /sin-soporte/, use: { launchOptions: { args: FLAG } } },
    { name: "chrome-webmcp", testIgnore: /sin-soporte/, use: { channel: "chrome", launchOptions: { args: FLAG } } },
    { name: "chrome-sin-soporte", testMatch: /sin-soporte\.spec\.ts/, use: { channel: "chrome" } },
  ],
Código del sitio: tests/webmcp/playwright.config.ts.

Las pruebas corren contra la build en workerd, con la base de datos local sembrada con cinco perfiles, y graban vídeo. La batería pasa entera en Chrome 154 headless. Cubre el inventario por página y perfil, la navegación, la bfcache, el checkout, la autorización de los endpoints, los errores, el caso sin soporte y el aviso role="status" que muestra la página cuando un agente usa una herramienta.

Para revisar a mano están el panel Application de DevTools y la extensión Model Context Tool Inspector. El panel está disponible desde Chrome 149, con los flags #devtools-webmcp-support y #enable-webmcp-testing, según New in DevTools 149. No hemos probado ninguno de los dos, ni una sesión real con ChatGPT Desktop.

Si quieres medir si un modelo elige bien tus herramientas, WebMCP Evals tiene un modo smoke que ejecuta llamadas esperadas contra la página sin LLM ni clave de API. La lección de testing y depuración del curso aplica las mismas ideas a servidores MCP.

Diferencias entre Chrome 154 y la especificación

Esto es lo que vimos con Chrome 154 estable frente al borrador del 2 de octubre de 2026, y cómo lo resuelve el sitio:

  • executeTool() exige una cadena JSON. La especificación acepta un objeto desde el 17 de agosto de 2026, pero en Chrome 154 un objeto falla con UnknownError: Failed to parse input arguments. Chrome anuncia que la cadena queda obsoleta en Chrome 155. Por eso la validación de entrada del sitio acepta las dos formas, y los ayudantes de las pruebas también.
  • getTools() devuelve inputSchema como cadena, aunque la especificación lo cambió a object el 14 de agosto de 2026.
  • toolactivated se dispara en window, no en document.modelContext. Chrome documenta que desde Chrome 156 dejan de funcionar window.ontoolactivated y window.ontoolcancel. El aviso visible del sitio escucha en los dos sitios y descarta los duplicados.
  • Las anotaciones no incluyen debugging, que Chrome declara “available from Chrome 156”.
  • navigator.modelContext no existe. El getter se movió a Document el 27 de mayo de 2026 y en Chrome 154 'modelContext' in navigator es false, también con el flag. Tampoco hay unregisterTool() ni provideContext(). Si un tutorial los usa, está escrito contra una versión anterior del explicador.

Según el calendario de Chrome, Chrome 155 sale el 6 de octubre y Chrome 156 el 20 de octubre de 2026. No los hemos probado, así que lo que cambian viene de la documentación de Chrome. Para eso están el proyecto con el Chrome estable instalado y la prueba del retraso de 250 ms.

Por dónde seguir

La lección de herramientas del curso explica cómo se diseña una tool en MCP: nombre, descripción, esquema y errores útiles para el modelo. El nivel Básico es gratuito con registro. El soporte actual está en dónde funciona WebMCP, y las confirmaciones en tiendas, en WebMCP en pagos y comercio.

Aprende esto en el curso

  • Básico Gratis Herramientas · Acciones que el modelo puede invocar, con esquemas y errores
  • Avanzado 10 € Testing y depuración · Inspector, tests de conformidad, evaluación y CI

La lección 1 la puedes leer ya, sin registro. Las otras 13 del Básico son gratis con una cuenta.

Empieza gratis

Fuentes

  1. WebMCP, Draft Community Group Report (2026-10-02)
  2. Repositorio webmachinelearning/webmcp (README y explicadores)
  3. Chrome: WebMCP (2026-10-01)
  4. Chrome: Imperative API (2026-09-21)
  5. Chrome: WebMCP tool security (2026-09-01)
  6. Chrome: Agent security considerations for WebMCP (2026-06-09)
  7. ChatGPT: Site tools (WebMCP)
  8. Meta: agent tools para web apps
  9. Chrome DevTools Protocol (dominio WebMCP)
  10. New in DevTools 149 (2026-06-02)
  11. Calendario de versiones de Chrome
  12. Intent to Extend Experiment: WebMCP (2026-09-28)
  13. WebMCP Evals (GoogleChromeLabs/webmcp-tools)

Artículos relacionados