---
id: 03-mcp-tools
updated: 2026-08-16 13:45 GMT+3
---

# MCP-инструменты Демонстратора

Карта инструментов (имя → зачем). Полные схемы параметров **не** дублируются здесь.

## Параметры инструментов

После подключения MCP клиент вызывает `tools/list`. Сервер отдаёт канонические `inputSchema` (required, типы, enums, лимиты).

- Бери аргументы **только** из `tools/list` / подсказок клиента — не из этой страницы и не из догадок.
- Этот файл — ориентир «какой tool выбрать». Контракт вызова — у MCP.
- Без токена схемы недоступны (`401`).
- Нет нужного tool в `tools/list` → **стоп**, спроси пользователя. Не обходи дырку delete+create / массовой перезаписью.
- Soft-delete в корзину, перенос, смешивание материалов, `replace_all` поверх живого контента — **только** после описания процедуры и прямого подтверждения пользователя (см. `README.md`, блок СТОП).
- **Вне MCP (не ищи в `tools/list`):** permanent `delete-project` / `delete-resource`, снятие share-ссылок, invite/ACL пространства, suggestions propose/apply (WIP), restore из entity-snapshot / backup ZIP.

## Подключение к MCP

Подпишись на remote MCP-шлюз на сервере Демонстратора. Клонировать репозиторий и запускать `node` локально не нужно.

Токен уже лежит в конфиге MCP-клиента в IDE (`mcp.json` → `headers.Authorization`, значение после `Bearer `). В Личном кабинете он генерируется один раз при первичной настройке — не запрашивай его заново и не ищи в переменных окружения.

```json
{
  "mcpServers": {
    "demonstrator-mcp": {
      "url": "https://app.longread.agency/mcp/demonstrator/mcp",
      "headers": {
        "Authorization": "Bearer <токен_из_mcp.json>"
      }
    }
  }
}
```

Эндпоинты шлюза:

| Метод | URL | Назначение |
|---|---|---|
| `GET` | `https://app.longread.agency/mcp/demonstrator/health` | Проверка живости |
| `GET` | `https://app.longread.agency/mcp/demonstrator/sse` | SSE (keepalive; Cursor сюда `url` не ставь) |
| `POST` | `https://app.longread.agency/mcp/demonstrator/mcp` | JSON-RPC / streamable HTTP — **это URL для IDE** |

- В конфиге Cursor / Claude Code в поле `url` указывай **`…/mcp`**, не `…/sse`.
- Заголовок `Authorization: Bearer <токен>` обязателен на `POST /mcp`.
- Без токена — `401`.
- Токен привязан к аккаунту: права и квоты — как в UI Демонстратора.
- MCP и публичный сайт — только `app.longread.agency`. Другие шлюзы Демонстратора не используй.
- Share `url` отдавай с хостом `https://app.longread.agency`. Если в ответе `localhost` / `127.0.0.1` — подставь этот host (token тот же) и проверь ссылку. Loopback человеку не отдавай.

## Happy path: материал с нуля

1. `list-projects` → `project_id` (или `create-project`).
2. `create-material` — `title` + `project_id`.
3. Markdown по `01-syntax.md` / `02-methodology.md`.
4. Запись тела — **только через Yjs collab-мост**, не прямым PATCH в БД:
   - полное тело: `apply-material-point-patch` с `mode: "replace_all"` + `content`  
     или `save-material-content` (тот же `POST /api/collab/materials/:id/patch`);
   - точечно (`find_replace_once` / `range_patch`) — **только при закрытой вкладке**; при открытом редакторе API вернёт 409;
   - title вместе с телом: `save-material-content` + `title`; theme — `apply-material-theme`.
   - **Переименование без записи тела** (например, снять суффикс «(копия)» после `duplicate-material`): `rename-material` (`material_id` + `title`). Контент не трогается.
5. Проверить `verify.ok` **и** `verify.integrity.ok` / `get-material`. `source`: `yjs_room` или `db_fallback`. Не сдавай по одному `verify.ok`.

**Запрещено** писать markdown через raw `PATCH /api/materials` с полем `content` (tool `update-material-content` удалён): открытый редактор перезапишет старым Yjs-документом.

