В Notez есть два способа дать ИИ доступ к вашим заметкам. Это чат внутри приложения и MCP-сервер, через который ваш собственный ассистент — Claude Code, Cursor или любой другой — читает и пишет в то же рабочее пространство.
Задумывались они как две двери в одну комнату. На прошлой неделе кто-то задал разумный вопрос: может ли ассистент удалить заметку? С обеих сторон ответ был «нет», и это нормально. Ответ на следующий вопрос оказался неприятнее.
Шестнадцать против десяти
У чата было шестнадцать инструментов. У MCP — десять.
Этот разрыв никто не планировал. В августе я научил чат в приложении читать и менять свойства, теги и колонки доски — шесть новых инструментов. Они вышли в чате и там остались. Никто не решал, что MCP они не нужны. Никто не заметил, что у MCP их нет.
Хуже того, даже общие инструменты не совпадали, и два интерфейса ощущались как разные продукты. В чате был search_notes, в MCP — list_notes, который делал то же самое, но с большим числом фильтров. В чате был semantic_search, в MCP он назывался search_similar_notes. Одна возможность, разные названия, разная форма — такое расхождение никто не планирует, но рано или поздно о него спотыкаются все.
Так обычно и портится софт. Не из-за плохого решения, а из-за неповторённого: изменение было правильным для интерфейса, который оно затронуло, и ничего не сказало про тот, который не затронуло.
Что я не стал объединять
Очевидное решение — одна общая реализация, которую вызывают оба интерфейса. Я попробовал и отказался: два различия оказались несущими.
Результаты должны различаться. MCP отвечает в JSON и бросает типизированные ошибки, потому что разговаривает с программой. Чат отвечает текстом со ссылками вида [Budget 2024](note:a1b2…), потому что приложение потом разбирает их и рисует под ответом маленькие кликабельные чипы заметок. Заставьте чат возвращать JSON, и чипы исчезнут.
Ошибки тоже должны различаться. Когда чат натыкается на проблему, он возвращает предложение — «oldText was not found in the note; re-read it rather than guessing», — потому что модель читает его и исправляется на следующем ходу, укладываясь в бюджет из пяти раундов. MCP бросает исключение и оставляет решение клиенту. Навяжите обоим один контракт, и одному из них станет хуже.
Поэтому я объединил то, что должно совпадать: какие инструменты есть, как они называются и какие аргументы принимают, — а представление оставил своим у каждого интерфейса. Сторожит это тест: он собирает настоящий список инструментов MCP, сравнивает его с объявлениями чата и роняет сборку при любом расхождении. Разрешённое исключение было ровно одно, и тест называл его вместе с причиной: перенос карточки между колонками доски остался только в приложении, потому что доска казалась визуальной раскладкой, которую ассистент по ту сторону API не видит. Этого исключения больше нет: сегодня MCP тоже переносит карточки между колонками, а из инструментов, которых нет в чате, у MCP остались только два для загрузки файлов и, при подключении через вход в аккаунт, один, который показывает ваши рабочие пространства.
Комментарий «держите это синхронным» ничего бы не стоил. И его бы тоже проигнорировали, как и предыдущий.
Потом я взялся за перемещение
Удаление, закрепление и создание сразу вложенной заметки дались без труда. Перемещение заметки внутрь другой выглядело таким же простым: в приложении уже несколько месяцев есть пункт меню Переместить в..., так что сложная часть наверняка была сделана.
Не была.
Notez хранит дерево заметок в таблице замыканий (closure table): не только «родитель B — это A», а по строке на каждую пару «предок — потомок» с расстоянием между ними. Благодаря этому «покажи все заметки под этой, на любой глубине» — один запрос по индексу, а не рекурсивный обход.
Код перемещения переписывал эти строки для перемещаемой заметки. Только для неё.
Поэтому при дереве A > B > C перемещение B под D давало вот что:
Предки
B:D. Верно.Предки
C:AиB. Неверно.Aбольше никак не связана сC, аDнет вообще.
Хлебные крошки у C показывали Tree A › Tree B › Tree C, хотя B уже лежала под D. Баг всё это время был доступен из меню самого приложения. Нужно было только переместить заметку, у которой уже есть дочерние, а потом посмотреть на одну из них.
Я чуть не выпустил инструмент, с которым ассистент мог бы делать это сто раз в минуту.
Тест, который я мог и не написать
Исправление читает поддерево, удаляет только строки, ведущие из него наружу, и заново привязывает каждого потомка к новым предкам. Юнит-тесты прошли. Но база данных в них подменена моками, так что подтверждают они одно: код делает то, чего ждал его автор.
Сквозной тест — настоящий браузер, настоящая база, переместить заметку с дочерней, спуститься до внучки и прочитать её хлебные крошки — упал на первом же прогоне. На исправленном коде. Ради этого его и стоило писать.
Причина оказалась в детали, которую никто бы не угадал и никакой мок не показал бы: у заметки, созданной на верхнем уровне, нет в таблице замыканий строки на саму себя. У любой другой заметки есть строка «я свой собственный предок на расстоянии ноль», а у обычной заметки верхнего уровня её нет, потому что такую строку пишет только код, привязывающий родителя. Поэтому «выбрать всё под этой заметкой» возвращало дочерние заметки, но не саму заметку, и шаг удаления, которому велели убрать связи, ведущие из поддерева, бодро перерезал связь заметки с её собственными дочерними.
В итоге у нового родителя не осталось ни одной дочерней заметки, а единственным предком внучки стала заметка на два уровня выше.
Ручная проверка до этого проходила. Проходила случайно: заметку, на которой я проверял, раньше уже перемещал старый код, а он всегда записывал строку на саму заметку, так что у неё такая строка была.
В этом и весь довод в пользу тестов, которые работают с настоящей системой. Мок фиксирует то, во что вы верите. База данных фиксирует то, что есть в действительности. Когда они расходятся, лучше узнать об этом из теста, а не от человека, у которого заметки сами собой переставились.
Что получилось
Теперь у обоих интерфейсов одни и те же восемнадцать инструментов. Ваш ассистент может вложить заметку в другую, закрепить её, отправить в корзину и сообщить, что вместе с ней ушли четыре вложенные заметки. Так вы знаете масштаб случившегося до того, как решите, отменять ли его.
Заодно вышли два исправления поменьше. Список заметок через MCP раньше возвращал заметки из корзины вперемешку с живыми, если их не исключить явно, — вопреки собственной документации. А на вопрос «над чем я сейчас работаю?» чат сортировал заметки по дате создания и отвечал про текущую неделю заметками из марта.
Здесь нет ни одной функции, ради которой кто-то сделает скриншот. Это версия продукта, которая делает то, что уже обещали его собственные страницы помощи, а на определённом этапе жизни проекта такой релиз полезнее.
Читайте также
Зачем я сделал Notez — две задачи, с которых начался Notez, сохранение и поиск, и что его ИИ-чат может и не может менять в ваших заметках.
Четыре способа вернуть потерянное: отмена, версии, корзина и защита от перезаписи — стек отмены на 20 действий, 30 дней истории версий, корзина на 30 дней и защита, которая отклоняет устаревшее сохранение.
Notez: приложение для заметок, созданное для разработчиков — что такое Notez, откуда в него попадают заметки, как поиск их находит и сколько стоит каждый тариф.