Fetch, parse, screenshot and browse pages via MCP without a real browser -- no JS execution.
Copy the AI prompt to install this server into Claude Code, Cursor, or another agent — or use 1-click editor setup below.
💡 Paste into ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows)
mcp-name: io.github.MauricioPerera/kite-lite
Motor web ligero, autocontenido y orientado a agentes. Está diseñado para ejecutarse fuera de Cloudflare en un VPS o contenedor, con un parser HTML, JavaScript aislado, renderizado SVG determinista y una superficie CDP pequeña.
El núcleo DOM en Rust:
fetch/CDP)
mediante un cliente HTTP con cookie jar propio;{x, y, width, height} en
Element.layout) usando una hoja de estilos por defecto fija por tag
(tamaños/negrita de h1-h6, márgenes de p/li/blockquote), con ajuste de
texto (word wrap) — ver la sección "Layout mÃnimo" para las limitaciones;
permite click/escritura/submit de formularios por CDP sin ejecutar JS de
la página — ver "Interacción: click, escritura y submit de formularios";
renderiza a PNG y PDF además de SVG — ver "Renderizado PNG/PDF";
se puede correr como servidor MCP por stdio — ver "Servidor MCP".kite-lite le da a un agente acceso a la web sin arrastrar un navegador real
(Chromium/Playwright) — pico de memoria medido de ~4-7 MiB (ver "Recursos
mÃnimos"), arranque en milisegundos, corre bajo --network=none/
--cap-drop=ALL/--read-only sin romperse. Eso cambia el cálculo de costo:
en vez de una instancia pesada compartida, es viable levantar una por tarea,
en paralelo, en un VPS chico.
Lo que habilita, concretamente:
fetch_page) — resumen liviano
(tÃtulo/texto/links), no el DOM completo con layout. Ahorra contexto
frente a devolver HTML crudo cuando el agente solo necesita "qué dice
esta página".browser_navigate + cookies/redirecciones
persistentes) — flujos de varios pasos (login → página protegida),
mientras el flujo no dependa de JS del lado cliente.type o name.eval_js) — extraer un valor calculado o
probar una expresión, con garantÃas reales de sandboxing: sin red, sin
filesystem, en un proceso separado que ni siquiera puede linkear
reqwest/tokio (ver "Servidor MCP").render_screenshot/browser_screenshot) — para
verificación aproximada o reportes, no para QA visual pixel-perfect (ver
"Layout mÃnimo").webmcp-lint, a11y-lint, seo-lint, social-lint) le dan a
un agente que construye o revisa sitios un gate determinista y barato,
sin gastar una llamada a un modelo para chequear si falta un
<meta description>.browser_call_tool) — si el sitio objetivo
publica formularios anotados, el agente consume una interfaz con schema
en vez de adivinar selectores (ver "Soporte declarativo de WebMCP").Los lÃmites duros, por diseño — no son bugs, son la contracara directa de lo anterior (ver "Próximas capas" para el porqué):
fetch del lado cliente, cualquier framework que renderiza
en el navegador, es invisible.onclick ni ningún evento de
verdad, solo navegación (<a>) y submit GET.method="post" real no se puede
enviar con cuerpo.Runtime.callFunctionOn contra un DOM vivo (asà opera
Playwright internamente) no funciona (ver "Compatibilidad CDP").Tiene sentido para: contenido servido en HTML plano (blogs, docs, la mayorÃa de sitios de contenido, APIs que devuelven HTML, formularios GET simples), lectura/extracción/auditorÃa en volumen donde el costo de un navegador real no se justifica, o cualquier caso donde el aislamiento (sin red desde el JS, sin DOM vivo) es una ventaja de seguridad, no una limitación. Para SPAs, interacción compleja, o sitios que validan con JS del cliente, hace falta un navegador real — no hay atajo.
render (and POST /v1/render, via ?format=png|pdf|svg) picks the output
format from the --output path's extension (.svg is the default for
anything else).
--js evaluates a script in a fresh Boa JavaScript context with no filesystem,
network, or host bindings. The page snapshot exposes document.title,
document.body.innerText, and a limited document.querySelector() for the
first h1, h2, h3, p, a, or button. --svg writes a deterministic
rendering driven by the minimal layout described below (see "Layout
mÃnimo"): every leaf element with text gets word-wrapped and positioned
according to a fixed default style per tag.
Example:
The JavaScript context now has source-size, recursion, and VM stack limits. The
CLI additionally runs page scripts in a separate kite-lite-js process and
terminates it after 1.5 seconds. That evaluator binary does not link
reqwest/tokio at all, so it cannot make network requests regardless of
what a script tries to do — the isolation is a property of what's compiled
into the executable, not just of what the code chooses to call. This is a
useful local isolation boundary; production deployments should still add
OS-level resource limits and containers or microVMs.
La imagen Docker ejecuta el binario como usuario sin privilegios. Para una prueba con lÃmites estrictos de recursos:
El acceso de red es necesario en este modo para descargar la URL. Para páginas
ya serializadas, se puede ejecutar el evaluador sin red — y desde que el
evaluador (kite-lite-js) es un binario separado que no enlaza reqwest ni
tokio, --network=none es una garantÃa real del binario, no solo de la
configuración del contenedor:
El renderizado también puede ejecutarse sin red:
fetch, Page.navigate/Page.reload en CDP, y POST /v1/parse calculan un
layout de bloque simple sobre el árbol y lo guardan en Element.layout
({x, y, width, height}) antes de devolver el Page. render_svg vuelve a
calcularlo internamente al ancho exacto que se le pide renderizar (no
reutiliza el guardado en el JSON, para no desalinearse si se pide un ancho
distinto).
Esto no es un motor de CSS:
<style> ni style="..." ni clases/ids — los tamaños, negrita y
márgenes son fijos por nombre de tag (h1-h6, p, li, blockquote, strong/b),
una imitación mÃnima de una hoja de estilos de usuario por defecto;text (sin
conservar el orden ni los nodos de texto como hermanos), asà que texto
suelto junto a un hijo — por ejemplo <p>Hola <a href="/x">link</a></p> —
no tiene dónde calcularse por separado una vez que el padre tiene un hijo
elemento; solo el texto propio de los nodos hoja se ajusta (word wrap) y
se dibuja;x siempre es 0.0: no hay eje horizontal en el layout, solo apilado
vertical;render_svg se puede rasterizar a PNG (resvg + tiny-skia, Rust puro, sin
depender de un navegador ni de herramientas externas) y ese PNG se puede
envolver en un PDF de una sola página (printpdf).
Esto necesita fuentes instaladas en el sistema donde corre el binario:
todo lo que dibuja render_svg es texto (no hay rectángulos, imágenes ni
otros gráficos), y el rasterizador usa fontdb::load_system_fonts() — no
trae ninguna fuente embebida. Sin fuentes, el PNG/PDF sale con el texto
faltante (el resto de los elementos, al no existir, tampoco aparece). La
imagen Docker instala fonts-dejavu-core para esto; en un build local fuera
de Docker, depende de que el sistema operativo tenga alguna fuente
disponible.
El PDF no es vectorial: es la imagen PNG rasterizada, envuelta en una
página PDF a 96 DPI — no hay texto seleccionable ni buscable. Un PDF
vectorial de verdad necesitarÃa su propio pipeline de fuentes independiente
del rasterizado (printpdf trae soporte nativo de SVG, pero internamente
usa una versión vieja de usvg con Options::default() sin fuentes
cargadas y sin forma de inyectar una — no es viable para texto).
El servidor se enlaza a loopback por defecto y expone:
GET /healthPOST /v1/parse con HTML en el body (los enlaces quedan relativos, ya que
este endpoint no recibe una URL base para resolverlos);POST /v1/render con un snapshot JSON — ?format=svg (default), png o
pdf;POST /v1/eval con { "page": ..., "script": "..." }.No se debe publicar directamente a Internet; colócalo detrás de autenticación y un proxy si se necesita acceso remoto.
El modo cdp abre un WebSocket compatible con una parte pequeña del Chrome
DevTools Protocol:
Browser.getVersion;Runtime.enable, Page.enable, Network.enable, DOM.enable;Runtime.evaluate;Page.getNavigationHistory, Page.getResourceTree;Page.navigate, Page.reload, Page.captureSnapshot;DOM.getDocument, DOM.querySelector, DOM.querySelectorAll;DOM.getOuterHTML, DOM.getAttributes, DOM.getBoxModel;Input.dispatchMouseEvent, Input.dispatchKeyEvent;Target.getTargets, Target.getTargetInfo, Target.attachToTarget,
Target.attachToBrowserTarget, Target.setAutoAttach,
Target.setDiscoverTargets, Target.closeTarget,
Target.disposeBrowserContext — un solo target fijo, sin multi-pestaña
real (ver "Compatibilidad CDP" más abajo);Page.frameStartedLoading, Page.loadEventFired y
Page.frameStoppedLoading (se disparan tanto tras Page.navigate/reload
como tras un click que termina navegando), y Target.attachedToTarget.Ejecuta cdp con un snapshot JSON para iniciar una sesión desde una página
serializada. Sin snapshot inicia una página vacÃa:
Page.navigate y Page.reload descargan HTML con un cliente HTTP persistente
por sesión CDP (la vida del proceso cdp en ejecución), reconstruyen el DOM y
actualizan la URL con el destino final tras seguir redirecciones — no con la
URL originalmente solicitada. Las cookies que el sitio establezca (incluso en
saltos intermedios de una redirección) se guardan en el cookie jar de esa
sesión y se reenvÃan en navegaciones posteriores dentro del mismo proceso
cdp, tal como esperarÃa un agente que necesite, por ejemplo, permanecer
autenticado entre una página de login y las siguientes. Runtime.evaluate
sólo expone un snapshot reducido del documento; no proporciona fetch,
filesystem ni bindings del host.
Además de reenviarse automáticamente, cada cookie que el servidor
establece (cabecera Set-Cookie) queda expuesta en Page.cookies
(nombre, valor, y flags como domain/path/secure/http_only/
same_site si vienen) — antes solo vivÃa dentro del cookie jar interno
de reqwest, sin forma de leerla. fetch_page y browser_navigate del
MCP incluyen este campo cuando hay cookies. LÃmite: solo ve cookies de
cabeceras HTTP en los fetches que hace kite-lite — no cookies que
pondrÃa JS de la página, que no se ejecuta.
Ejemplo conceptual de una llamada CDP:
La implementación todavÃa no ofrece captura PNG/PDF vÃa CDP (usá render o
/v1/render?format=png|pdf para eso — ver "Renderizado PNG/PDF"), ejecución
de scripts de la página ni eventos de red.
El servidor cdp expone, en el mismo puerto, tanto el WebSocket como los
endpoints HTTP de descubrimiento que Chrome real expone en su puerto de
remote-debugging (GET /json/version, GET /json, GET /json/list) —
los mismos que herramientas genéricas (chrome-remote-interface, scripts
que arrancan pidiendo webSocketDebuggerUrl, etc.) consultan antes de
conectar el WebSocket. También implementa lo mÃnimo del dominio Target
(getTargets, attachToTarget, setAutoAttach, ...) para que un cliente
que espera el flujo real de CDP (adjuntarse a un target antes de operar
sobre él, y que cada respuesta/evento de página lleve sessionId) no se
quede esperando algo que nunca llega — pero es un solo target fijo,
simulado: no hay múltiples pestañas/targets reales, attachToTarget
siempre devuelve el mismo sessionId y sigue siendo el único Page
compartido por todas las conexiones WebSocket, como ya se explica arriba.
Esto NO habilita Playwright real. playwright.chromium.connectOverCDP()
podrÃa llegar a conectar y adjuntarse gracias a esto, pero cada acción de
Playwright (page.click(), page.fill(), locator()) ejecuta JavaScript
inyectado contra un DOM vivo (vÃa Runtime.callFunctionOn) para chequear
visibilidad/scroll/actionability antes de actuar — eso exige exactamente el
DOM↔JS enlazado y persistente que este proyecto evita a propósito (ver
"Interacción" y "Layout mÃnimo" para el porqué). No se intentó simular eso;
harÃa creer que funciona hasta el primer click real.
Input.dispatchMouseEvent (type: "mousePressed") y Input.dispatchKeyEvent
(type: "char"/"keyDown") implementan una interacción sin ejecutar JS de
la página — como un navegador con JavaScript desactivado:
y del evento:
<a href> → navega a esa URL (mismo camino que Page.navigate: cookies,
redirecciones y resolución de URL de la sesión aplican igual);<input>/<textarea> → lo enfoca, para que Input.dispatchKeyEvent
sepa dónde escribir (el foco se pierde en cualquier navegación);<button>, o <input type="submit"> → busca el <form> ancestro más
cercano, junta el name/valor actual de sus <input>/<textarea>
descendientes en una query string y navega a action?query (o a la URL
actual si no hay action).Input.dispatchKeyEvent con type:"char" agrega text al value del
nodo enfocado; con type:"keyDown" y key:"Backspace" borra el último
carácter.Ningún click ejecuta onclick ni corre <script> de la página — sigue sin
haber un DOM vivo ligado a JS, por las mismas razones que en "Layout mÃnimo".
Limitaciones adicionales: el click es por coordenada y únicamente (no hay
eje x en el layout — ver "Layout mÃnimo"); solo se arma un submit GET
(el method/action con POST no se soporta, no hay cuerpo de request);
<select>/checkboxes/radios no tienen semántica propia, se tratan como
cualquier otro nodo sin acción especial. Para saber dónde clickear, un
cliente CDP real primero hace DOM.querySelector y después
DOM.getBoxModel para obtener las coordenadas — igual que Playwright/Chrome
DevTools.
cargo run -- mcp corre kite-lite como servidor MCP (Model Context Protocol)
por stdio: JSON-RPC 2.0 delimitado por saltos de lÃnea en stdin/stdout, el
mismo transporte que usan Claude Desktop/Claude Code para lanzar herramientas
locales. Implementado a mano con serde_json (sin SDK de MCP) — misma
filosofÃa que el resto del proyecto con CDP: la superficie necesaria
(initialize, tools/list, tools/call) es chica.
Herramientas expuestas, todas sobre el motor ya existente:
fetch_page(url) — resumen liviano {url, title, text, links}, no
el árbol DOM completo con layout (serÃa demasiado JSON para el contexto
de un agente). No ejecuta JS de la página ni toca la sesión persistente.render_screenshot(url, format?) — trae la URL y la renderiza a PNG
(imagen en base64) o SVG (texto). Mismas limitaciones que "Renderizado
PNG/PDF": sin fuentes en el sistema, sale en blanco.eval_js(url, script) — trae la URL y evalúa JS aislado contra el
snapshot, mismo sandbox de siempre (sin red/filesystem/DOM real).browser_navigate(url), browser_click(selector), browser_type(text, selector?),
browser_get_dom(selector?), browser_screenshot(format?) — una única
sesión de navegación persistente por proceso (como cdp, no
multi-pestaña), con cookies/redirecciones igual que el resto del
proyecto. browser_click reusa la misma lógica de
"Interacción: click, escritura y submit de formularios" pero ubicando el
elemento por selector en vez de coordenada y — más natural para una
herramienta MCP. El selector no es CSS real: tag (nombre exacto, p. ej.
button), * para cualquier tag, o tag[attr=value]/[attr=value]
filtrando por type o name (p. ej. input[type=submit]) — los únicos
dos atributos que el DOM interno guarda como campo propio. Sin clases,
ids, ni combinadores. Esto importa en la práctica: un <input type=text>
y un <input type=submit> comparten tag, asà que sin el filtro por
atributo solo se puede alcanzar el primero en orden del documento.Un error de una herramienta (selector que no matchea, fetch fallido, etc.)
se devuelve como isError: true con el mensaje en el contenido — asà el
agente lo ve y puede reaccionar, en vez de que falle la llamada JSON-RPC
completa.
Se verificó además con un cliente MCP real, no solo hablando el protocolo
a mano: scripts/mcp_ollama_bridge.py
conecta un modelo de Ollama Cloud (con tool-calling nativo) al servidor
MCP de kite-lite, dejando que el modelo decida qué herramienta llamar y
con qué argumentos — se probaron asà las 9 herramientas, incluyendo el
flujo completo browser_navigate → browser_click → browser_type →
submit de formulario, y captura de pantalla en ambos formatos. Está
configurado también en claude_desktop_config.json para Claude Desktop,
aunque esa integración puntual no se validó con una conversación real en
la app.
Además de sus propias herramientas, kite-lite detecta formularios
anotados con los atributos declarativos de
WebMCP
(toolname, tooldescription, toolparamdescription, toolautosubmit,
required) y los expone como tools adicionales:
fetch_page, browser_navigate,
browser_click) incluye un campo tools con las tools detectadas: su
nombre, descripción, autosubmit, y un inputSchema JSON generado a
partir de los campos del formulario (<select> se vuelve un enum de
strings con sus <option value>; type="checkbox"/"number" se
infieren como boolean/number; el resto, string).browser_call_tool(name, arguments?) llena esos campos con
arguments (usando el valor actual del campo si falta alguno) y
envÃa el formulario, igual que browser_click sobre un submit.LÃmites explÃcitos: solo el subset declarativo (atributos HTML)
está soportado — la API imperativa (navigator.modelContext.registerTool()
en JS) no, porque requerirÃa ejecutar JS de la página contra un DOM vivo,
justo lo que kite-lite evita a propósito (ver "Próximas capas" más abajo).
El envÃo del formulario es GET únicamente, sin cuerpo de request — la
misma limitación que el submit por click.
kite-lite webmcp-lint <url|page.json|archivo.html> [--json] valida los
formularios toolname="..." de una página contra reglas prácticas, antes
de publicarla:
Chequeos, de más a menos grave:
tooldescription;
hay dos formularios con el mismo toolname en la página (un agente no
puede distinguir cuál invocar).toolname con caracteres fuera de [A-Za-z0-9_-] (algunos
backends de tool-calling lo rechazan); el formulario no tiene action;
un campo sin name (queda fuera del schema, invisible para el agente);
un <select> sin ninguna <option> (enum vacÃo).method="post" — kite-lite solo puede simular
un submit GET, asà que browser_call_tool no refleja el comportamiento
real; conviene probarlo también en un navegador con WebMCP nativo. Un
campo sin toolparamdescription (no es obligatorio, pero ayuda al agente).No reemplaza probarlo en un navegador real con WebMCP activo — es un chequeo rápido y local de los errores más comunes antes de llegar ahÃ.
kite-lite a11y-lint <url|page.json|archivo.html> [--json] — mismo
formato de entrada y salida que webmcp-lint, pero para un puñado de
reglas de accesibilidad prácticas, no una auditorÃa WCAG completa:
Reglas, todas Warning hoy (el comando sale con código 0 aunque haya
hallazgos — la separación por severidad ya está lista para cuando haga
falta una regla que sà deba romper el build):
<img> sin atributo alt (alt="" para decorativas no cuenta como
falta).<a> sin texto y sin una imagen descendiente con alt no vacÃo.<h1> en la página.<h1> seguido de <h3> sin <h2>).<html> sin atributo lang.kite-lite social-lint <url|page.json|archivo.html> [--json] simula lo
que mostrarÃa un bot de Twitter/Slack/Facebook/WhatsApp al compartir el
link: resuelve tÃtulo, descripción e imagen siguiendo la misma cadena de
fallback que usan esos crawlers (Open Graph → Twitter Card → meta/<title>
simple), y avisa cuándo ese preview va a salir degradado.
og:title, ni twitter:title, ni <title>) — la mayorÃa de los
bots mostrarÃa solo la URL pelada.og:image/twitter:image); sin descripción
resoluble (ni meta/OG/Twitter ni texto de la página).Esto requirió que kite-lite empiece a capturar <meta> del <head>
(Page.meta), que antes se descartaba por completo junto con el resto
del <head>.
kite-lite seo-lint <url|page.json|archivo.html> [--json] — cuarto
hermano de los linters, para SEO básico. No repite los chequeos de
encabezados de a11y-lint (múltiples <h1>, saltos de nivel).
<title> por completo; <meta name="robots">
incluye noindex (la página está explÃcitamente excluida de la
indexación — fácil de olvidar prendido en producción).<title> fuera de 10-60 caracteres; sin
<meta name="description">; descripción fuera de 50-160 caracteres;
sin ningún <h1> en la página.El despliegue probado usa una imagen multi-stage y un usuario sin privilegios:
El puerto CDP debe mantenerse en loopback o protegerse con un túnel y autenticación. No se recomienda exponerlo directamente a Internet.
Medido en el VPS real (Docker, cgroup v2, memory.peak, contra
https://example.com), bisectando --memory/--cpus hasta encontrar
el punto de falla:
| Modo | Pico de memoria real |
|---|---|
fetch / --js (sin render) | ~4.1–4.2 MiB |
--png | ~4.5 MiB |
--pdf | ~5.3 MiB |
serve en reposo | ~4.0–4.4 MiB |
serve tras un parse+render png | ~6.0–7.0 MiB |
cdp en reposo | ~4.0 MiB |
mcp (initialize + fetch_page) | ~3.9–4.5 MiB |
No se pudo bajar de 6 MB de lÃmite porque Docker mismo lo rechaza
("Minimum memory limit allowed is 6MB") — no es un piso de kite-lite, es
un piso del motor de contenedores. El proceso en producción (7+ horas de
uptime real al momento de medir) confirma lo mismo: memory.peak de
4.46 MiB con un lÃmite de 256 MB, es decir, sobre-aprovisionado ~55x.
La cuota de --cpus no afectó la corrección de ningún resultado — incluso
en --cpus=0.01 (1% de un core) un render terminó en ~11 s sin fallar; en
esta carga la CPU importa para la latencia bajo concurrencia, no para si
funciona o no. --pids-limit=16 alcanza de sobra: en reposo el proceso
usa 4 hilos (el runtime de tokio lanza uno por core del host, sin relación
con --cpus).
Los 32m/0.2 de arriba son el mÃnimo recomendado con margen (no el piso
exacto de 6 MB/6 MiB) para absorber páginas más pesadas que una página de
ejemplo simple — DOMs más grandes, PNGs de mayor resolución. Para páginas
grandes en producción, medà de nuevo con tu propio contenido antes de
ajustar el lÃmite hacia abajo.
El roadmap original (fetcher/JS aislados, URLs/cookies/redirecciones, layout mÃnimo, interacción, PNG/PDF, compatibilidad CDP, servidor MCP) está completo, más el soporte declarativo de WebMCP agregado después. La limitación más grande que queda, y que no tiene una solución que no rompa el modelo de aislamiento de este proyecto: kite-lite no puede ver nada que dependa de JavaScript para renderizarse (SPAs, contenido cargado por fetch del lado cliente) — es la contracara directa de no tener un DOM vivo ligado a JS, la misma razón por la que ni la interacción real, ni Playwright, ni la API imperativa de WebMCP funcionan. No hay Ãtem de roadmap para esto porque resolverlo significarÃa abandonar esa decisión de diseño, no extenderla.
Showcase your server listing on GitHub or your project documentation. Embed this dynamic SVG badge to highlight official listing status and live engagement.
[](https://allmcps.com/mcp/kite-lite)<a href="https://allmcps.com/mcp/kite-lite"><img src="https://allmcps.com/api/badge/kite-lite?style=directory" alt="Kite Lite on AllMCPs" /></a>