Полный маршрут и сценарий без MCP — в `README.md`.

## Каталог (обзор)

Ниже — имя и краткое действие. Схемы полей — в `tools/list`.

## Проекты и пространства

| Инструмент | Действие |
|---|---|
| `list-projects` | Получить список проектов |
| `create-project` | Создать проект |
| `update-project` | Обновить проект |
| `list-spaces` | Получить список пространств |
| `get-space` | Получить пространство |
| `update-space` | Переименовать пространство |
| `list-space-members` | Список участников (только чтение) |

Создание пространства, приглашения, смена состава/ролей, permanent `delete-project` — только через UI, не через MCP.

## Структура

| Инструмент | Действие |
|---|---|
| `list-sections` | Список разделов (**с `sort_order`**, среди разделов) |
| `create-section` | Создать раздел |
| `update-section` | Обновить раздел (в т.ч. одиночный `sort_order`) |
| `reorder-sections` | Порядок **только среди разделов** (`sort_order`) |
| `delete-section` | Удалить раздел (soft delete) |
| `list-materials` | Список материалов (**с `sort_order`**) |
| `search-materials` | Полнотекстовый поиск по материалам |
| `create-material` | Создать материал |
| `duplicate-material` | Клонировать материал (суффикс «(копия)») |
| `rename-material` | Переименовать материал (только title, контент не меняется) |
| `update-material-placement` | Переместить материал / один `sort_order` |
| `reorder-materials` | Порядок уроков **внутри раздела** (или среди корневых) |
| `get-project-sequence` | Смешанный порядок карточек проекта: раздел ↔ корневой прототип |
| `patch-project-sequence` | Задать смешанный порядок (как DnD в ЛК) |
| `delete-material` | Удалить материал (soft delete) |

### Порядок: три разных оси (не путать)

| Что меняешь | Tool | Где видно |
|---|---|---|
| Раздел ↔ корневой прототип вперемешку | **`get-project-sequence` / `patch-project-sequence`** | сетка карточек проекта в ЛК |
| Порядок разделов между собой | `reorder-sections` (`sort_order`) | среди разделов |
| Порядок уроков внутри раздела | `reorder-materials` + `section_id` | внутри раздела |

**Можно** чередовать: раздел → прототип → раздел → прототип. Это штатный `project_sequence`, не запрет продукта.

Стартовый/пустой sequence (миграция + UI-fallback) часто выглядит как «сначала все разделы, потом корневые» — **дефолт, не закон**. Не сочиняй «разделы всегда сверху» / «без разделов только плоский список».

Дерево слева sequence почти не отражает: смешанный список — в сетке карточек.

`patch-project-sequence`: `items: [{ "item_type": "section"|"material", "item_id": N }, …]` сверху вниз. В `items` только **корневые** материалы (без `section_id`). Перед вызовом — подтверждение пользователя (см. СТОП в `README.md`).

`reorder-sections` / `reorder-materials` **не** умеют смешивать типы — для этого только sequence.

## Медиа

| Инструмент | Действие |
|---|---|
| `list-resources` | Список ресурсов (фильтр: project_id, material_id, q) |
| `presign-media-upload` | Init upload audio/video/file/web_archive → `resource_id` + `upload_url` (PUT с машины клиента) |
| `complete-media-upload` | Complete после PUT → `id`, `status`, `full_url` (CDN), `markdown_snippet` |
| `upload-media-from-fs` | Загрузка по `file_url` (HTTPS) или `file_path` **уже на сервере MCP** — не Windows-path клиента |
| `get-resource-status` | Статус обработки медиа (poll до ready / failed) |
| `insert-media-block` | Вставить медиа-блок; `display_name` / `name` → имя в медиатеке |
| `rename-resource` | Переименовать ресурс |

Permanent удаление ресурса — только UI (`delete-resource` в MCP нет).

Upload и insert: осмысленный `filename` / `display_name`. «Без названия» в медиатеке — брак; сразу `rename-resource`.

### Kind и CDN URL — не путать

В markdown / обложку / логотип ставь **только `full_url` (или `url`) из ответа upload / `list-resources` / `get-resource-status`**. Не собирай CDN-путь руками и **не «чини»** подстроки в URL.

