Dos asistentes, las mismas herramientas

Product

Notez tiene dos formas de poner una IA a trabajar con tus notas. Está el chat dentro de la app y está el servidor MCP, con el que tu propio asistente — Claude Code, Cursor o el que uses — lee y escribe en el mismo espacio de trabajo.

Debían ser dos puertas a la misma habitación. La semana pasada alguien hizo una pregunta razonable: ¿puede el asistente eliminar una nota? La respuesta era no en los dos lados, y eso estaba bien. La respuesta a la siguiente pregunta fue menos cómoda.

Dieciséis contra diez

El chat tenía dieciséis herramientas. MCP tenía diez.

La diferencia no la decidió nadie. En agosto enseñé al chat de la app a leer y escribir propiedades personalizadas, etiquetas y columnas del tablero: seis herramientas nuevas. Ese trabajo llegó al chat y se quedó ahí. Nadie decidió que MCP no debía tenerlas. Nadie se dio cuenta de que no las tenía.

Peor aún, lo que tenían en común era tan incoherente que parecían productos distintos. El chat tenía search_notes; MCP tenía list_notes, que hacía lo mismo con más filtros. El chat tenía semantic_search; MCP lo llamaba search_similar_notes. La misma capacidad, otro nombre, otra forma: el tipo de divergencia que nadie planea y con la que todos acaban tropezando.

Así es como se pudre el software normalmente. No por una mala decisión, sino por una que no se repitió: un cambio correcto para la superficie que tocaba y que no decía nada de la que no tocaba.

Lo que no unifiqué

La solución obvia es una sola implementación compartida a la que llamen las dos superficies. Lo intenté y di marcha atrás, porque dos diferencias resultaron imprescindibles.

Los resultados tienen que ser distintos. MCP responde en JSON y lanza errores tipados, porque habla con un programa. El chat responde en prosa con enlaces como [Budget 2024](note:a1b2…), porque la app los extrae para mostrar, debajo de cada respuesta, los chips de nota en los que puedes hacer clic. Si el chat devuelve JSON, los chips desaparecen.

Los errores también tienen que ser distintos. Cuando el chat se topa con un problema, devuelve una frase — "oldText was not found in the note; re-read it rather than guessing" — porque el modelo la lee y se corrige en el turno siguiente, dentro de un presupuesto de cinco rondas. MCP lanza una excepción y deja que decida el cliente. Si impones un único contrato a los dos, uno de ellos empeora.

Así que unifiqué la parte que debe ser idéntica — qué herramientas existen, cómo se llaman, qué argumentos aceptan — y dejé la presentación en manos de cada superficie. La protección es un test que construye la lista real de herramientas de MCP, la compara con las declaraciones del chat y rompe el build ante cualquier diferencia. Había exactamente una excepción permitida, escrita en el test con su motivo: mover una tarjeta entre columnas del tablero se quedó solo en la app, porque un tablero parecía una disposición visual que un asistente al otro lado de una API no podía ver. Esa excepción ya no existe: hoy MCP también mueve tarjetas entre columnas, y las únicas herramientas de MCP que el chat no tiene son las dos que suben archivos y, con inicio de sesión, la que lista tus espacios de trabajo.

Un comentario que dijera "mantener esto sincronizado" no habría costado nada. Y se habría ignorado, igual que el anterior.

Luego intenté añadir "mover"

Eliminar, fijar y crear una nota ya anidada fue sencillo. Mover una nota dentro de otra parecía igual de sencillo: la app tiene desde hace meses la opción de menú Mover dentro de..., así que lo difícil seguramente ya estaba hecho.

No estaba hecho.

Notez guarda el árbol de notas como una tabla de cierre (closure table): no solo "el padre de B es A", sino una fila por cada par ancestro-descendiente, con la distancia entre ellos. Así, "muéstrame todas las notas que hay debajo de esta, a cualquier profundidad" es una sola consulta indexada en lugar de un recorrido recursivo.

El código de mover reescribía esas filas para la nota que se movía. Solo para la nota que se movía.

Así que, con A > B > C, mover B dentro de D daba esto:

  • Ancestros de B: D. Correcto.

  • Ancestros de C: A y B. Incorrecto. A ya no tiene nada que ver con C, y D no aparece por ningún lado.

La ruta de navegación de C mostraba Tree A › Tree B › Tree C mientras B estaba en realidad dentro de D. El bug se podía provocar todo ese tiempo desde el propio menú de la app. Solo hacía falta que alguien moviera una nota que ya tenía hijas y que luego mirara una de esas hijas.

Habría publicado una herramienta con la que un asistente podría hacer eso cien veces por minuto.

El test que casi no escribí

El arreglo lee el subárbol, borra solo las filas que salen de él y vuelve a enganchar cada descendiente a los nuevos ancestros. Los tests unitarios pasaron. Sustituyen la base de datos por mocks, así que lo único que confirman es que el código hace lo que su autor esperaba.

El test end-to-end — un navegador real, una base de datos real, mover una nota que tiene una hija, bajar hasta la nieta y leer su ruta de navegación — falló a la primera. Con el arreglo puesto. Para eso lo había escrito.

La causa era un detalle que nadie adivinaría y que ningún mock revelaría: una nota creada en el nivel superior no tiene fila propia en la tabla de cierre. Todas las demás notas tienen una fila que dice "soy mi propio ancestro a distancia cero"; una nota normal de nivel superior no la tiene, porque solo la escribe el código que le asigna un padre. Así que "seleccionar todo lo que hay debajo de esta nota" devolvía las hijas pero no la nota misma, y el paso de borrado, encargado de eliminar los vínculos que salían del subárbol, cortó tan tranquilo el vínculo de la nota con sus propias hijas.

El resultado: el nuevo padre se quedaba sin ninguna hija, y el único ancestro de la nieta era una nota dos niveles por encima.

Una comprobación manual anterior había pasado. Pasó por suerte: la nota con la que hice la prueba había cambiado de padre con el código antiguo, que siempre escribía esa fila propia, así que la tenía.

Ese es todo el argumento a favor de los tests que tocan lo real. Un mock codifica lo que crees. Una base de datos codifica lo que es verdad. Cuando no coinciden, más vale enterarse por un test que por alguien cuyas notas se reordenaron solas sin avisar.

Lo que obtienes

Las dos superficies tienen ahora las mismas dieciocho herramientas. Tu asistente puede colocar una nota dentro de otra, fijarla o mandarla a la papelera, y decirte que se llevó cuatro subnotas con ella, para que sepas el tamaño de lo que acaba de pasar antes de decidir si lo deshaces.

De paso llegaron dos arreglos menores. Listar notas por MCP devolvía notas de la papelera mezcladas con las activas salvo que las excluyeras explícitamente, lo que contradecía su propia documentación. Y preguntarle al chat "¿en qué estuve trabajando?" ordenaba por fecha de creación, así que respondía a una pregunta sobre esta semana con notas de marzo.

Nada de esto es una función que alguien vaya a capturar en pantalla. Es la versión del producto que hace lo que sus propias páginas de ayuda ya decían que hacía, y a partir de cierto momento en la vida de un proyecto esa es la versión más útil.

Sigue leyendo