DEV Community

Cover image for Un MCP para el fallo más común de la web
Ceci Olivera
Ceci Olivera

Posted on

Un MCP para el fallo más común de la web

Hay un algoritmo determinista en el centro de esta historia y un modelo probabilístico en el borde, y la mayor parte de lo que aprendí vive en el espacio que hay entre ellos.

Versión corta: una amiga diseñadora, Marta Herrera Hollingsworth, construyó, hace años, un algoritmo que genera paletas monocromáticas cuyas relaciones de contraste están garantizadas matemáticamente. Convertí ese algoritmo en una librería, y luego envolví la librería en un servidor MCP (Model Context Protocol) para que cualquier agente de IA que construya una interfaz pueda llamarlo y obtener colores que no pueden fallar el contraste WCAG 2.2. Esta es la historia de esa librería, de por qué existe, del problema sorprendentemente obstinado de lograr que los modelos usen su output tal como está escrito, y de dónde terminó todo: en un lugar mejor que donde empezó, aunque no el lugar que esperaba al principio.

Por qué el color, de entre todas las cosas

WebAIM escanea la home del millón de páginas más visitadas del mundo cada año, y durante siete años consecutivos, el texto de bajo contraste ha sido el fallo de accesibilidad más común que pueden detectar. El informe de febrero de 2026 lo encontró en el 83,9% de las páginas de inicio, frente al 79,1% del año anterior, con una media de 34 instancias separadas por página. Esto no es un caso límite. Es el estado base de la web.

Y la web está siendo escrita cada vez más por agentes. El A11y LLM Eval harness de Microsoft (construido por Michael Fairchild y colaboradores) mide lo accesible que es realmente el código generado por modelos, y la línea de base es sombría: sin orientación sobre accesibilidad, ocho modelos de frontera superaron las comprobaciones automatizadas del harness solo aproximadamente el 12% de las veces, con errores de contraste muy presentes. La parte esperanzadora de ese mismo informe es que la orientación funciona: una breve instrucción de "debe ser accesible" elevó las tasas de aprobación en unos 24 puntos, y una skill estructurada que hacía que el modelo revisara su propia salida funcionó aún mejor, resolviendo de media el 86% de los problemas que las herramientas automáticas pueden medir, siendo el contraste de color el problema que más persiste incluso así.

Este es un lugar perfecto para poner una herramienta.

Qué es MCP y por qué lo usé aquí

Antes de entrar en la librería, vale la pena explicar el andamiaje, porque la elección del protocolo importa.

MCP (Model Context Protocol) expone tres primitivas distintas que un modelo puede consumir durante su generación:

  • Tools: funciones que ejecutan lógica (cálculos, validaciones) y devuelven datos estructurados.

  • Resources: datos que el modelo lee como una fuente de verdad persistente, identificable por URI, no como el resultado puntual de una llamada.

  • Prompts: flujos de instrucción reutilizables que el propio servidor expone, en lugar de dejar que cada integración los reinvente.

Una API REST con function calling también te da tools, eso no es nuevo. Lo que no te da, sin convención adicional, es una forma estándar de exponer una fuente de verdad legible por URI o un flujo de pasos forzado que cualquier cliente MCP entienda igual. Elegí MCP por eso: porque palette://{hex}/{theme} es un recurso al que el agente puede volver en cualquier momento de la generación, y porque pensé que un prompt podía ser el flujo que forzara el orden correcto de pasos. Sobre esa segunda idea me equivoqué, y vuelvo a ello más adelante.

Qué hace la librería

La librería

Dale un color base y un tema (si se va a asentar sobre blanco o negro), y produce una pequeña escala monocromática, seis tonos en un rango 100–900, donde cada relación de contraste ha sido calculada. Pero los colores son la parte fácil. La parte valiosa es lo que la librería entrega junto a ellos: una matriz de compatibilidad. Para cada tono, te dice exactamente qué otros tonos (además de blanco y negro puros) son seguros para usar como texto sobre él en WCAG 2.2 AA, y qué emparejamientos solo son válidos para texto grande.