| Назначение | `kind` | Как залить | Типичный `full_url` |
|---|---|---|---|
| Картинка для показа (логотип, обложка, иллюстрация, `![…](…)`) | `image` | curl multipart (ниже) или `upload-media-from-fs` с `kind: image` | `https://cdn.longread.agency/images/{hash}.webp` (растр) или `…/{hash}.svg` (SVG) |
| Скачиваемый файл (PDF, ZIP, DOCX…) | `file` | presign → PUT → complete | `…/files/originals/{id}.{ext}` — так и должно быть |
| Аудио / видео после `ready` | `audio` / `video` | presign → … | `…/audio/processed/…` или `…/video/processed/…` |
| ИИ-HTML | `web_archive` / bake | см. виджеты | `…/archives/{id}/…` |

**Частая ошибка:** логотип/обложку залить как `file` (получится `files/originals/…`), потом «исправить» на выдуманный `files/processed/…`. Пути `files/processed/` **нет**. Для показа — перезалей как **`image`** и возьми новый `full_url` с `/images/…webp`.

Картинка-image: jpeg / png / webp / gif / svg. SVG принимается как `image` **без растеризации** — сервер санитизирует (вырезает скрипты/обработчики) и хранит как `…/images/{hash}.svg`. Растр (`jpeg/png/webp/gif`) пережимается в `…/images/{hash}.webp`. SVG как `file` — только для скачивания, не для показа.

### Как загрузить локальный файл → Object Storage → CDN

MCP не читает файлы с диска агента (`C:\…`).  
Не вызывай `upload-media-from-fs` с Windows-path.  
`insert-media-block` файл не загружает. Сначала upload, потом insert.

**Запрещено**

- Писать свой upload (Python, Node).
- Искать `upload-local-media.ps1` вне текущего workspace.
- Спрашивать пользователя «как залить».
- Класть файлы на VPS «под from-fs».
- Передавать `file_base64` в MCP.
- Заливать файл с кириллицей в пути или имени (сначала скопируй в ASCII-имя).
- Подменять в URL `originals` ↔ `processed` / `images` / выдуманные сегменты.
- Искать токен в переменных окружения шелла: там может лежать старый/отозванный токен. Токен для `curl` — тот же Bearer, что стоит в конфиге MCP-клиента в IDE (сервер `demonstrator-mcp` / `user-demonstrator-mcp`), или спроси у пользователя.

### Дерево решений: как загрузить файл

```
Файл — картинка для показа? (PNG/JPEG/WebP)
  ├─ Файл локально на твоём диске (C:\...)
  │   → Shell curl multipart (↓ см. «Картинка — curl»).
  │   → НЕ вызывай recipe-upload-and-insert / upload-media-from-fs с file_path.
  │   → НЕ вызывай presign-media-upload (пресайн не для image).
  │
  ├─ Файл уже на публичном HTTPS
  │   → recipe-upload-and-insert с file_url + kind: image
  │
  └─ Файл на сервере MCP (не твоя машина)
      → upload-media-from-fs с file_path

Файл — аудио / видео / file / web_archive?
  ├─ Файл локально на твоём диске
  │   → presign-media-upload → Shell curl PUT → complete-media-upload
  │   → ИЛИ recipe-upload-and-insert если не нужно вручную PUT
  │
  ├─ Файл на публичном HTTPS
  │   → recipe-upload-and-insert с file_url
  │
  └─ Файл на сервере MCP
      → upload-media-from-fs с file_path
```

#### Выбор пути

| Что на диске агента | Как |
|---|---|
| Картинка для показа (`image`) | Shell `curl` multipart (ниже) |
| audio / video / file / web_archive | MCP `presign-media-upload` → Shell PUT → MCP `complete-media-upload` |
| Уже HTTPS URL | MCP `upload-media-from-fs` с `file_url` + `material_id`/`project_id` (+ верный `kind`) |

После upload вызови MCP `insert-media-block` или вставь `![…](full_url)` в content.  
Processing-медиа: `get-resource-status` до `ready` / `failed`.  
На каждом upload передай `material_id` или `project_id`.

