onenote-mcp
onenote-mcp
MCP-сервер, который открывает доступ к Microsoft OneNote через Microsoft Graph — структура записных книжек и разделов, содержимое страниц и рукописный ввод, преобразованный в изображение, которое вызывающая модель может прочитать.
project-spec.md — авторитетный проектный документ. В нём описаны конвейер восстановления рукописного ввода, два независимых уровня OAuth, модель развёртывания Cloud Run и кэш токенов на базе Firestore. Прочтите его, прежде чем что-либо здесь менять.
Требования
Node >= 24. @google-cloud/firestore требует Node >= 22, а Node 24 — текущая Active LTS.
Related MCP server: OneNoteMCP
Быстрый старт
npm ci
npm run build
npm testСкрипты
Скрипт | Что делает |
| Компилирует |
| Проверка типов без генерации кода |
| Запускает сервер из исходников с |
| Запускает скомпилированный сервер из |
| Локальный вход с кодом устройства, заполняющий кэш токенов Firestore |
|
|
Тесты находятся в test/ и повторяют структуру src/. Они выполняются напрямую против исходного кода TypeScript с использованием нативного удаления типов Node, поэтому npm test не требует сборки. Это накладывает ограничения на исходный код: никаких enum, namespace, свойств параметров конструктора, а импорты только типов должны записываться как import type. Параметры компилятора erasableSyntaxOnly и verbatimModuleSyntax обеспечивают соблюдение этих правил.
См. CLAUDE.md — структура каталогов и связанные с ней соглашения.
Кэш токенов
src/token-cache.ts реализует ICachePlugin от MSAL для одного документа Firestore, путь к которому задаётся через FIRESTORE_CACHE_DOC. beforeCacheAccess читает поле cache документа и передаёт строку в MSAL. afterCacheAccess записывает сериализованный кэш обратно внутри транзакции Firestore, и только когда MSAL сообщает, что кэш изменился. Документ, который не существует, читается как пустой кэш — это состояние до запуска npm run bootstrap. Оба входных точки используют один и тот же плагин: CLI bootstrap записывает кэш через него, а сервер читает через него, так что есть один сериализатор и не нужно поддерживать второй формат.
Этот блоб — единственная копия refresh-токена, поэтому его защищают две вещи.
Запись, которая опустошила бы документ, отклоняется. MSAL удаляет учётные данные из своего кэша в памяти при некоторых сбоях, и afterCacheAccess выполняется внутри блока finally MSAL, поэтому сериализация, потерявшая учётную запись, может достичь этого кода, пока сохранённая всё ещё действительна. overwriteWouldEmptyCache останавливает её и записывает в журнал {"event":"token-cache-write-refused"}. Проверка пустоты не читает ни одного имени ключа MSAL — кэш пуст, когда он разбирается в объект, каждое значение которого является пустым контейнером, — поэтому она не может инвертироваться, когда MSAL меняет свой формат, и всё, что она не распознаёт, пропускается, а не блокируется.
Блоб, который заменяет каждая запись, сохраняется в поле previousCache. Одно поколение, а не история: кэш перезаписывается при каждом обновлении, и полезная копия всегда — самая последняя хорошая. Восстановление после неудачной записи — это копирование этого поля поверх cache в консоли Firestore, что стоит сделать, потому что альтернатива — вход с кодом устройства. Включите восстановление на момент времени для второго уровня защиты:
gcloud firestore databases update --enable-pitrСбой бэкенда — это не сбой учётных данных. Недоступность Firestore или отозванная привязка roles/datastore.user вызывает TokenCacheUnavailableError, а не проявляется как ошибка, которую даёт мёртвый refresh-токен. Записи повторяются три раза перед этим. См. строку cache-unavailable в таблице ниже — почему это различие стоит кода.
npm test покрывает только readCache — функцию, которая декодирует снимок документа. Два колбэка, транзакция и createFirestoreTokenCachePlugin не имеют автоматических тестов — им нужен бэкенд Firestore. Их проверка означает эмулятор, которому нужен java в PATH и собственная установка:
sudo apt-get install google-cloud-cli-firestore-emulatorgcloud components install cloud-firestore-emulator не устанавливает его в Debian-пакете Google Cloud CLI. Менеджер компонентов отключён в этой сборке, и gcloud вместо этого выводит команду apt-get, указанную выше.
Аутентификация Graph
src/graph-auth.ts превращает заполненный кэш токенов в токен доступа Microsoft Graph. createGraphAuth создаёт один PublicClientApplication из ONENOTE_CLIENT_ID, ONENOTE_AUTHORITY и плагина кэша Firestore и хранит его на всё время жизни процесса. getAccessToken() читает кэшированную учётную запись, вызывает acquireTokenSilent и возвращает токен. Запрашиваемые области — Notes.Read и Notes.ReadWrite, полностью квалифицированные.
Развёрнутый сервер никогда не входит в систему интерактивно. У него нет возможности запросить у кого-либо ввод, а конечные точки OneNote в Graph не поддерживают аутентификацию только для приложений, поэтому нет запасного варианта, когда сохранённый refresh-токен умирает — человек повторно запускает npm run bootstrap. Поэтому каждый сбой — это GraphAuthError, сообщающий об этом, а не сырая ошибка MSAL, которая дошла бы до вызывающего кода как голый 401 от Graph:
| Что произошло | Что делать |
| Документ Firestore отсутствует, или его поле |
|
| Firestore не ответил, или сервисная учётная запись времени выполнения потеряла | Повторить. Не вход. |
| Кэш прочитан, но не содержит вошедшей учётной записи |
|
| Сохранённый refresh-токен истёк или отозван, или конечная точка токена не вернула ничего полезного |
|
cache-unavailable — строка, которая оправдывает своё существование. Firestore читается и записывается внутри acquireTokenSilent через плагин кэша, поэтому сбой бэкенда раньше приходил как то же отклонение, что и мёртвый refresh-токен — и это сообщение говорит оператору пойти в браузер и заменить учётные данные, которые работают. GraphAuthError.retryable несёт это различие, и только этот reason его устанавливает.
Каждый из этих случаев также записывает одну строку в stderr:
{"event":"graph-auth-failure","reason":"silent-failed","documentPath":"tokencache/msal","retryable":"false"}Эта строка — суть. Сбой инструмента в противном случае виден только внутри разговора Claude, поэтому без неё ничто не сообщает оператору, что коннектор перестал работать. См. Оповещения ниже.
Сообщения называют путь к документу и базовую ошибку MSAL и намеренно не содержат идентификатора учётной записи: username — это UPN пользователя, а homeAccountId содержит идентификатор тенанта, и ни то, ни другое не должно попадать в журнал.
npm test покрывает логику получения токена через фиктивный клиент. Сам createGraphAuth не имеет автоматического теста: ему нужен кэш, заполненный реальным входом с кодом устройства, и никакие учётные данные, которые могли бы его заполнить, не могут быть закоммичены. Запустите npm run bootstrap, а затем сервер против того же документа, чтобы проверить его. Его потребитель — клиент структуры Graph ниже; ничто пока не подключает их к createApp.
Структура Graph
src/graph-structure.ts читает дерево OneNote: записные книжки, группы разделов, разделы и список страниц внутри одного раздела. new GraphStructure(auth) принимает что угодно с getAccessToken(), поэтому сервер передаёт ему GraphAuth выше.
Метод | Возвращает |
| Каждую записную книжку по отображаемому имени |
| Разделы непосредственно внутри записной книжки или группы разделов |
| Группы разделов непосредственно внутри записной книжки или группы разделов |
| Оба вышеперечисленных, полученные вместе |
| Страницы в одном разделе, сначала самые недавно изменённые, максимум |
| Одна записная книжка со всеми вложенными группами разделов, разрешёнными рекурсивно |
| Каждая записная книжка, каждая со своим разрешённым деревом |
| Каждая записная книжка с её разделами и одним уровнем групп разделов, одним запросом |
| Разделы в любом месте учётной записи, имя которых содержит этот текст, каждый со своей родительской записной книжкой и группой разделов |
| Страницы в одном разделе, заголовок которых совпадает, сравнение без учёта регистра через Graph |
containerKind — это notebooks или sectionGroups — два имени связей Graph. Оба типа контейнеров предоставляют одинаковые дочерние связи, поэтому методы списков принимают вид вместо того, чтобы существовать дважды.
getExpandedTree() — дешёвый. Он просит Graph развернуть связи, а не обходить их:
GET /me/onenote/notebooks?$select=id,displayName
&$expand=sections($select=id,displayName),
sectionGroups($select=id,displayName;$expand=sections($select=id,displayName))Измерено на учётной записи с 54 записными книжками: один запрос и 78 КБ против 195 запросов для getFullTree(), что важно, потому что OneNote разрешает 400 запросов в час и 5 одновременных. $select внутри каждого предложения expand — это то, что уменьшает ответ с 441 КБ до 78 КБ, а разделитель внутри предложения, несущего и $select, и $expand, — точка с запятой. Чего он не достигает — это группы разделов, вложенной в группу разделов: Graph ограничивает вложенность $expand двумя уровнями, поэтому findSectionsByName покрывает этот случай одним запросом, фильтруя список разделов всей учётной записи и разворачивая родителей каждого раздела.
api-overview.md фиксирует, что принимают эти конечные точки, включая места, где сервис противоречит собственной документации.
Три вещи, которые обход обрабатывает, а один вызов Graph — нет:
Вложенность. Группы разделов — это «группы вкладок» в интерфейсе, и они содержат дальнейшие группы разделов.
getNotebookTreeрекурсивен.Постраничность. Каждый вызов списка следует за
@odata.nextLink, пока тот не перестанет появляться. Graph сам выбирает размер страницы и игнорирует больший$top, поэтому один ответ никогда не доказывает, что коллекция полна.listPagesInSectionостанавливается, как только в руках оказываетсяtopэлементов, так чтоtop— это количество результатов, а не размер страницы.Список страниц всей учётной записи никогда не вызывается.
GET /me/onenote/pagesзавершается ошибкой 20266, «превышено максимальное количество разделов», в структуре «записная книжка на год». Перечисление страниц всегда ограничено/me/onenote/sections/{id}/pages, и тест сканируетsrc/на предмет пути всей учётной записи.
Сбои — это GraphRequestError для ответа не 2xx — он несёт status,
statusText и тело ответа body, потому что ошибку 20266 можно отличить от любой
другой 400 только по этому тексту — и GraphResponseError для 2xx, чьё тело не
соответствует ожидаемой форме, списка, который не завершается, или групп разделов,
вложенных глубже 20 уровней. Ни одно сообщение не содержит имени записной книжки,
раздела или страницы.
npm test прогоняет всё это через фейковый fetch, ключом которого является точный URL. Что это не может
проверить — принимает ли Graph эти URL; строки запроса взяты из проверенного
recon-скрипта в Приложении A к project-spec.md и подтверждаются только запуском против
реального тенанта.
Ink
Обычная конечная точка Graph для содержимого страницы отбрасывает рукописный ввод и оставляет
<!-- InkNode is not supported --> позади, и Graph не может экспортировать страницу как изображение или
PDF. Поэтому рукописный ввод восстанавливается из необработанных данных штрихов:
GET /me/onenote/pages/{id}/content?includeInkML=true отвечает multipart/mixed, одна
часть — тот же HTML, а другая — InkML. Штрихи становятся SVG, а затем PNG, который
передаётся вызывающей модели как изображение для её собственного зрения. Никакой OCR-сервис не
задействован.
Модуль | Что он делает |
|
|
|
|
|
|
Четыре детали определяют, работает ли это вообще, и все четыре взяты из проверенного recon-
скрипта в Приложении A к project-spec.md:
Пространства имён удаляются. Graph выдаёт
inkml:ink,inkml:trace,inkml:traceFormat.fast-xml-parserнастроен сremoveNSPrefix: true, и каждый поиск использует голое имя.Порядок каналов берётся из
<traceFormat>. Точки этой учётной записи — X, Y, F, где F — давление пера. Чтение первых двух чисел каждой точки рисует давление как координату.Координаты в химетрических единицах.
px = himetric * 96 / 2540. Это то же самое координатное пространство, в котором HTML страницы позиционирует печатный контент, поэтому рукописный ввод и печатный контент можно впоследствии совмещать друг с другом арифметически.Штрихи находятся где угодно в дереве. Страница может содержать более одного корня
<ink>, и элементы<traceGroup>вкладываются. Все они собираются.
Страница без рукописного ввода отображается как null. Это нормальный ответ для печатной страницы, а не
ошибка. Сбои, которые действительно возникают, — это InkParseError для групп штрихов, вложенных глубже 50
уровней, и InkRenderError для документа, который отклоняет resvg; ни одно сообщение не воспроизводит
документ, потому что координаты штрихов — это почерк пользователя.
test/fixtures/*.inkml написаны вручную — несколько штрихов, порядок каналов X/Y/F, химетрические
единицы, один файл с двумя корнями <ink> и вложенными элементами <traceGroup>. Никакой захваченный
дамп страницы не может быть закоммичен: отрисованные чернила — это полностью читаемые личные заметки.
Конечная точка MCP
Сервер говорит по MCP через stateless Streamable HTTP на POST /mcp. Каждый запрос
создаёт свой собственный MCP-сервер, отвечает и уничтожает его; ничего не переживает следующий.
Нет идентификатора сессии и нет SSE — GET /mcp и DELETE /mcp отвечают
405, а POST отвечает JSON-телом, а не открывает поток. Открытый поток
удерживал бы экземпляр Cloud Run живым и приводил бы к оплате простоя.
curl -s -X POST localhost:8080/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# {"result":{"tools":[]},"jsonrpc":"2.0","id":1}Оба типа Accept требуются спецификацией Streamable HTTP, даже если этот сервер
никогда не стримит. createTools в src/tools.ts — это реестр — шесть инструментов просмотра
(list_notebooks, list_sections, list_pages, search_pages, find_page_by_name,
list_pages_by_name), один инструмент чтения (get_page_content) и пять инструментов записи
(append_to_page, append_to_page_by_name, create_page, create_page_by_name,
update_page_title) — а src/mcp-server.ts — это
JSON-RPC поверх них.
Инструмент, который выбрасывает исключение, возвращается как результат инструмента с isError: true и читаемым
сообщением — истёкший refresh-токен, исчезнувшая страница и документ, который отклоняет resvg, — это
нормальные исходы, а не ошибки протокола. Только вызов инструмента, который никогда не был зарегистрирован,
является ошибкой JSON-RPC.
Каждый запрос пишет одну строку JSON-лога: HTTP-глагол, путь, статус,
длительность, метод JSON-RPC и имя инструмента при tools/call. Никогда строку запроса,
заголовки, аргументы или результат — см. src/logging.ts.
/mcp закрыт за bearer-токеном — см. Bearer-токены на конечной точке
MCP. Конечная точка health остаётся открытой.
OAuth discovery
Claude должен найти сервер авторизации, прежде чем начать поток. src/oauth-router.ts
монтирует mcpAuthRouter из SDK в корне приложения — он строит свои пути из URL
эмитента, а не из точки монтирования, поэтому его нельзя поместить за префикс — и обслуживает
пять маршрутов, все из которых по необходимости неаутентифицированы:
Путь | Что это |
| Метаданные сервера авторизации RFC 8414 |
| Метаданные защищённого ресурса RFC 9728 |
| Конечная точка авторизации |
| Куда отправляется форма согласия; роутер |
| Конечная точка токена |
curl -s localhost:8080/.well-known/oauth-authorization-server
curl -s localhost:8080/.well-known/oauth-protected-resource/mcpВсё в обоих документах выводится из MCP_PUBLIC_URL: это эмитент, а
идентификатор resource — это он плюс /mcp. MCP_PUBLIC_URL отклоняется при запуске, если
несёт завершающий слэш, чтобы каждый URL, построенный конкатенацией пути к нему, был
корректным; поле issuer затем сообщает нормализованную форму URL, которая для значения
только с origin — это та же строка с добавленным обратно завершающим слэшем. Документ защищённого ресурса обслуживается только
по URL с суффиксом пути — голый /.well-known/oauth-protected-resource — это 404, и
также /.well-known/openid-configuration. Claude сначала пробует путь с суффиксом.
scopes_supported перечисляет offline_access, что заставляет Claude запрашивать refresh-
токен, а не повторно давать согласие всякий раз, когда истекает access-токен. Нет
registration_endpoint: идентификатор клиента и секрет настроены, поэтому Dynamic Client
Registration нечего делать. Зарегистрирован один клиент с тремя redirect URI —
https://claude.ai/api/mcp/auth_callback для размещённых поверхностей Claude, и
http://localhost/callback плюс http://127.0.0.1/callback для Claude Code, чей порт
игнорируется согласно RFC 8252.
GET /authorize отображает страницу согласия, а не перенаправляет: одна кнопка Approve,
называющая, что предоставляется, и хост, на который будет отправлен код авторизации. Одобрение
отправляет обратно на POST /consent, который выпускает одноразовый код на 60 секунд и перенаправляет на
callback клиента. Весь запрос авторизации пересекает эту страницу в одном скрытом
поле, подписанном MCP_TOKEN_SIGNING_KEY, так что замена экземпляра во время согласия не
ломает поток, и форму нельзя отредактировать; поле, не прошедшее проверку, — это 400 с
без перенаправления и без выпущенного кода.
Оба ответа согласия несут Cache-Control: no-store, Referrer-Policy: no-referrer
— форма отправляется с URL /authorize, в строке запроса которого есть state и PKCE-
челлендж — X-Frame-Options: DENY и CSP default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'. Намеренно нет
form-action: браузеры расходятся во мнениях, проверяется ли он против цели
перенаправления, и POST согласия отвечает перенаправлением на claude.ai.
POST /consent имеет собственный лимит скорости — 200 за 15 минут — потому что он смонтирован
впереди лимитера /authorize из SDK намеренно, и отображаемая форма остаётся отправляемой в течение
десяти минут, так что один проход через /authorize даёт поле, которое можно воспроизвести.
Лимит находится выше предела в 100 ожидающих кодов, так что собственное вытеснение хранилища, чьё
поведение специфицировано, — это то, на что сначала натыкается всплеск.
POST /token выдаёт access-токен, действительный один час, и refresh-токен, действительный 30
дней. Оба — это HMAC-SHA256 над компактной полезной нагрузкой под MCP_TOKEN_SIGNING_KEY и
больше ничего — для проверки не используется хранилище, что позволяет замене ревизии Cloud Run
не принуждать к переподключению. Полезная нагрузка несёт аудиторию, которая
равна MCP_PUBLIC_URL плюс /mcp, так что токен действителен для этой конечной точки MCP и никакой другой. Как долго
живут эти токены и как заставить человека одобрять чаще — двумя разделами ниже.
Bearer-токены на конечной точке MCP
Каждый запрос к /mcp требует Authorization: Bearer <access token>. requireBearerAuth
из SDK стоит перед MCP-роутером в createApp, и
verifyAccessToken в src/oauth-provider.ts — это то, что он вызывает: HMAC-подпись под
MCP_TOKEN_SIGNING_KEY, вид токена, срок действия и аудитория. Токен, который
корректно подписан и не истёк, но несёт идентификатор ресурса другого сервера,
отклоняется — SDK не проверяет собственную аудиторию, поэтому без этой проверки токен, выпущенный для
другого MCP-сервера сервером, разделяющим этот ключ подписи, был бы принят.
Запрос без токена, с истёкшим токеном или с токеном, не прошедшим любую из этих проверок, — это
401 с заголовком challenge:
WWW-Authenticate: Bearer error="invalid_token", error_description="…",
resource_metadata="https://<MCP_PUBLIC_URL>/.well-known/oauth-protected-resource/mcp"Параметр resource_metadata — это та часть, которая важна: именно так Claude находит
сервер авторизации и запускает поток, поэтому 401 без него — это тупик, а не
приглашение ко входу. Claude обновляет токен реактивно при 401 и проактивно за несколько минут до
сохранённого срока истечения, так что 401 здесь — обычное событие.
Токен читается из заголовка Authorization и ниоткуда больше. ?access_token= в
строке запроса не учитывается — спецификация авторизации MCP запрещает это, и
src/logging.ts полагается на это, оставляя строку запроса вне строки лога.
Какие маршруты открыты — это список исключений, и он длиннее, чем «всё, кроме
/mcp», потому что весь поток авторизации должен отвечать вызывающим, у которых ещё нет токена:
/healthz и /health, оба документа .well-known, /authorize, /consent и
/token. Тест
в test/server.test.ts перечисляет маршруты, которые createApp фактически регистрирует, и утверждает,
что каждый не входящий в этот список отвечает 401 без токена, так что маршрут, добавленный позже,
закрыт, если кто-то не откроет его намеренно.
Никакие scope не требуются. offline_access, единственный scope, который выдаёт этот сервер, касается того,
будет ли выдан refresh-токен, а не того, что вызывающий может делать, и требование
его отвечало бы 403 для токенов, которые в остальном хороши. Если проверка scope когда-либо будет добавлена,
403 должен нести WWW-Authenticate: Bearer error="insufficient_scope" — что это
промежуточное ПО делает — потому что Claude считает любой другой 403 терминальным и не предлагает
ничего.
Время жизни токена и принудительная повторная проверка
Этот сервер спроектирован для работы без присмотра. Настройки по умолчанию отражают это и жертвуют некоторой способностью отозвать утёкший токен. Прочтите это перед развёртыванием в важном месте и измените числа, если такой компромисс вам не подходит.
Что делают настройки по умолчанию
Токен | Срок жизни | Что его продлевает |
Access token | 1 час | Refresh token, автоматически |
Refresh token | 30 дней | Каждое обновление выпускает новый с новыми 30 днями |
Форма согласия | 10 минут | Ничего; устаревшая форма отклоняется, и поток начинается заново |
Claude обновляется сам — заранее, до истечения часа, и реактивно по 401. Поэтому человек нажимает «Разрешить» при первом добавлении коннектора, а затем только если коннектор не использовался 30 дней. Это скользящее окно: 30 дней ограничивают, как долго соединение может оставаться бездействующим, а не как долго оно может жить.
Почему скользящее окно и чем это обходится
Каждый токен, который выпускает этот сервер, не имеет состояния. Это подписанная полезная нагрузка и ничего больше — ни строки в базе данных, ни записи сессии, ничего, что можно было бы найти при возврате. Именно поэтому замена ревизии Cloud Run незаметна: новый экземпляр проверяет токен, выпущенный старым, без общего состояния между ними. Хранилище токенов означало бы переподключение при каждом развёртывании.
Цена в том, что ничего нельзя отозвать по отдельности. Нет конечной точки отзыва, потому что нечего удалять. В частности:
Утёкший refresh token даёт доступ на срок до 30 дней, и каждое использование продлевает доступ его владельца ещё на 30. Нет серверной записи, которую можно аннулировать, и нет способа отличить украденный refresh token от легитимного — оба представляют собой одни и те же байты, подписанные одним ключом.
Скользящее окно — это не ротация. Когда обновление выпускает новый refresh token, тот, который он заменяет, продолжает работать до истечения срока, указанного внутри него. Настоящая ротация означает пометку старого токена как использованного, а для этого нужно хранилище, которого в этой конструкции нет.
Access token нельзя отозвать в течение его часа по той же причине.
Остаётся один грубый рычаг, и он действует немедленно: измените MCP_TOKEN_SIGNING_KEY и переразверните. Все access token'и, все refresh token'и и все открытые страницы согласия аннулируются сразу, потому что все они проверяются по этому ключу. Следующий запрос Claude получит 401, и оператор один раз нажмёт «Разрешить». Ротация ключа по расписанию — разумная политика сама по себе.
Экран согласия, если уж на то пошло, никого не аутентифицирует — на нём одна кнопка и нет пароля. Между посторонним и вашими блокнотами стоят MCP_OAUTH_CLIENT_SECRET, который требует POST /token, список разрешённых redirect-URI, отправляющий каждый код авторизации на claude.ai или на loopback, и PKCE, привязывающий код к клиенту, который начал поток.
Как заставить человека одобрять чаще
Каждый из этих пунктов — изменение исходного кода, а не значение конфигурации. Это осознанно: оператор, сокращающий окно, меняет уровень безопасности развёртывания, и это должно быть в коммите, который кто-то может прочитать, а не в переменной окружения, которую кто-то может забыть.
Сократите окно бездействия. В src/oauth-provider.ts:
const REFRESH_TOKEN_TTL_S = 30 * 24 * 60 * 60; // 30 days
const REFRESH_TOKEN_TTL_S = 7 * 24 * 60 * 60; // a weekПри таком простое коннектору нужен клик. При регулярном использовании он по-прежнему не спрашивает — окно продолжает скользить вперёд. Это ограничивает, как долго утёкший refresh token живёт после того, как утечка перестала использоваться, и ничего больше.
Остановите скольжение окна. Именно это изначально было указано в issue #22, и это ограничивает общий срок жизни соединения, а не время его бездействия: человек одобряет каждые 30 дней независимо от того, насколько занят коннектор. Одна строка в exchangeRefreshToken, в src/oauth-provider.ts:
// Sliding: a new refresh token, 30 days from now.
return issueTokens(client.client_id, requested, mintRefreshToken(client.client_id, granted));
// Fixed: hand back the same token, expiring 30 days after the consent click.
return issueTokens(client.client_id, requested, refreshToken);Откажитесь выпускать refresh token'ы вообще. Самый строгий вариант: человек одобряет каждый час, потому что истёкшему access token'у нечем его продлить. Две правки, обе обязательны — одного переключателя метаданных недостаточно, чтобы остановить выпуск токена.
В
src/oauth-router.tsочиститеSCOPES_SUPPORTED. Claude добавляетoffline_accessк запросу авторизации только тогда, когда метаданные это рекламируют, и именно этот переключатель решает, запрашивает ли он refresh token.В
src/oauth-provider.tsуберите полеrefresh_tokenиз того, что возвращаетissueTokens. Сегодня он выпускается независимо от запрошенных областей.
Ожидайте, что это будет заметно в использовании: Claude отправляет браузер обратно на экран согласия в середине сессии, когда истекает час.
Сократите access token. ACCESS_TOKEN_TTL_S в src/oauth-provider.ts сужает окно, в котором работает утёкший access token. Это стоит одного запроса токена за истечение и не требует участия человека, так что это дёшево — но ничего не делает с утёкшим refresh token'ом, а именно он является учётными данными, о которых стоит беспокоиться.
Keepalive
Делегированные refresh token'ы Microsoft истекают примерно через 90 дней без использования. Токен скользит вперёд только при фактическом обмене, а обмен происходит только когда после истечения хранимого access token'а приходит вызов инструмента — так что коннектор, которым никто не пользуется три месяца, — это коннектор, которому нужен человек с браузером, запускающий npm run bootstrap. Ничто на сервере не может предотвратить это само по себе, потому что ничто на сервере не работает, когда никто его не вызывает.
POST /keepalive — это решение. Он вызывает acquireTokenSilent с forceRefresh: true, что пропускает хранимый access token и обменивает refresh token, поэтому Entra выпускает замену с новым окном, а src/token-cache.ts записывает её в Firestore. forceRefresh — ключевая часть: без него MSAL отвечает из своего кэша, запрос не достигает Entra, и окно не сдвигается.
Установите MCP_KEEPALIVE_SECRET не менее чем из 32 случайных символов — и маршрут будет смонтирован; оставьте его не заданным — и путь вернёт 404. Планировщик предъявляет секрет в заголовке X-Keepalive-Secret, который сравнивается за постоянное время до выполнения какой-либо работы. Это общий секрет, а не bearer-токен, потому что планировщик не может выполнить поток OAuth — у него нет браузера и негде хранить refresh token — и это отдельная переменная, а не секрет клиента Layer-1, чтобы учётные данные, способные достичь всей поверхности MCP, не лежали также в задании планировщика.
Настройка в вашем собственном аккаунте
Четыре шага: сгенерируйте секрет, передайте его в сервис, создайте задание и проверьте, что задание действительно достигает сервиса. Всё это необязательно — пропустите, и сервер работает, пока однажды его никто не вызовет в течение 90 дней.
# 1. Generate the secret. umask 077 so the file is not world-readable, and it is never
# printed: the two commands below read it from disk.
umask 077
openssl rand -hex 32 > keepalive.secret
# 2. Get it onto the service. The GitHub secret alone changes nothing — the value
# reaches the container as an env var, which happens on a deploy and only then.
gh secret set MCP_KEEPALIVE_SECRET < keepalive.secret
gh workflow run deploy.yml --ref main
# 3. Create the job. The API enable takes a minute or two to propagate; a create run
# immediately after it fails with SERVICE_DISABLED, which is a retry rather than a
# misconfiguration.
gcloud services enable cloudscheduler.googleapis.com --project="$GCP_PROJECT"
gcloud scheduler jobs create http onenote-mcp-keepalive \
--project="$GCP_PROJECT" \
--location="$GCP_REGION" \
--schedule="0 4 * * 1" \
--time-zone=UTC \
--uri="$MCP_PUBLIC_URL/keepalive" \
--http-method=POST \
--headers="X-Keepalive-Secret=$(cat keepalive.secret)" \
--attempt-deadline=60s \
--max-retry-attempts=3
rm keepalive.secret--location обязателен и относится к собственному региону планировщика, который не имеет отношения к тому, где находится цель задания; использование региона Cloud Run позволяет держать в голове на одно значение меньше. scripts/gcp-bootstrap.sh также включает cloudscheduler.googleapis.com, так что включение на шаге 3 нужно только для проекта, развёрнутого до появления этой строки.
Затем докажите это, потому что каждый режим отказа здесь молчалив — неверный секрет, задание, указывающее на старый URL, и сервис, развёрнутый без переменной, выглядят как включённое задание планировщика:
gcloud scheduler jobs run onenote-mcp-keepalive --project="$GCP_PROJECT" --location="$GCP_REGION"
# An empty status code is success. A code is a gRPC status; the request log below says why.
gcloud scheduler jobs describe onenote-mcp-keepalive \
--project="$GCP_PROJECT" --location="$GCP_REGION" \
--format='value(status.code,lastAttemptTime,scheduleTime)'
# The request as the service saw it. 200 is the answer; 401 is a secret mismatch and 404
# means the deploy in step 2 did not happen.
gcloud logging read \
'resource.type="cloud_run_revision" AND httpRequest.requestUrl:"keepalive"' \
--project="$GCP_PROJECT" --limit=3 --freshness=10m \
--format='value(timestamp,httpRequest.requestMethod,httpRequest.status,httpRequest.userAgent)'200 означает, что токен был обменян. Доказательством того, что он был сохранён, служит поле updatedAt документа Firestore, переместившееся на время выполнения, — это единственное свидетельство того, что заменяющий refresh token пережил вызов. Измерено на этом развёртывании 2026-08-19: принудительный запуск ответил 200, и tokencache/msal продвинулся с 14:15:53Z до 14:30:54Z.
Еженедельно более чем достаточно для 90-дневного окна и оставляет запас на несколько пропущенных запусков. Задание стоит одного обращения к конечной точке токена и одной записи в Firestore.
Для ротации секрета: задайте новый секрет GitHub, разверните, затем gcloud scheduler jobs update http onenote-mcp-keepalive --update-headers=…. Любой запуск между развёртыванием и обновлением задания ответит 401 и не выполнит работы, что при еженедельном расписании — окно, в которое ничего не попадает.
Заголовок находится в определении задания и доступен для чтения любому, у кого есть roles/cloudscheduler.viewer в проекте, точно так же, как переменные окружения доступны для чтения в спецификации ревизии Cloud Run. Это тот же компромисс, что принят в разделе Deploy для отказа от Secret Manager.
Статус | Значение | Что должен делать планировщик |
200 | Refresh token обменян, новый сохранён | Ничего |
401 | Секрет отсутствует или неверен | Исправить задание; маршрут не выполнил работы |
404 |
| Задать его и переразвернуть |
503 с | Firestore был недоступен | Повторить |
503 с | Грант мёртв |
|
От чего это не защищает: политика условного доступа с частотой входа, смена пароля, сброс MFA или отзыв гранта администратором. Любое из этого убивает refresh token независимо от расписания, и никакое изменение кода этого не избежит. Если тенант Entra ваш, исключите эту регистрацию приложения из политик частоты входа; если нет, считайте 90 дней верхней границей, которую кто-то другой может сократить, не сообщая вам.
Маршрут keepalive также не связан с 30-дневным окном Layer-1 в разделе Token lifetime ниже. Этот refresh token живёт в хранилище коннектора Claude, и только Claude может предъявить его или получить замену, поэтому ничто, работающее здесь, не может поддерживать его жизнь. Потеря его стоит одного клика по кнопке «Разрешить»; потеря Microsoft-токена стоит входа с кодом устройства.
Синхронизация зеркала страниц
Зеркало хранит копии страниц из выбранного вручную набора блокнотов в Firestore, чтобы инструменты чтения могли отвечать, не тратя бюджет запросов OneNote. POST /sync — это то, как оно заполняется. Ничего в этом не автоматично: маршрут не монтируется без секрета, а инструменты чтения не обращаются к зеркалу без MIRROR_READ_ENABLED.
Что поддерживает его актуальность
Три механизма. Только два по расписанию, и один из них временный.
Что его запускает | Что он покрывает | |
Первоначальное заполнение | Задание планировщика | Каждый зеркалируемый блокнот, активный или нет, заполнен один раз |
Текущая догоняющая синхронизация | Каждое покрытое чтение, встроенно, перед ответом | Страницы, созданные и отредактированные в активных блокнотах |
Очистка удалений | Одно ночное задание планировщика | Страницы, удалённые из активных блокнотов |
В установившемся режиме нет плановой инкрементальной синхронизации, потому что её делают чтения. Каждое покрытое чтение само запускает runIncremental перед ответом, максимум 12 запросов и 15 секунд, и не чаще одного раза за 30 секунд — см. раздел Чтение из него ниже. Таким образом, зеркало обновляется, когда кто-то собирается на него посмотреть, и не иначе. Плановая инкрементальная синхронизация рядом с этим тратила бы те же 400 запросов в час на обновление данных, которые никто не запрашивал, а следующее чтение всё равно обновило бы их снова.
Чтение не может заметить удаление — именно поэтому и существует ночной обход.
Инкрементальный проход опирается на lastModifiedDateTime родительского раздела — создание,
правка и удаление — каждое из них сдвигает его, — и затем запрашивает у этого раздела его изменённые
страницы. Страница, которая была удалена, не появляется ни в одном списке, поэтому ничто в этом ответе
не говорит о том, что её больше нет. Чтобы это выяснить, нужно перечислить идентификаторы страниц раздела
и сверить их с зеркалом, потому что у Graph нет /delta ни для какого ресурса OneNote и нет
«надгробия» для удалённой страницы. Это обход, он слишком дорог, чтобы ставить его перед чтением, и пока
он не выполнен, зеркало продолжает отдавать страницу, которой больше не существует. Одна ночь — это та
степень устаревания, которую данная схема принимает.
Бэкфилл не является ни тем, ни другим, поэтому у него собственное расписание, пока он не завершён. Это
один список на раздел от эпохи плюс одна загрузка содержимого на страницу — тысячи запросов на этом
аккаунте, — и бюджет в 12 запросов инлайн-обновления занял бы недели, чтобы покрыть это.
Удалите задание, как только sync/state.backfillComplete станет true, то есть когда каждый
отзеркалированный раздел имеет непустой pagesSyncedThrough. Больше ничто не вызывает POST /sync.
Первая запись структуры после обновления переписывает каждый документ один раз. Документы
структуры несут строку identity, которая говорит, что дерево о них сказало, а документ,
написанный до появления этого поля, не имеет её — поэтому он не совпадает и записывается.
Это один проход записи в Firestore и ни одного запроса к Graph, и это происходит один раз.
Неактивная записная книжка бэкфиллится один раз и затем замораживается. activeNotebookIds в
документе выбора — вот что проводит эту границу — см. Выбор того, что зеркалировать. Выбранная
записная книжка, которая не активна, заполняется бэкфиллом и обслуживается из зеркала навсегда;
никакое инлайн-обновление и никакой обход не посещают её снова. Именно это делает зеркалирование
аккаунта с 55 записными книжками доступным: архив перечисляется и загружается один раз и больше никогда.
Четыре маршрута
POST /sync incremental, active notebooks only
POST /sync/sweep scoped sweep, active notebooks only
POST /sync/sweep/full every section of every active notebook
POST /sync/sweep/all every section of every mirrored notebook, active or notДва из них работают по расписанию: /sync, пока выполняется бэкфилл, и /sync/sweep/full
еженощно. /sync/sweep — более дешёвый ограниченный вариант, удобный для ручного запуска. /sync/sweep/all — единственный
режим без фильтра активности и единственный способ заметить удаление внутри
неактивной записной книжки; он стоит один запрос на каждую отзеркалированную секцию, поэтому запускайте его
осознанно, а не по таймеру.
Сколько это стоит
OneNote разрешает делегированному приложению 400 запросов в час, и каждый интерактивный вызов инструмента расходует те же 400. Аккаунт, на котором это работает, содержит 55 записных книжек и 568 разделов; 4 записные книжки и 202 раздела отзеркалированы, а 2 записные книжки и 70 разделов активны.
Запуск | Запросы |
Инлайн-обновление, ничего не изменилось | 1 — расширенное дерево, и больше ничего |
Инлайн-обновление, несколько отредактированных страниц | 2–12; загрузка только для страницы, которая действительно изменилась, и 12 — жёсткий бюджет |
Ночной | ~70 здесь, плюс один на каждую изменённую страницу |
| один на каждый посещённый раздел |
| ~202 здесь — половина часового бюджета |
Один запуск бэкфилла | 49–120 по замерам; останавливается на том бюджете, который сработает первым |
Страница, которая не изменилась, не стоит ни одного запроса. Список изменённых страниц несёт
метку времени и заголовок каждой страницы, и сохранённая копия, совпадающая с обоими, не загружается
снова. Таким образом, стоимость запуска зависит от страниц, которые действительно изменились, а не от
страниц, отредактированных за последний час, и pagesSkipped в отчёте о запуске показывает, сколько
страниц было пропущено таким образом.
Два окна перекрытия имеют разную ширину, и это намеренно.
WATERMARK_OVERLAP_MS — это час, и он определяет, какие страницы возвращает список раздела;
ничто из того, что он выявляет, не стоит запроса, поэтому он сохраняет запас. SECTION_SCAN_OVERLAP_MS —
пятнадцать минут, и он определяет, как долго раздел, отредактированный один раз, продолжает посещаться,
ценой одного запроса списка за запуск — час этого был последним параметром, который стоило сократить. Оба
являются запасами на задержку распространения Graph и на расхождение между его часами и часами этого сервиса;
два запроса к журналу из раздела Доказательство работы — вот что говорит, правильны ли пятнадцать минут.
MIRROR_SYNC_REQUEST_BUDGET (по умолчанию 120) — это жёсткая остановка. Запуск, достигший его,
фиксирует сделанное, отвечает 200 с "done": false и возобновляется по следующему расписанию. Именно
это делает первый бэкфилл вообще возможным.
Какой из двух ограничителей срабатывает, варьируется, поэтому рассчитывайте частоту по бюджету запросов, а не по наблюдаемому среднему. Замерено на шести запусках 2026-08-20: 49, 57, 60, 63, 120 и 120 запросов. Первые четыре завершились по 240-секундному таймеру с запасом по бюджету — страница стоит один запрос к Graph, один рендер чернил в resvg и две записи в blob, и именно рендеры заполняют таймер на инстансе с 1 CPU. Последние два потратили полный бюджет задолго до истечения таймера, на разделах, чьи страницы были дешевле в рендере.
Частота | Запросов/час при бюджете 120 | |
| до 360 | самая быстрая частота, которая не может превысить лимит |
| до 480 | ниже лимита, пока запуски ограничены таймером, выше — когда нет |
| до 720 | выше лимита всякий раз, когда запуски ограничены запросами |
| до 1440 | замерено на этом аккаунте 2026-08-20: 429 секунд в пределах 45 минут |
Свыше 400 в час Graph отвечает 429 с кодом OData 10007. Отказанный запрос не выполняет работы
и всё равно тратит попытку, метка раздела не продвигается, и штраф переживает короткую паузу —
пять повторов в течение трёх минут были все отклонены после одного всплеска, записано в docs/graph.md. Превышение лимита
поэтому не ускоряет завершение бэкфилла. Уменьшение MIRROR_SYNC_REQUEST_BUDGET вдвое даёт
вдвое меньший интервал при тех же почасовых затратах, ценой одного дополнительного чтения дерева за запуск.
Размер бэкфилла на этом аккаунте — примерно один запрос на раздел плюс один на страницу. 202 раздела примерно по 24 страницы — оценка по первым четырём разделам, чьи списки завершились, а не замер — это примерно 4 800 страниц и 5 000 запросов, так что нижняя граница — около 12,5 часов срезов, независимо от частоты.
При MIRROR_READ_ENABLED=false постоянной догоняющей синхронизации нет вообще. Это
текущая развёрнутая настройка, поэтому задание бэкфилла и ночной обход — единственное, что
касается зеркала. Включение чтения — вот что делает описанную выше схему, ориентированную на чтение, реальной,
и это добавляет до 12 запросов на обновление с частотой не более одного за 30 секунд — примерно 120
запросов в час от непрерывно занятого разговора и до 144, пока зеркало отстаёт. Этот параметр
укладывается под 400 рядом с ночным обходом, как только задание бэкфилла исчезнет.
Настройка
# 1. The secret. Never printed; the commands below read it from disk.
umask 077
openssl rand -hex 32 > sync.secret
gh secret set MIRROR_SYNC_SECRET < sync.secret
gh workflow run deploy.yml --ref main
# 2. The backfill job. Temporary — delete it when sync/state.backfillComplete is true.
# --attempt-deadline sits just above Cloud Run's 300s request timeout, so the
# platform's cut is the one that happens and the scheduler records it.
gcloud scheduler jobs create http onenote-mcp-sync \
--project="$GCP_PROJECT" --location="$GCP_REGION" \
--schedule="*/20 * * * *" --time-zone=UTC \
--uri="$MCP_PUBLIC_URL/sync" --http-method=POST \
--headers="X-Sync-Secret=$(cat sync.secret)" \
--attempt-deadline=330s --max-retry-attempts=2
# 3. The nightly deletion sweep. This one is permanent: it is the only thing that
# notices a page deleted out of an active notebook.
gcloud scheduler jobs create http onenote-mcp-sync-sweep-full \
--project="$GCP_PROJECT" --location="$GCP_REGION" \
--schedule="0 0 * * *" --time-zone=America/New_York \
--uri="$MCP_PUBLIC_URL/sync/sweep/full" --http-method=POST \
--headers="X-Sync-Secret=$(cat sync.secret)" \
--attempt-deadline=330s --max-retry-attempts=1
rm sync.secret
# 4. When the backfill finishes.
gcloud scheduler jobs delete onenote-mcp-sync \
--project="$GCP_PROJECT" --location="$GCP_REGION"Задание бэкфилла на развёрнутом аккаунте находится на */15. Это ниже лимита, пока запуски
ограничены таймером — замерено 61–81 запрос за запуск 2026-08-20 — и выше, если они снова начнут
тратить полный бюджет в 120 запросов. */20 — это частота, которая не может превысить лимит
в любом случае; уменьшение MIRROR_SYNC_REQUEST_BUDGET до 100 делает */15 также безопасным.
Повторы безопасны в обоих случаях: каждый режим идемпотентен, и повтор после запуска с исчерпанным бюджетом активно полезен во время бэкфилла. Пока существуют оба задания, аренда запуска делает перекрытие 409, а не двойной тратой бюджета Graph.
/sync/sweep/all не получает задания. Запускайте его вручную после редактирования activeNotebookIds или когда
записная книжка была реорганизована вне этого сервера и зеркало нуждается в сверке с тем, что
OneNote на самом деле содержит.
Выбор того, что зеркалировать
Выбор — это один документ Firestore, редактируемый вручную, MIRROR_ROOT_DOC, по умолчанию
onenoteMirror/default. Это единственный документ, который сервис никогда не записывает.
Поле | Тип | Значение |
| массив строк | Идентификаторы записных книжек, чьи страницы зеркалируются |
| массив строк, необязательный | Какие из них всё ещё редактируются. Отсутствует или |
| строка | Свободный текст для вас. Никогда не читается. |
Получите идентификаторы из list_notebooks. Структура — каждое имя записной книжки и раздела в
аккаунте — зеркалируется в любом случае, потому что чтение дерева возвращает всё это за один
запрос, а частичная структура заставила бы list_notebooks и search_pages без области действия
отвечать уверенно и частично. Только содержимое страниц следует выбору.
activeNotebookIds сужает текущую работу и больше ничего. Страницы, содержимое и чернила неактивной
записной книжки хранятся точно так же, как у активной, записи через этот сервер по-прежнему достигают
её и по-прежнему обновляют зеркало, и чтение по-прежнему отвечает из неё. Что меняется — так это то,
что никакой инкрементальный проход и никакой обход не перепроверяет её после завершения её бэкфилла, поэтому
правка, сделанная в клиенте OneNote в неактивной записной книжке, не достигает зеркала, пока
записная книжка не будет снова сделана активной или /sync/sweep/all не будет запущен вручную.
Возврат записной книжки в activeNotebookIds безопасен и стоит только этой записной книжки:
следующий запуск сравнивает сохранённые списки, находит единственный идентификатор, ставший активным, и заново перечисляет
разделы этой записной книжки по их собственным пораздельным меткам. Каждая другая записная книжка сохраняет
свою метку и не перечисляется. Только страницы, которые действительно изменились, загружаются. Добавление записной книжки
в notebookIds ведёт себя так же.
Идентификатор в notebookIds, не соответствующий ни одной записной книжке, сообщается как unknownNotebookIds в каждом
отчёте о запуске. Идентификатор в activeNotebookIds, называющий не отзеркалированную записную книжку — опечатку
или реальную записную книжку, никогда не добавленную в notebookIds — сообщается как unknownActiveNotebookIds и
логируется как mirror-selection-unknown-active. Проверяйте оба после редактирования: идентификаторы — непрозрачные строки,
и опечатка иначе означает записную книжку, которая молча никогда не синхронизируется.
Доказательство работы
# While the backfill job exists. Once it is deleted, the sweep job is the one to force,
# and a covered read is what exercises the incremental path.
gcloud scheduler jobs run onenote-mcp-sync --project="$GCP_PROJECT" --location="$GCP_REGION"
# The report the run returned, in the service log.
gcloud logging read \
'resource.type="cloud_run_revision" AND jsonPayload.event="sync-completed"' \
--project="$GCP_PROJECT" --limit=3 --freshness=30m --format='value(jsonPayload)'200 — это не доказательство само по себе. Три вещи, которые являются:
sections/{id}.pagesSyncedThroughсдвинулся в Firestore. Запуск, ответивший 200 и не продвинувший ни одной метки, ничего не сделал.В бакете лежит объект на каждую страницу с чернилами, и его размер в байтах совпадает с
ink.bytesдокумента страницы.sectionsSkippedInactive— это число, которое вы ожидаете. Счётчик, покрывающий каждую отзеркалированную секцию, означает, что активный набор пуст или содержит опечатку;unknownActiveNotebookIdsв том же отчёте говорит, какая именно.
Два числа из журналов говорят, правильна ли ширина окон перекрытия. Ни одно из них не срочно; читайте их после того, как сервис проработает неделю.
# How far back the page-listing overlap actually reached to catch a page that had really
# changed. The largest ageMs over a day is the narrowest WATERMARK_OVERLAP_MS that would
# still have caught every one of them; approaching 3600000 means that hour is too narrow.
# Tens of minutes is ordinary. Nothing logged at all means the overlap has never caught
# anything.
#
# It says nothing about SECTION_SCAN_OVERLAP_MS. A section that window declines is never
# listed, so it produces no line here at all -- that failure is silence, not a large
# number, and nothing observes it. The nightly /sync/sweep/full is the backstop for it.
gcloud logging read \
'resource.type="cloud_run_revision" AND jsonPayload.event="sync-overlap-save"' \
--project="$GCP_PROJECT" --limit=50 --freshness=1d --format='value(jsonPayload.ageMs)'
# How far this service's clock is from Graph's, in milliseconds, signed. Both overlap
# windows and the settle guard are sized against this being seconds rather than minutes.
# Nothing logged means it stayed under the threshold, which is the expected reading.
gcloud logging read \
'resource.type="cloud_run_revision" AND jsonPayload.event="graph-clock-skew"' \
--project="$GCP_PROJECT" --limit=50 --freshness=1d --format='value(jsonPayload.skewMs)'Следите за бэкфиллом, перезапуская задание и читая done в последовательных отчётах: false
с растущим pagesUpdated, затем true. sync/state.backfillComplete говорит то же
самое, и это сигнал удалить задание onenote-mcp-sync.
Следите за 429 в журнале запросов во время выполнения. Пока задание обратного заполнения работает выше 400 запросов в час, 429 — ожидаемое следствие такого выбора, и лекарство — более длинный интервал. В любое другое время это означает, что что-то обходит общий шлюз запросов, а это тот сбой, из-за которого троттлится весь аккаунт.
Статус | Значение | Что делать |
200, | Выполнение завершило свою работу | Ничего |
200, | Остановилось по бюджету запросов или времени | Ничего; следующий запуск продолжит |
401 | Секрет отсутствует или неверен | Исправьте задание; маршрут не выполнил работу |
404 |
| Задайте его и переразверните |
409 | Другой запуск удерживает аренду | Ничего; она истекает через 15 минут |
504 | Запуск превысил таймаут запроса Cloud Run в 300 секунд | Ничего; см. ниже |
503, | Токен обновления Microsoft пропал | Повторно выполните |
503, | Firestore не ответил | Повторите; учётные данные в порядке |
Чтение из него
MIRROR_READ_ENABLED — это переключатель. При значении false (по умолчанию) каждый инструмент отвечает из Microsoft Graph точно так же, как до появления зеркала. При значении true семь инструментов идут через локальную копию: list_notebooks, list_sections, list_pages, list_pages_by_name, find_page_by_name, get_page_content и search_pages.
Каждое из этих чтений — это три шага, в таком порядке.
Обновление. Инструмент сам выполняет инкрементальную синхронизацию — тот же
runIncremental, что иPOST /sync, но с меньшими бюджетами: 12 запросов к OneNote и 15 секунд. После удаления задания обратного заполнения это единственное, что вообще выполняет инкрементальный проход.Чтение локальной копии. Попадание не стоит ни одного запроса к OneNote. Промах переходит к Microsoft Graph, как и раньше.
Сообщение источника.
sourceравенonenote, когда ответ совпадает с тем, что хранит OneNote,best-available, когда часть ответа взята из блокнота, который оператор заморозил, иmirror, когда он может отставать.
Полное правило source. «Подтверждено» означает, что обновление на шаге 1 завершилось, или ответ — это одна страница, которую ни одна запись не пометила устаревшей:
Ситуация |
|
Чтение перешло к Microsoft Graph |
|
Локальное попадание из активного блокнота, подтверждено |
|
Локальное попадание из активного блокнота, не подтверждено |
|
Локальное попадание из неактивного блокнота, подтверждено или нет |
|
Неограниченный поиск, покрывающий оба, подтверждено |
|
Неограниченный поиск, покрывающий оба, не подтверждено |
|
Обновление «завершено» означает, что оно прочитало дерево блокнотов, посетило все нужные разделы, получило все нужные страницы и не исчерпало ни один из бюджетов. Всё остальное — запросный или временной бюджет исчерпан, дерево ответило 500, получение страницы не удалось, планировщик удерживал аренду синхронизации, Firestore не ответил — это mirror, и ответ несёт mirroredAt, указывающий, когда была сделана копия. get_page_content — исключение в первых трёх строках: документ страницы несёт собственный флаг устаревания, и каждая запись помечает свою страницу устаревшей до обращения к OneNote, так что попадание там — это страница, которую ничто не превзошло.
best-available — это не ухудшенный ответ и не становится mirror при сбое обновления: это обновление всё равно не проверяло бы неактивный блокнот, поэтому сообщение о нём описывало бы сбой, который не мог изменить ответ. Что оно говорит, так это то, что копия повторно проверяется только записью через этот сервер или через /sync/sweep/all. list_notebooks сообщает pagesActive для каждого блокнота, а неограниченный search_pages сообщает inactiveNotebooks рядом с notebooksSearched, так что модель может видеть, насколько велик был пропуск. list_notebooks и list_sections никогда не сообщают best-available — структура хранится для всего аккаунта при каждом запуске, независимо от того, что говорит активный набор.
Обновление выполняется не при каждом вызове. Завершённое действует 30 секунд, а незавершённое — 5 минут, так что всплеск вызовов инструментов оплачивает одно обновление, а не тридцать. Худший случай — около 120 дополнительных запросов к OneNote в час от непрерывно активного разговора на тихом аккаунте, при лимите 400 — а обновление на тихом аккаунте — это один запрос, дерево блокнотов.
Нет аргумента для принудительного живого чтения при каждом вызове. Раньше был (useLiveData); он удалён, потому что чтение, которое сначала обновляется, а затем сообщает, завершилось ли обновление, отвечает на тот же вопрос без поля схемы, о котором вызывающей модели приходится рассуждать.
Отключение — это полный откат: установите переменную в false, переразверните, и инструменты будут побайтово идентичны своему поведению до зеркала. Никаких обновлений не выполняется, ни один вызов инструмента не тратит запрос на синхронизацию, и каждый ответ говорит source: "onenote". Никакой миграции данных, а зеркало продолжает заполняться в фоне.
Записи немедленно повторно синхронизируют свою страницу. Все пять пишущих инструментов после успешной записи заново читают страницу из OneNote и сохраняют её, так что get_page_content сразу после append_to_page отвечает из локальной копии с только что добавленным текстом. Это стоит одного дополнительного запроса к OneNote на запись. Если повторная синхронизация не удалась, страница вместо этого помечается устаревшей, что отправляет следующее чтение в OneNote — медленнее, но никогда не неправильно — и обновление перед последующим чтением исправляет это в любом случае. Неудачная повторная синхронизация никогда не приводит к сбою записи.
Две вещи, которые нужно проверить перед включением:
Все четыре составных индекса имеют статус
READY. Запрос к индексу, который ещёCREATING, завершается ошибкойFAILED_PRECONDITION.sync/state.backfillCompleteравно true. Включение раньше безопасно — незазеркаленная страница — это промах и уходит в Graph — но большинство чтений проходило бы в обход, что медленнее, чем отсутствие зеркала вообще, и тратит бюджет, нужный обратному заполнению.
Единственное изменение поведения, о котором стоит знать, — это search_pages. При ответе из Graph он обходит разделы и останавливается на 60 из них или через 25 секунд, поэтому сообщает sectionsSearched, sectionsFound и stoppedEarly. При ответе из зеркала нет обхода и нет границы — но содержимое страниц хранится только для блокнотов из вашего выбора, поэтому он сообщает notebooksSearched против notebooksInAccount и говорит об этом в своём примечании, когда они различаются. Модель, читающая «совпадений нет», должна знать, какой из вариантов она получила. Чтобы искать в блокноте вне выбора, передайте sectionId: это даст промах в локальной копии и обойдёт один раздел в OneNote.
Когда выполнение превышает лимит
MIRROR_SYNC_REQUEST_BUDGET и бюджет настенного времени в 240 секунд оба останавливают выполнение задолго до таймаута запроса Cloud Run в 300 секунд, так что обычный ответ — это 200 с "done": false. 504 означает, что что-то заняло больше времени, чем ожидалось — почти всегда 429 от OneNote с длинным Retry-After.
Что происходит дальше, стоит знать. Сервис развёрнут с троттлингом CPU и без минимального количества экземпляров, поэтому прерванный запрос встречает одну из двух судеб, и Cloud Run не обещает ни одну: если экземпляр ещё тёплый, процесс приостанавливается и возобновляется, когда приходит следующий запрос, а если он масштабировался до нуля, работа просто теряется. В любом случае выполнение не завершается, его аренда не освобождается, и повторные попытки отвечают 409, пока аренда не истечёт через 15 минут. Следующее выполнение — задание обратного заполнения, ночная очистка или обновление перед следующим покрытым чтением — затем продолжается нормально.
Ничего не теряется ни в одном случае. Водяные знаки продвигаются по разделам и только после того, как страницы этого раздела сохранены, так что прерванное выполнение сохраняет всё, что зафиксировало, а следующее продолжает с этого места. То же самое касается экземпляра, который просто исчезает между выполнениями: зеркало, токен обновления Microsoft и каждый токен, который выдаёт этот сервер, хранятся или подписаны, а не удерживаются в памяти, так что экземпляр может исчезнуть в любой момент, не сто́я больше одного круга обмена токенами.
Единственное, что нужно проверить при повторяющихся 504, — это журнал запросов на 429. Устойчивый троттлинг — единственный реалистичный способ достичь таймаута, и лекарство — меньший MIRROR_SYNC_REQUEST_BUDGET или более длинный интервал, а не более длинный таймаут Cloud Run.
Восстановление после плохого зеркала
Удалите подколлекции notebooks, sectionGroups, sections, pages, pageContent и tombstones из MIRROR_ROOT_DOC и очистите structureHash и sectionsScannedThrough в sync/state. Затем заново создайте задание onenote-mcp-sync из раздела Настройка и оставьте его, пока backfillComplete снова не станет true: встроенное обновление перед чтением имеет бюджет в 12 запросов и не восстановит зеркало с нуля. Документ выбора и кэш токенов не затрагиваются этим, и никаких записей в Graph не происходит — зеркало — это копия, а Microsoft Graph остаётся источником истины.
Оповещения
Два сбоя невидимы без метрики на основе журнала, потому что оба проявляются только как сообщение внутри разговора Claude или как строка, которую никто не читает:
Событие | Означает |
| Грант Microsoft мёртв. Кто-то должен выполнить |
| MSAL передал кэш без учётных данных в нём. Сохранённая копия уцелела; что-то не так. |
gcloud logging metrics create onenote_mcp_auth_failure \
--description="Microsoft Graph credential failures needing an operator" \
--log-filter='resource.type="cloud_run_revision"
resource.labels.service_name="onenote-mcp"
jsonPayload.event=("graph-auth-failure" OR "token-cache-write-refused")
jsonPayload.retryable!="true"'Затем — политика оповещений на этой метрике выше нуля. Одобрение согласия тоже стоит отслеживать: POST /consent, отвечающий 302, должен происходить только при добавлении коннектора, и журнал запросов уже это фиксирует.
jsonPayload.event="request" jsonPayload.path="/consent" jsonPayload.status=302Начальная настройка
npm run bootstrap — единственный интерактивный вход в Microsoft в проекте, и он выполняется на вашей машине, а не на Cloud Run. Он входит с помощью потока кода устройства и записывает полученный кэш MSAL в документ Firestore, который читает сервер.
gcloud auth application-default login
ONENOTE_CLIENT_ID=00000000-0000-0000-0000-000000000000 \
ONENOTE_AUTHORITY=https://login.microsoftonline.com/common \
GOOGLE_CLOUD_PROJECT=your-project \
FIRESTORE_CACHE_DOC=tokencache/msal \
npm run bootstrapОн печатает сообщение Microsoft с кодом устройства, ждёт, пока вы одобрите в браузере, затем один раз перечисляет ваши блокноты и печатает количество как доказательство, что токен работает. Заключительные строки называют проект Firestore и записанный документ, а также домашний тенант аккаунта, чтобы вы видели, что вошли в правильный каталог. Этот вывод содержит идентификатор тенанта; не выносите его в issues, pull request и журналы рабочих процессов.
GOOGLE_CLOUD_PROJECT и FIRESTORE_CACHE_DOC обязательны здесь, в отличие от сервера, где первое выводится, а второе имеет значение по умолчанию. CLI записывает с вашими собственными Application Default Credentials, поэтому неустановленное значение посеяло бы реальный документ в том проекте, на который указывает ваш вход gcloud, и всё равно напечатало бы строку успеха. Значения MCP_OAUTH_* не читаются, так что выполнение этого никогда не помещает секрет клиента Layer-1 на вашу машину.
Запускайте его снова всякий раз, когда в журналах сервера появляется событие graph-auth-failure с retryable: "false". Токен обновления ротируется при каждом использовании и умирает, если сервис простаивает дольше примерно 90 дней; автоматического восстановления нет. Настройка задания keepalive выше — это то, что предотвращает бездействие как один из способов туда попасть.
Контейнер
Сервис разворачивается в Cloud Run, который работает на linux/amd64. Образ собирается явно под эту платформу, чтобы нативный бинарник @resvg/resvg-js соответствовал. База рантайма — Debian node:24-slim и не должна становиться Alpine — пребилд resvg работает только с glibc.
docker build --platform linux/amd64 -t onenote-mcp .
docker run --rm -p 8080:8080 \
-e PORT=8080 \
-e ONENOTE_CLIENT_ID=00000000-0000-0000-0000-000000000000 \
-e ONENOTE_AUTHORITY=https://login.microsoftonline.com/common \
-e MCP_OAUTH_CLIENT_ID=test-client \
-e MCP_OAUTH_CLIENT_SECRET=test-secret \
-e MCP_TOKEN_SIGNING_KEY=0123456789abcdef0123456789abcdef \
-e MCP_PUBLIC_URL=https://onenote-mcp.example.run.app \
onenote-mcp
curl -i localhost:8080/health # 200, {"status":"ok",...}/healthz отвечает тем же самым, и именно его используют собственные пробы Cloud Run. Не вызывайте его извне: фронтенд Google отвечает на https://<service>.run.app/healthz собственной страницей 404, и запрос вообще не доходит до контейнера, поэтому внешняя проверка аптайма должна использовать /health. Замерено на развёрнутом сервисе 2026-08-19 — /health, /healthz2 и даже /Healthz доходят, и только точный нижний регистр /healthz проглатывается.
Эти значения — заглушки, которые лишь достаточно корректны, чтобы пройти валидацию при запуске; они ни против чего не аутентифицируются. В Cloud Run PORT предоставляется платформой, а остальное приходит из workflow развёртывания. Если порт 8080 на хосте уже занят, укажите другой: -p 8081:8080 с -e PORT=8080.
Чтобы протестировать образ:
RUN_DOCKER_TESTS=1 bash scripts/test/run.shЭто собирает образ, проверяет, что glibc-бинарник resvg пережил production-only установку и что dev-зависимости не попали внутрь, рендерит SVG в PNG внутри контейнера и проверяет, что /healthz отвечает 200 на порту, заданном в PORT. Без RUN_DOCKER_TESTS=1 docker-набор тестов пропускается, а остальное по-прежнему выполняется.
Развёртывание
.github/workflows/deploy.yml запускается при каждом пуше в main и по workflow_dispatch. Он проверяет типы, запускает npm test, собирает проект, собирает и пушит образ контейнера в Artifact Registry с тегом по sha коммита и разворачивает этот образ в Cloud Run. Падающая проверка типов или тест останавливают запуск до сборки образа.
В GitHub нет долгоживущих учётных данных. Задача аутентифицируется через Workload Identity Federation: permissions: id-token: write позволяет ей запросить GitHub OIDC-токен, а google-github-actions/auth@v2 обменивает его на кратковременные учётные данные Google. Никакой JSON-ключ сервисного аккаунта не создаётся scripts/gcp-bootstrap.sh и нигде не нужен. Провайдер принимает только токены, у которых claim repository — этот репозиторий.
Образ собирается на ubuntu-latest, то есть linux/amd64 — платформа, на которой работает Cloud Run, и та, под которую скомпилирован пребилд @resvg/resvg-js. Именно поэтому workflow собирает образ сам, а не использует gcloud run deploy --source, что также означало бы включение Cloud Build и выдачу связанных с ним ролей.
Развёртывание выполняется с --max-instances=1 и --allow-unauthenticated, от имени runtime-сервисного аккаунта, у которого есть roles/datastore.user для кэша токенов Firestore. Именно --allow-unauthenticated позволяет Claude вообще достучаться до сервиса; MCP-эндпоинт закрыт bearer-токеном. См. Bearer-токены на MCP-эндпоинте.
Что должен содержать репозиторий
scripts/gcp-bootstrap.sh подготавливает сторону GCP и выводит команды gh variable set для первых шести. Workflow падает на первом же шаге, называя недостающее, а не разворачивает наполовину сконфигурированный сервис.
Имя | Вид | Значение |
| variable | ID проекта |
| variable | Регион Cloud Run |
| variable | Регион Artifact Registry |
| variable | Полное имя ресурса провайдера workload identity |
| variable | Email сервисного аккаунта развёртывания |
| variable | Email runtime-сервисного аккаунта |
| variable | Client ID регистрации приложения Azure |
| variable | URL центра авторизации Entra |
| variable | Client ID OAuth уровня 1 |
| variable | Публичный URL сервиса; см. ниже |
| variable | Необязательно; по умолчанию |
| secret | Секрет OAuth-клиента уровня 1 |
| secret | Ключ подписи access-токенов, не менее 32 символов |
| secret | Необязательно; если не задан, |
| variable | Необязательно; Cloud Storage bucket зеркала страниц. |
| variable | Необязательно; по умолчанию |
| variable | Необязательно; по умолчанию |
| variable | Необязательно; по умолчанию |
| secret | Необязательно; если не задан, |
Только четыре из них — учётные данные. Имя WIF-провайдера и email сервисных аккаунтов — идентификаторы, бесполезные для того, кто не может предъявить OIDC-идентичность этого репозитория, поэтому они переменные, а не секреты.
Развёртывание передаёт env_vars_update_strategy: overwrite, так что список в workflow — это всё окружение сервиса на каждой ревизии. Действие по умолчанию использует merge, при котором переменная, удалённая из workflow, молча пережила бы предыдущую ревизию. PORT и GOOGLE_CLOUD_PROJECT намеренно отсутствуют в списке: Cloud Run предоставляет оба и отклоняет PORT как входной параметр.
Первое развёртывание и MCP_PUBLIC_URL
MCP_PUBLIC_URL — это OAuth-эмитент и аудитория каждого access-токена, который выпускает этот сервер, и до первого развёртывания никакого сервиса с URL не существует. Workflow разрешает его в три шага: переменная репозитория MCP_PUBLIC_URL, затем URL, который Cloud Run уже назначил сервису, и только — когда нет ни того ни другого — https://placeholder.invalid, который сразу после развёртывания заменяется на настоящий URL. Так что первый запуск работает без участия человека и завершается с корректным значением на месте. Он оставляет предупреждение с именем URL; задайте его как переменную репозитория, потому что это единственный из трёх источников, который переживает установку собственного домена перед сервисом.
Смена MCP_PUBLIC_URL сама по себе ничего не инвалидирует, но каждый уже выданный access-токен привязан к старой аудитории и будет отклонён. В этом случае Claude заново запускает поток авторизации.
Откат
Тег образа — это sha коммита, так что более ранний образ всё ещё лежит в Artifact Registry:
gcloud run services update-traffic onenote-mcp --region "$GCP_REGION" --to-revisions <revision>=100Повторный запуск workflow из более раннего коммита через workflow_dispatch тоже работает и именно он держит развёрнутое окружение в согласии с workflow-файлом этого коммита.
Конфигурация
Каждое значение берётся из переменной окружения и валидируется при запуске. Отсутствующая или некорректная переменная порождает ConfigError, перечисляющий сразу всё, что не так, и процесс завершается с кодом 1 без стектрейса.
Переменная | Обязательна | По умолчанию | Назначение |
| да | — | Идентификатор клиента регистрации приложения Azure (публичный клиент) |
| да | — | URL центра авторизации Entra ID для тенанта |
| да | — | Идентификатор OAuth-клиента уровня 1, который предъявляет Claude |
| да | — | Секрет OAuth-клиента уровня 1 |
| да | — | Ключ для подписи выдаваемых токенов доступа (минимум 32 символа) |
| да | — | Собственный публичный URL сервиса: |
| сервер: нет · bootstrap: да |
| Путь к документу Firestore, содержащему кэш токенов MSAL |
| сервер: нет · bootstrap: да | — | Проект GCP; определяется автоматически на Cloud Run |
| нет |
| Порт привязки. Cloud Run задаёт его; сервер никогда не задаёт его жёстко. |
| нет | — | Не менее 32 символов. Если задан, монтируется |
| нет |
| Документ Firestore, содержащий вручную редактируемые списки |
| нет | — | Не менее 32 символов. Если задан, монтируется |
| условно | — | Сегмент Cloud Storage для отрисованных чернил и HTML страниц большого размера. Обязателен, если задан |
| нет |
|
|
| нет |
| Запросы к Graph, которые один запуск синхронизации может потратить, прежде чем остановится и сообщит о невыполненных. 10–350, при часовом лимите 400. |
Пять переменных MIRROR_* настраивают зеркало страниц Firestore. Все они необязательны, поэтому сервис, развёрнутый без единой заданной переменной, ведёт себя ровно так же, как до появления зеркала, — именно поэтому MIRROR_READ_ENABLED является полноценным переключателем отката, а не изменением кода.
MIRROR_BUCKET — единственное перекрёстное правило в loadConfig. VarSpec указывает обязательность или необязательность для каждой переменной и не может сказать «обязательна, если задана другая», но синхронизации некуда положить отрисованный PNG чернил без сегмента, а зеркальному чтению неоткуда его взять. Поэтому задание MIRROR_SYNC_SECRET или установка MIRROR_READ_ENABLED в true без сегмента приводит к сбою при запуске контейнера с MIRROR_BUCKET в списке отсутствующих — а не через несколько часов обратного заполнения при первой записи объекта.
MIRROR_READ_ENABLED принимает только true и false, без учёта регистра. Он намеренно не снисходителен: 1, yes и on для человека читаются как true, и парсер, принявший бы их, должен был бы также решить, что означают off и 0, и тогда опечатка вроде ture превратилась бы в false и выключила бы зеркало, не сказав ни слова.
FIRESTORE_CACHE_DOC называет документ, который читает и записывает плагин кэша MSAL в src/token-cache.ts. Его значение должно быть путём к документу, то есть чётным числом сегментов, разделённых слэшем; loadConfig отклоняет путь к коллекции при запуске.
ONENOTE_CLIENT_ID и ONENOTE_AUTHORITY идентифицируют регистрацию приложения Azure, которую src/graph-auth.ts предъявляет Entra ID. Это публичный клиент, поэтому намеренно нет секрета клиента уровня 2; значения MCP_OAUTH_* ниже относятся к уровню 1, между Claude и этим сервером, и не связаны с ним.
MCP_PUBLIC_URL — это URL, по которому Claude обращается к этому сервису. Из него строятся издатель OAuth, идентификатор resource, к которому привязан токен, и URL документа метаданных защищённого ресурса. Ничто на Cloud Run не сообщает процессу, по какому URL к нему обращаются, а значение, взятое из заголовка Host, было бы тем, что прислал вызывающий, поэтому он настраивается. Его можно заполнить только после того, как первый деплой создал URL.
npm run bootstrap читает только ONENOTE_CLIENT_ID, ONENOTE_AUTHORITY, FIRESTORE_CACHE_DOC и GOOGLE_CLOUD_PROJECT — не значения MCP_OAUTH_* — и требует последние два, а не подставляет значения по умолчанию. См. Bootstrap.
Гигиена репозитория
Этот репозиторий публичный. Не коммитьте реальное содержимое страниц, отрисованные чернила, имена или идентификаторы тенантов Entra или содержимое документов Firestore. .gitignore исключает output/ и шаблоны файлов кэша токенов; см. раздел «Repo hygiene» в project-spec.md.
This server cannot be deployed
Maintenance
Related MCP Connectors
Manage Microsoft 365 email, calendar, contacts and inbox rules via the Graph API with OAuth 2.0.
Permissioned access to Outlook, OneDrive and Teams via the user's own Microsoft account
Microsoft OneNote (Microsoft 365) MCP Pack
MCP-native open-source Notion alternative: read & write pages, databases and kanban boards.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables AI language models to interact with Microsoft OneNote via a standardized interface, supporting notebook and page management through natural language.21 npm27MIT
- AlicenseNot gradedqualityDmaintenanceEnables interaction with Microsoft OneNote via the Microsoft Graph API, allowing users to list notebooks and retrieve page content. It supports both personal and organization notebooks with credential caching for efficient authentication.21 npm3MIT
- AlicenseDqualityCmaintenanceEnables AI assistants to securely interact with Microsoft OneNote data through the Microsoft Graph API. It supports comprehensive management tasks including searching page content, creating and editing notes, and automating productivity workflows like daily note creation.203MIT
- AlicenseNot gradedqualityFmaintenanceEnables natural language access to Microsoft OneNote notebooks, sections, and pages for reading and listing content.53MIT