A través de MCP ofrece varias vías de entrada:

  • generate_palette: devuelve la paleta y su matriz de compatibilidad completa como datos estructurados.

  • validate_pairings: toma una lista de pares (primer plano/fondo) y califica cada uno contra esa matriz antes de que se escriba CSS, respondiendo con un contundente proceed: true o proceed: false.

  • generate_css_tokens: devuelve un bloque :root {} listo para pegar. Tiene una condición de entrada de la que hablo más adelante, porque cambia algo importante de lo que sigue.

  • check_contrast: un chequeo de contraste libre entre dos hex cualquiera, sin depender de una paleta generada. Esta escala es monocromática por diseño (un solo tono, variando su luminosidad) y eso es justo lo que la hace matemáticamente predecible. Pero casi ninguna interfaz real vive solo de eso: un estado de error, una etiqueta de oferta, un acento de marca, necesitan un color que la escala monocromática no tiene. check_contrast existe para que esa decisión también pase por la aritmética, en vez de por el ojo del modelo. Vuelvo más adelante sobre un problema que esto no resuelve del todo.

  • Un recurso, palette://{hex}/{theme}, que permite al agente extraer la matriz completa por URI antes de tomar una sola decisión de color.

Esto es exactamente el tipo de problema que conviene resolver con una herramienta en lugar de con el modelo, y la razón es precisa: el contraste es aritmética. Calcular la relación de luminancia entre dos valores hex es un cálculo, y los cálculos son el tipo de trabajo que conviene sacar de un sistema que predice texto. La idea completa es sacar esas matemáticas de la "cabeza" probabilística del modelo y ponerlas en una herramienta que devuelve una respuesta determinista.

Esa fue la apuesta. Exponer la librería sobre MCP, y los fallos de contraste deberían volverse casi imposibles.

El diagnóstico: por qué el modelo ignora las reglas

No se volvieron imposibles, aunque el modelo nunca ignoró la paleta. Llamaba a la herramienta, recibía la salida con cada color y cada regla intacta, y hacía algo más persistente: reescribía el CSS desde cero, con sus propias convenciones, sus propios nombres de variable, su propia estructura. Y, de forma crucial, sin las reglas de compatibilidad (el objetivo completo de la herramienta) que se perdían en algún punto entre leer el output y escribir la página.

Esto no era constante ni catastrófico. Los fallos eran minoría, pero persistentes, y siempre tenían la misma forma: la información de emparejamiento seguro, la razón de ser de la herramienta, se evaporaba en el viaje desde la salida de la herramienta hasta la hoja de estilos final.

¿Por qué pasa esto? No es un fallo de seguimiento de instrucciones. Es algo más simple y más difícil de combatir: un modelo entrenado con una cantidad enorme de CSS tiene un sentido muy fuerte de cómo se ve el CSS bien escrito, y cuando recibe un dato con forma de CSS, no lo copia, lo regenera con esa forma aprendida. Las anotaciones de accesibilidad nunca formaron parte de esa forma aprendida, así que no sobreviven a la regeneración.
Generalmente, el System Prompt tiene un peso jerárquico mayor porque define el "personaje" y las reglas operativas globales del modelo. Sin embargo, incluso un prompt muy fuerte podría perder contra el sesgo de exposición (los ejemplos que el propio modelo acaba de generar) o el sesgo de entrenamiento mencionado anteriormente.

El patrón es reconocible: el modelo conserva con fiabilidad las partes de la salida que no puede reconstruir de memoria (un hex concreto) y reescribe las partes que sí puede: la estructura, los nombres de las variables, el formato del comentario. No hay desobediencia en eso. Es la ruta de menor resistencia para un sistema que genera texto token a token, y la ruta de menor resistencia es siempre el CSS que ya ha visto millones de veces. Y ninguna cantidad de "IMPORTANTE: no reescribas esto" la vence de forma fiable, porque la instrucción es solo texto, y compite contra el sentido completo que el modelo tiene del medio.

Esto reordena el problema: si la instrucción pierde casi siempre contra el prior de entrenamiento, la única palanca que queda no es pedirle al modelo que se comporte de otra manera. Es cambiar el formato de lo que recibe, para que la opción correcta sea más barata de copiar que de reescribir.

Tres formas de investigar esto sin ser investigadora de IA

Una parte del proceso que no quiero ocultar: no soy investigadora de IA, y no entiendo los mecanismos internos de un transformador al nivel de los papers científicos. Pero eso no impide razonar bien sobre cómo se comporta uno, y lo hice apoyándome en tres fuentes distintas, cada una con un punto ciego que las otras dos no tenían.

La primera fue un NotebookLM cargado con una docena de papers sobre LLMs, usado como un oráculo al que hacerle preguntas en lenguaje natural: por qué un modelo ignora instrucciones explícitas pero copia ciertos formatos, qué hace que un fragmento de texto sea más fácil de regenerar que de copiar. De ahí salió la hipótesis que finalmente probé: que la diferencia entre un comentario de bloque y uno en línea no es cosmética, sino que cambia cuánto le cuesta al modelo separar el dato de la estructura que lo rodea.
Ese mismo ir y venir con el oráculo también produjo algo más estructural: una jerarquía de prioridades que explica por qué el cumplimiento de reglas lógicas puede perder de forma consistente contra los sesgos estadísticos del entrenamiento. Ese modelo mental es lo que me permitió convertir mis intentos fallidos en la arquitectura sobre la que descansa el proyecto hoy:

Nivel Componente Peso / Naturaleza Interacción con el Modelo
1 Prior de Entrenamiento El más alto. El "tirón gravitacional" estadístico. Gana por defecto. Los modelos prefieren regenerar formas familiares aprendidas de miles de millones de tokens antes que copiar reglas a medida que se sienten "antinaturales" frente a su entrenamiento.
2 System Prompt Alto (Estructural). Define la persona y los límites operativos. Marca el escenario, pero es texto. Puede ser anulado por el Prior u olvidado en ventanas de contexto largas ("Lost in the Middle").
3 Tools / MCP Medio (Lógico). Funciones externas para lógica determinista. El modelo trata la salida de la herramienta como "más texto" sobre el que improvisar. El propio marco del oráculo para vencer al Prior aquí es Read-Plan-Generate. Forzar al modelo a planear y validar antes de generar. Funciona, pero solo cuando algo realmente impone la secuencia; dejado como sugerencia, el modelo salta directo a generar.
4 Skills Variable. Capacidades aprendidas (por ejemplo, código, matemáticas). ¿Son system prompts? Por lo general no. Las skills son capacidades incorporadas en los pesos mediante SFT/RLHF. Invocas una skill a través de un prompt, pero la skill en sí forma parte del repertorio interno del modelo.

La segunda fue el modelo implementador, en sesiones de trabajo reales: proponía formatos concretos y, por lo que preservaba frente a lo que eliminaba al generar código, revelaba cuáles de esos formatos resistían de verdad la regeneración.

La tercera fue la más directa: preguntarles a los propios agentes, después de que generasen algo, por qué habían hecho lo que hicieron. No doy por buena esa fuente sin matizarla. Un modelo explicando su propio comportamiento es, en el mejor de los casos, una reconstrucción plausible, no introspección real; pero más de una vez esas respuestas señalaron, con sus propias palabras, exactamente el punto donde la herramienta estaba fallando. Una de ellas terminó proponiendo dos arreglos arquitectónicos razonables. Ninguna de las tres fuentes por sí sola habría llegado hasta aquí.

Primera solución: comentarios en línea

Una vez que el diagnóstico estaba claro, la primera solución fue una cuestión de formato, no de instrucción.

Un comentario de bloque al principio del archivo es, para el modelo, documentación separable: se elimina sin coste, porque quitar un bloque entero no rompe nada alrededor. Un comentario en línea, pegado al final de una declaración, es distinto: para eliminarlo, el modelo tiene que editar esa línea concreta, una por una, en lugar de descartar un bloque completo de una vez. Esa fricción adicional es suficiente para que, la mayoría de las veces, no se moleste en hacerlo.

El formato ganador fue este:

:root {
    --color-100: #faf2f5; /* ✅ text→900·800·700  ⚠️ lg→600 */
    --color-300: #e9bfcc; /* ✅ text→900·800  ⚠️ lg→700 */
    --color-600: #c86f90; /* ✅ text→900  ⚠️ lg→100·800·white */
    --color-700: #bb4268; /* ✅ text→white·100  ⚠️ lg→300·900 */
    --color-800: #67273f; /* ✅ text→white·100·300  ⚠️ lg→600 */
    --color-900: #3b1521; /* ✅ text→white·100·300·600  ⚠️ lg→700 */
}
Enter fullscreen mode Exit fullscreen mode

Cada variable lleva soldada, como comentario en línea, la lista exacta de fondos sobre los que es segura. El modelo copia el bloque entero, :root {} incluido, porque separar la regla de la variable cuesta más que copiar las dos juntas.
Al poner el comentario inline al final de la variable, se está creando un vínculo sintáctico (Contextual Binding). Para el modelo, el token de la variable CSS y el token de la regla de accesibilidad están tan cerca en el espacio de atención que separarlos requeriría "nadar contra corriente" de su entrenamiento previo. El comentario se procesa como parte de la unidad lógica de la declaración.

Llegar aquí costó varios intentos fallidos, y cada uno enseñó algo. Un aviso en mayúsculas dentro de un comentario ("COPIAR TAL CUAL") no cambió nada: un comentario que ordena copiarse sigue siendo, para el modelo, un comentario de una herramienta que no tiene capacidad real de forzar al modelo. Una tabla en texto plano antes del CSS se leía y se ignoraba igual, porque el modelo escribía su propio CSS de todas formas. El formato que funcionó no fue el más legible para un humano: fue el que más se parecía a lo que el modelo ya quería escribir.