**ASCII-only на диске.** Путь и имя файла для upload — только латиница/цифры (`C:\work\media\fig-01.png`).  
Кириллица в папках или имени файла ломает `curl` у агентов.  
Русское имя в медиатеке — после upload: `rename-resource` или `display_name` / `name` в `insert-media-block`.

#### Картинка — curl

Токен — тот же Bearer, что стоит в конфиге MCP-клиента в IDE (сервер `demonstrator-mcp` / `user-demonstrator-mcp`). **Не ищи токен в переменных окружения шелла** — там может лежать старый/отозванный токен после ротации. Если в конфиге IDE токена нет — спроси у пользователя. Prod API: `https://app.longread.agency`.

Если `POST /api/resources` отвечает `401` — токен не тот или ротирован: **стоп и спроси у пользователя актуальный токен**. Не перебирай варианты.

```powershell
curl.exe -sS -X POST "https://app.longread.agency/api/resources" `
  -H "Authorization: Bearer ТОКЕН_ИЗ_КОНФИГА_MCP" `
  -H "Accept: application/json" `
  -F "file=@C:\work\media\fig-01.png;filename=fig-01.png;type=image/png" `
  -F "material_id=123" `
  -F "original_name=fig-01.png"
```

1. Из JSON возьми `id` и `full_url` (или `url`) — должен быть путь `/images/…webp`.
2. Вызови `insert-media-block` (`kind: image`) или вставь `![подпись](full_url)`.
3. Задай читаемое имя. Иначе вызови `rename-resource`.

Для image нет `presign-media-upload`.  
Нет MCP-tool, который читает локальный png.  
Если `upload-local-media.ps1` уже лежит в текущем workspace — можно вызвать его вместо curl. Иначе делай curl. Не сканируй диск.

#### audio / video / file / web_archive

1. MCP `presign-media-upload` (`kind`, `filename`, `size`, `material_id` или `project_id`).
2. Shell: `curl.exe -sS -X PUT "<upload_url>" -H "Content-Type: …" --data-binary "@C:\path\to\file"`.
3. MCP `complete-media-upload` (тот же `material_id` / `project_id`).
4. MCP `insert-media-block`.

`kind: file` — только для скачиваемых вложений. Не для логотипа/обложки/блока картинки.

#### Уже публичный HTTPS

MCP `upload-media-from-fs`: только `file_url` (https://…). Не `C:\…`. Для показа картинки — `kind: image`.

### Медиатека проекта

Медиатека = `resource_links` на `project_id` / `material_id`.

- Upload без `material_id`/`project_id` — MCP отклоняет вызов.
- При записи content через collab backend линкует твои ресурсы из markdown в материал/проект.
- Чужой CDN URL / demo-заполнитель без upload в медиатеку не попадает.

### Что не делать

- Не писать свои upload-скрипты сверх `curl` / MCP выше.
- Не искать helper вне текущего workspace.
- Не считать `insert-media-block` заменой upload.
- Не вызывать несуществующие base64-tools (`upload-media-resource` / `upload-resource`).
- Не класть медиа на диск VPS «под from-fs».
- Не подставлять Windows-path в `upload-media-from-fs`.
- Не загружать без `material_id` / `project_id`.
- Не заливать картинку для показа как `file` и не править CDN-путь вручную.

## ИИ-HTML (`:::ai_widget`)

| Инструмент | Действие |
|---|---|
| `create-widget` | Создать черновик ИИ-HTML. **Обязательно** `name` (или `caption` / `original_name`) — имя в медиатеке |
| `get-widget` | Получить ИИ-HTML; у `linear_dialog` — ещё `archetype` + `scenario` |
| `save-widget-draft` | Сохранить HTML в черновик (свободная вёрстка). **Не** для правок линейного диалога |
| `assemble-linear-dialog` | Собрать **или обновить** линейный диалог из полного `scenario` JSON (без Studio AI). Фича `ai_widget_templates` (тестеры) |
| `bake-widget` | Собрать ИИ-HTML в готовый архив |
| `fork-widget` | Форкнуть ИИ-HTML в другой материал |
| `insert-ai-widget-block` | Вставить `:::ai_widget` в материал |

### Имя в медиатеке

Ресурс без нормального `original_name` в UI выглядит как «Без названия» / бессмысленный дефолт. Это брак.

- `create-widget`: всегда передай `name` (коротко по смыслу: «Квиз онбординг», не uuid).
- Upload: осмысленный `filename`; если в библиотеке плохое имя — сразу `rename-resource`.
- `insert-media-block`: можно `display_name` / `name` — MCP переименует ресурс.

**Не готово:** виджет/файл в медиатеке без читаемого имени.

### Путь создания ИИ-HTML (только IDE + MCP)

```
локальный .html + маркер + отладка → вопрос «bake на CDN?» → «да» →
create-widget → save-widget-draft → bake-widget (ready) →
insert-ai-widget-block (fence = id, не draft) →
poll archives/ до совпадения маркера → get-material
```

1. Запиши и отладь HTML в workspace (`widgets/<slug>.html`). Добавь уникальный маркер.
2. Спроси пользователя про bake/insert. Жди прямого «да». Не вызывай `bake-widget` на каждой правке.
3. `create-widget` → `save-widget-draft` (HTML с диска) → `bake-widget`.
4. **`insert-ai-widget-block`** — в блок только **CDN `entry_url`** после bake (`ready`). Draft URL и bare id в материал запрещены.
5. Poll: `get-resource-status` / `archive-info` → HTML с `archives/` (`entry_url`, учти `?v=`). Маркер должен совпасть с локальным файлом. `get-widget` и draft — не критерий сдачи.
6. `get-material` — fence на месте. Путь к локальному файлу — пользователю.

В `create-widget` всегда передай **`name`** (имя в медиатеке). Без имени — не готово; при ошибке — `rename-resource`.

### Шаблон: линейный диалог (`assemble-linear-dialog`)

Когда нужен **диалоговый плеер**, не пиши HTML с нуля — собери сценарий JSON и вызови `assemble-linear-dialog`. Shell канонический; агент заполняет **данные** (пул портретов, локации, реплики с парой слотов).

Предпочтительный HAPPY PATH: `recipe-linear-dialog` (создаёт виджет + собирает диалог + валидирует схему, ловит `portrait_url` и пропущенный `id`).

```
scenario.json на диске → recipe-linear-dialog (или create-widget → assemble-linear-dialog) →
вопрос «bake на CDN?» → «да» → bake-widget → insert-ai-widget-block (CDN URL)
```

#### Схема сценария (и частые ошибки)

Эталонный полный пример: `docs/templates/linear-dialog/scenario.example.json`. Основные поля:

| Поле | Правильно | Частая ошибка |
|---|---|---|
| `archetype` | `"linear_dialog"` **внутри `scenario`** | кладут в корень manifest'а, не в scenario |
| Персонаж — портрет | `imageUrl` + `portrait: "auto"` | `portrait_url` (несуществующий ключ) |
| Персонаж — ID | `id: "p1"` | нет поля `id` |
| Шаг — ID | `id: "s1"` обязательно | пропускают `id` |
| Шаг — локация | `locationId` обязательно | нет `locationId` → 422 |
| Шаг — портреты | `leftPortraitId`, `rightPortraitId` (два слота) | один слот или нет пары |
| Шаг — кто говорит | `speakerSide: "left"` или `"right"`, плюс `speaker` = id персонажа | нет speaker или неясно |
| Шаг — следующий | `next: "s2"` или `null` у последнего | все `null` — плеер остановится после первого |
| `locations[]` | **обязателен** (минимум одна, можно с пустым `imageUrl`) | не передают — 422 «опционально» в старых текстах ошибочно |
| `locations[].id` | `id: "loc1"` | нет id |

1. Сохрани `scenario` локально (`widgets/<slug>-scenario.json`). Начни с `docs/templates/linear-dialog/scenario.example.json` — не угадывай поля.
2. На сцене всегда **два слота картинок**; `characters[]` — N вариаций (ракурсы/эмоции). На каждой реплике задай пару портретов и кто говорит (`speakerSide`: `left`|`right`).
3. Демо-медиа CDN: `avatar-bot.webp`, `avatar-user.webp`, `slider-1.webp` на `cdn.longread.agency/demo/`.
4. `create-widget` с `name` → `assemble-linear-dialog` (`widget_id` + `scenario`). Не нужен `save-widget-draft`.
5. Bake/insert — только после прямого «да» (как у свободного HTML). Insert пишет **CDN `entry_url`**, не id.
6. При 403 `ai_widget_templates_forbidden` — шаблоны только у тестеров; не обходи через свободный HTML без согласования.

### Правка существующего линейного диалога

Тот же `assemble-linear-dialog` — **создаёт и обновляет**. Отдельного «refine» в MCP нет (Studio AI через MCP не вызываем).

```
get-widget → взять scenario → править JSON (локально или в ответе) →
assemble-linear-dialog (полный scenario) → при необходимости снова bake после «да»
```

1. `get-widget` (`widget_id`) → поля `archetype: linear_dialog` и `scenario`.
2. Если `scenario` пуст — вытащи из HTML `#vn-scenario` или восстанови из локального `widgets/<slug>-scenario.json`.
3. Внеси правки в **полный** scenario (реплики, портреты пула, `leftPortraitId`/`rightPortraitId`/`speakerSide`, локации, порядок `steps` / `next`). Не отправляй diff / SEARCH-REPLACE по shell.
4. Обнови локальный `widgets/<slug>-scenario.json`.
5. `assemble-linear-dialog` с тем же `widget_id` и **полным** `scenario` — пересоберёт draft.
6. **Запрещено** для диалога: `save-widget-draft` с ручным HTML плеера; правка CSS/JS shell; вызов `/api/ai/widgets/*/refine*`.
7. Повторный `bake-widget` / insert — только после прямого «да» пользователя (как при создании).

Чат-боты без MCP этот путь не используют. Они отдают HTML/markdown кодом и bake не делают.

**Не готово** (IDE): только bare id в fence; bake без «да»; bake без локального файла; bake без insert; сдача по draft при отстающем archive; fence с `draft_url`; ресурс в медиатеке без читаемого имени.

Серверный контракт — `05-html-widget-rules.md`.

## Корзина

| Инструмент | Действие |
|---|---|
| `list-project-trash` | Список удалённого |
| `restore-project-trash` | Восстановить (items: [{type, id}] или all: true) |

## Share-ссылки

Создание и обновление — да. **Снятие / unpublish ссылок через MCP нельзя** (только UI).

| Инструмент | Действие |
|---|---|
| `get-material-share-links` | Ссылки материала |
| `create-material-share-link` | Создать ссылку материала |
| `update-material-share-link` | Обновить уже созданную ссылку материала (`auto_update`) |
| `get-project-share-links` | Ссылки проекта |
| `create-project-share-link` | Создать или обновить (`refresh_only: true` — без ротации токенов) |
| `get-section-share-links` | Ссылки раздела |
| `create-section-share-link` | Создать или обновить (`refresh_only`) |
| `republish-comments-snapshot` | Обновить HTML-snapshot для **публичной** ссылки с правками. **Не** пишет тело урока (`content`) |