Segunda intento: el flujo de planificación forzada

Los comentarios en línea resuelven qué sobrevive una vez que el modelo tiene el CSS en la mano. No resuelven si el modelo llegó a esa CSS habiendo comprobado los pares antes de escribir nada. Para eso construí plan-palette-usage, un prompt de MCP que estructura la secuencia completa en cuatro pasos: leer la matriz, planificar en texto plano cada par antes de escribir CSS, validar esa lista con validate_pairings, y solo generar los tokens si la validación entera vino limpia.

La idea era convertir un problema de criterio, que los modelos manejan de forma inconsistente, en un problema de ejecución de pasos, que manejan mejor. Funcionaba, cuando se invocaba. El problema, que tardé en ver con claridad, estaba en esa condición.

El matiz que cambió todo: los prompts necesitan un humano

Asumí que el modelo podía activar plan-palette-usage por su cuenta, igual que activa cualquier otra herramienta cuando le conviene. Eso es falso, y no por una limitación de tal o cual cliente: es así por especificación.

De las tres primitivas de MCP, los tools son model-controlled: el modelo decide cuándo llamarlos. Los prompts son user-controlled: solo se disparan si una persona los invoca explícitamente. Lo que cambia entre clientes es la forma de esa invocación, no quién la dispara. En Claude Code, "explícitamente" significa literal: hace falta escribir el comando exacto, /mcp:accessible-palette:plan-palette-usage, autocompletado incluido; pedirle al modelo en una instrucción en lenguaje natural que "use el prompt X" no alcanza. En Cline sí alcanza. Si en mi propio mensaje le pido que ejecute plan-palette-usage, lo invoca sin que yo escriba el comando; pero la invocación sigue partiendo de que una persona lo pidió por su nombre o su intención, no de que el modelo decidiera por su cuenta que convenía usarlo. La barrera que importa para esta historia no es el formato del comando: es que en ningún cliente el modelo lo activa solo, sin que nadie se lo haya pedido.

En la práctica, esto significa que el flujo de cuatro pasos casi nunca se ejecuta. Nadie le va a enseñar a alguien sin perfil técnico ni el comando exacto ni la frase necesaria para disparar un prompt guiado al pedir una landing page con un "haz una página sobre X usando el palette mcp". Y la garantía de accesibilidad de esta librería no podía depender de eso.

Tercera solución: el gate en el servidor

Esto fue lo que realmente me hizo cambiar de capa: si el modelo no puede invocar el prompt por sí mismo, entonces toda la garantía descansaba en un mecanismo que, en el uso real, casi nunca se activa. Necesitaba algo que no dependiera de que nadie escribiera un comando.

validate_pairings siempre calculó el contraste de forma determinista; eso nunca fue el punto débil. El punto débil era que generate_css_tokens, el tool que entrega el CSS de verdad, no sabía nada de lo que había pasado antes. Se podía saltar la validación entera y pedir los tokens igual, y el servidor los entregaba sin preguntar. proceed: false era una convención de buena fe entre el prompt y el modelo; el servidor no tenía memoria de si esa convención se había respetado.

La corrección, en código, fue pequeña: el servidor ahora lleva un registro de qué combinaciones de hex y tema ya pasaron validate_pairings con éxito, y generate_css_tokens consulta ese registro antes de generar nada. Si la combinación no está validada, el tool lanza un error y se niega a responder, de la misma forma en que falla si le pasas un hex inválido, no como un aviso que el modelo puede leer o no.

Técnicamente, ese registro es un Set en memoria, instanciado una sola vez cuando arranca el proceso de Node. El transporte es StdioServerTransport, así que cada cliente que lanza el servidor hace nacer un proceso nuevo, y "sesión" no significa nada más sofisticado que eso: el tiempo de vida de ese proceso. No persiste en disco, no tiene TTL. Es deliberadamente la unidad más simple que podía funcionar, y crucialmente, no depende de que nadie escriba nada a mano: vive en un tool que el modelo ya iba a llamar de todas formas.

Lo que esto cambia es dónde vive la garantía. Antes dependía de que una persona supiera invocar un prompt, y de que el modelo respetara una convención de texto. Ahora no hay decisión posible: o la validación pasó antes, en este mismo proceso, para ese hex y ese tema, o el tool no produce salida.

Sigo siendo honesta sobre el límite real: esto no impide que un agente escriba CSS a mano, ignorando la herramienta por completo, con los colores que le parezcan. Ningún servidor MCP puede impedir eso; vive fuera de su alcance. Lo que el gate garantiza es más estrecho y, a la vez, más sólido: si el agente usa esta herramienta para obtener los tokens, no hay forma de que los obtenga sin haber validado antes.