Ответ create/get содержит `url` — полный канонический URL.

Типы ссылок: `preview` (просмотр), `preview_comments` (с комментариями).

**Comment/share ≠ editor body.** `republish-comments-snapshot`, share-links с `preview_comments` и `get-material-preview-with-comments` — про публичный review/HTML. Текст урока в редакторе правят только `save-material-content` / `apply-material-point-patch`. В ответах материала нет внутренних publish-кэшей — только `content`.

## Чтение и экспорт

| Инструмент | Действие |
|---|---|
| `get-material` | id, title, content, theme, version, placement (whitelist) |
| `get-material-export-data` | Markdown + тема материала |
| `get-section-export-data` | Раздел + все материалы с контентом |
| `get-project-export-data` | Дерево проекта для HTML-ZIP |
| `get-material-preview-with-comments` | Read-only HTML snapshot+overlay; **не** источник для правки markdown |

## Предложения правок

Сейчас **вне MCP** (недоделано; вернём позже). Пиши тело сразу через collab (`save-material-content` / `apply-material-point-patch`).

## Запись контента

Источник правды при открытом редакторе — **Yjs collab**, не строка в БД. Все правки тела материала — через collab-мост.

| Инструмент | Действие |
|---|---|
| `apply-material-point-patch` | **Канон.** `replace_all` / `range_patch` / `find_replace_once` → `/api/collab/materials/:id/patch` |
| `save-material-content` | Полная замена тела тем же collab-мостом (`replace_all`) + опционально title/theme. По умолчанию `verify_readback: true` |
| `apply-material-theme` | Тема (palette / contentStyle / customOverlayCss) |

### Коридор при открытом редакторе

| Режим | Вкладка закрыта (`db_fallback`) | Вкладка открыта (живая комната) |
|---|---|---|
| `replace_all` / `save-material-content` | ок | ок → `yjs_room` (live в Коде, тост MCP). 409 возможна только при редкой гонке reconnect — повтори |
| `range_patch` / `find_replace_once` | ок (одна-две мелкие правки) | **409 `point_patch_blocked_editor_open`** |

Серия точечных патчей поверх живого Yjs даёт дубли `#`/`##`, обрыв+повтор куска, голые `===` / `:::/…`. При открытой вкладке: `range_patch` и `find_replace_once` заблокированы (409), `replace_all` / `save-material-content` пишут в Yjs, контент приходит в Код как remote change (тост «обновлён с сервера (MCP)»). Не «добивай» пятью `find_replace_once` после 409.

**Future (чат-ассистент по открытому материалу):** настоящий agent-WS peer с мелкими Y.Text ops «как юзер» — отдельно от массовой авторской переписки урока; массовые правки всё равно через `replace_all` + integrity.

### Режимы `apply-material-point-patch`

| Режим | Когда использовать |
|---|---|
| `replace_all` | Замена всего контента (основной путь «записать материал»; **единственный** при открытом редакторе) |
| `range_patch` | Правка по смещению — только при **закрытой** вкладке |
| `find_replace_once` | Замена по тексту — только при **закрытой** вкладке; не сериями на большом diff |

Все write-операции используют `expected_version` (tool подставит сам, если не передан).

Ответ содержит `source`:
- `yjs_room` — материал был открыт в редакторе; текст ушёл в Yjs, потом в БД;
- `db_fallback` — вкладка редактора не была открыта (нет живых WS).

`replace_all` / `save-material-content` при открытой вкладке: контент пишется в Yjs → приходит в Код как remote change (тост «обновлён с сервера (MCP)»). 409 (`editor_open_room_not_ready` / `collab_room_push_failed`) возможна только при редкой гонке reconnect — повтори запрос.

Точечный патч при открытой вкладке → **409 `point_patch_blocked_editor_open`**. Используй один `replace_all` целым телом.

После записи смотри в ответе:

1. `verify.ok` — байтовое равенство отправленного и прочитанного (**не** «материал логически целый»).
2. `verify.integrity.ok` (и/или `integrity` в ответе collab patch) — структура: нет дублей `#`/`##`, нет голых `===` / orphan `:::/…`, нет незакрытых блоков, нет повторённого куска.
3. `source` и хвост `get-material` глазами при сомнении.

**Не сдавай**, если `verify.ok` true, а `integrity.ok` false. Надёжнее один финальный `replace_all` / `save-material-content` целым файлом, чем много мелких `find_replace_once`.

Не пишите тело через raw PATCH «в базу». Вторичных «workspace»/publish body-полей в ответах нет — править нечего, кроме `content` через collab.

## Темы

| Инструмент | Действие |
|---|---|
| `list-themes` | Список пресетов: minimalism, draft, journal, neon, airy, modern |
| `apply-material-theme` | Применить тему. Palette / `contentStyle` / `customOverlayCss`; также `bgImageResourceId` + `bgImageMode` (`pattern` \| `frame`) для фона-картинки из медиатеки |
| `inherit-project-themes` | Сбросить темы разделов/уроков проекта на наследование от темы проекта |

`apply-material-theme` принимает произвольный theme object (merge через `MERGE_KEYS`). Фон-картинка:

- `bgImageResourceId` — id image-ресурса (или `null`, чтобы убрать);
- `bgImageMode` — `pattern` (плитка) или `frame` (фиксированный кадр); по умолчанию `pattern`.

Не подставляй картинку фона через `customOverlayCss` (`url(...)`) — используй поля темы. URL на shared/export резолвит платформа (`bg_image_url`).

Правила CSS для `customOverlayCss` — см. раздел «Правила CSS-генерации».