Cómo terminó todo

Lo que sostiene el sistema hoy es, en esencia, una sola pieza: el gate del servidor, apoyado en los comentarios en línea para que lo validado no se pierda al copiarlo. El prompt sigue existiendo, sigue siendo invocable por quien conozca el comando, pero no tengo claro qué ventaja real le queda sobre el gate. En teoría fuerza una relectura de la matriz y un razonamiento explícito antes de validar; en la práctica, no tengo evidencia de que eso cambie el resultado final de un modo que el gate, por sí solo, no consiga ya. Lo dejo documentado como lo que es: un añadido opcional, no el mecanismo del que depende nada importante.

Quiero ser concreta sobre qué significa "funciona", en vez de quedarme en "lo he usado y ha ido bien". Generé 45 páginas de demostración con varios agentes y modelos distintos corriendo sobre los agentes Open Code, Claude Code, Github Copilot y Cline, usando solo prompts mínimos en lenguaje natural, del tipo "haz una landing sobre X usando el palette mcp", sin invocar nunca el prompt guiado, y combinando esto con la skill de accesibilidad de github/awesome-copilot (la misma que su autor, Michael Fairchild, afinó contra su propio harness de evaluación antes de publicarla). Le pasé axe-core a las 45: 35 no tuvieron ninguna violación de ningún tipo. De las 10 que sí, 8 fueron específicamente de contraste de color; el problema exacto que esta librería existe para resolver, todavía colándose, pero en una fracción pequeña del total. Quiero señalar que la muestra es extremadamente pequeña para obtener ninguna estadística. Les animo a ponerlo a prueba. Las capturas y el detalle completo están en el repositorio, en /demo.

Una de las muestras de demo. Una web para un campamento de verano con tonos verdes y amarillos

No siempre sale perfecto: de vez en cuando se cuela un fallo. Ya no en los tokens que el servidor entrega, que están cerrados matemáticamente, sino en el CSS de componente final, donde el modelo todavía puede ignorar el manifiesto si decide escribir a mano por fuera de la herramienta, o en los colores de acento, donde no tengo todavía el mismo tipo de garantía. El comportamiento de los modelos de lenguaje es probabilístico, y ningún parche (ni siquiera uno que vive en el servidor) es hermético contra un prior de formato que actúa por fuera del protocolo.

No voy a poner un número de benchmark propio encima de esto más allá de lo que ya he compartido: la accesibilidad depende del contexto, tu modelo, tus prompts, tu mezcla de tareas mueven el resultado. Así que te invito directamente: instala el servidor, pruébalo contra tus propios flujos de trabajo, corre tu propio axe-core o Lighthouse contra lo que te genere, y cuéntame qué te da. Si encuentras un caso donde el modelo se sale del carril, o un formato que funciona mejor que el mío, quiero saberlo, es la única forma de seguir afinando esto.

Lo que sí defiendo sin reservas es la dirección. Un puñado de fallos ocasionales, ahora acotados a la parte del proceso que el protocolo no puede tocar, vive en un mundo distinto al de "texto de bajo contraste en el 84% de las páginas" o a una tasa de aprobación no guiada del 12%. No estás instalando una restricción dura sobre todo el proceso; estás instalando una restricción dura sobre la parte aritmética, e inclinando con fuerza, con texto y con estructura, la parte que sigue siendo probabilística.

Qué viene después

Quedan dos cosas abiertas, y prefiero nombrarlas con la misma honestidad que el resto del artículo en vez de dar la sensación de que aquí ya no hay nada que arreglar.

La primera es mía: encontrar para los colores de acento algo equivalente a lo que los comentarios en línea logran para la paleta base, de forma que un acento validado con check_contrast quede de algún modo soldado al CSS, en vez de perder esa validación en la primera edición.

La segunda es de Marta: está trabajando en adaptar el algoritmo original a APCA (Accessible Perceptual Contrast Algorithm), el método de contraste perceptual que se está definiendo para WCAG 3.0.


El algoritmo de la paleta fue creado por Marta Herrera Hollingsworth; la librería y el servidor MCP son míos. Esta pieza es un acompañamiento práctico de mi ensayo anterior, "As We May Code: Why Software Is a Human Problem Dressed in Logic".

Datos de motivación: WebAIM Million 2026 y el informe A11y LLM Eval de Michael Fairchild.
Formo parte del GitHub Accessibility Advisory Panel; fue ahí donde conocí el trabajo de Michael Fairchild que motiva este proyecto.

Top comments (0)