Skip to main content
Glama

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

Скрипты

Скрипт

Что делает

npm run build

Компилирует src/ в dist/

npm run typecheck

Проверка типов без генерации кода

npm run dev

Запускает сервер из исходников с --watch

npm start

Запускает скомпилированный сервер из dist/ (сначала build)

npm run bootstrap

Локальный вход с кодом устройства, заполняющий кэш токенов Firestore

npm test

node --test по test/**/*.test.ts

Тесты находятся в 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-emulator

gcloud 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:

reason

Что произошло

Что делать

cache-unreadable

Документ Firestore отсутствует, или его поле cache не является тем, что MSAL может десериализовать

npm run bootstrap

cache-unavailable

Firestore не ответил, или сервисная учётная запись времени выполнения потеряла roles/datastore.user

Повторить. Не вход.

no-account

Кэш прочитан, но не содержит вошедшей учётной записи

npm run bootstrap

silent-failed

Сохранённый refresh-токен истёк или отозван, или конечная точка токена не вернула ничего полезного

npm run bootstrap

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 выше.

Метод

Возвращает

listNotebooks()

Каждую записную книжку по отображаемому имени

listSections(containerKind, containerId)

Разделы непосредственно внутри записной книжки или группы разделов

listSectionGroups(containerKind, containerId)

Группы разделов непосредственно внутри записной книжки или группы разделов

listContainerChildren(containerKind, containerId)

Оба вышеперечисленных, полученные вместе

listPagesInSection(sectionId, top?)

Страницы в одном разделе, сначала самые недавно изменённые, максимум top (по умолчанию 50)

getNotebookTree(notebook)

Одна записная книжка со всеми вложенными группами разделов, разрешёнными рекурсивно

getFullTree()

Каждая записная книжка, каждая со своим разрешённым деревом

getExpandedTree()

Каждая записная книжка с её разделами и одним уровнем групп разделов, одним запросом

findSectionsByName(displayName)

Разделы в любом месте учётной записи, имя которых содержит этот текст, каждый со своей родительской записной книжкой и группой разделов

findPagesByTitle(sectionId, title)

Страницы в одном разделе, заголовок которых совпадает, сравнение без учёта регистра через 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-сервис не задействован.

Модуль

Что он делает

src/multipart.ts

splitMultipart(body, contentType) → части, или null, когда ответ не multipart

src/ink.ts

parseInkStrokes(text) → штрихи; strokesToSvg; rasterizeSvg; renderInk(text, width?) → PNG или null

src/page-content.ts

GraphPageContent.fetchRaw(pageId) → разделённый ответ; .fetchInk(pageId) → PNG или null

Четыре детали определяют, работает ли это вообще, и все четыре взяты из проверенного 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 эмитента, а не из точки монтирования, поэтому его нельзя поместить за префикс — и обслуживает пять маршрутов, все из которых по необходимости неаутентифицированы:

Путь

Что это

GET /.well-known/oauth-authorization-server

Метаданные сервера авторизации RFC 8414

GET /.well-known/oauth-protected-resource/mcp

Метаданные защищённого ресурса RFC 9728

GET,POST /authorize

Конечная точка авторизации

POST /consent

Куда отправляется форма согласия; роутер /authorize из SDK не владеет путём возобновления

POST /token

Конечная точка токена

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'у нечем его продлить. Две правки, обе обязательны — одного переключателя метаданных недостаточно, чтобы остановить выпуск токена.

  1. В src/oauth-router.ts очистите SCOPES_SUPPORTED. Claude добавляет offline_access к запросу авторизации только тогда, когда метаданные это рекламируют, и именно этот переключатель решает, запрашивает ли он refresh token.

  2. В 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

MCP_KEEPALIVE_SECRET не задан в сервисе

Задать его и переразвернуть

503 с "retryable": true

Firestore был недоступен

Повторить

503 с "retryable": false

Грант мёртв

npm run bootstrap

От чего это не защищает: политика условного доступа с частотой входа, смена пароля, сброс MFA или отзыв гранта администратором. Любое из этого убивает refresh token независимо от расписания, и никакое изменение кода этого не избежит. Если тенант Entra ваш, исключите эту регистрацию приложения из политик частоты входа; если нет, считайте 90 дней верхней границей, которую кто-то другой может сократить, не сообщая вам.

Маршрут keepalive также не связан с 30-дневным окном Layer-1 в разделе Token lifetime ниже. Этот refresh token живёт в хранилище коннектора Claude, и только Claude может предъявить его или получить замену, поэтому ничто, работающее здесь, не может поддерживать его жизнь. Потеря его стоит одного клика по кнопке «Разрешить»; потеря Microsoft-токена стоит входа с кодом устройства.

Синхронизация зеркала страниц

Зеркало хранит копии страниц из выбранного вручную набора блокнотов в Firestore, чтобы инструменты чтения могли отвечать, не тратя бюджет запросов OneNote. POST /sync — это то, как оно заполняется. Ничего в этом не автоматично: маршрут не монтируется без секрета, а инструменты чтения не обращаются к зеркалу без MIRROR_READ_ENABLED.

Что поддерживает его актуальность

Три механизма. Только два по расписанию, и один из них временный.

Что его запускает

Что он покрывает

Первоначальное заполнение

Задание планировщика POST /sync, удаляемое по завершении

Каждый зеркалируемый блокнот, активный или нет, заполнен один раз

Текущая догоняющая синхронизация

Каждое покрытое чтение, встроенно, перед ответом

Страницы, созданные и отредактированные в активных блокнотах

Очистка удалений

Одно ночное задание планировщика

Страницы, удалённые из активных блокнотов

В установившемся режиме нет плановой инкрементальной синхронизации, потому что её делают чтения. Каждое покрытое чтение само запускает 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 — жёсткий бюджет

Ночной /sync/sweep/full по активным записным книжкам

~70 здесь, плюс один на каждую изменённую страницу

/sync/sweep по разделам, чья метка времени сдвинулась

один на каждый посещённый раздел

/sync/sweep/all по всем отзеркалированным записным книжкам

~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

*/20

до 360

самая быстрая частота, которая не может превысить лимит

*/15

до 480

ниже лимита, пока запуски ограничены таймером, выше — когда нет

*/10

до 720

выше лимита всякий раз, когда запуски ограничены запросами

*/5

до 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. Это единственный документ, который сервис никогда не записывает.

Поле

Тип

Значение

notebookIds

массив строк

Идентификаторы записных книжек, чьи страницы зеркалируются

activeNotebookIds

массив строк, необязательный

Какие из них всё ещё редактируются. Отсутствует или null означает все.

note

строка

Свободный текст для вас. Никогда не читается.

Получите идентификаторы из 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, outcome: complete

Выполнение завершило свою работу

Ничего

200, outcome: budget-exhausted

Остановилось по бюджету запросов или времени

Ничего; следующий запуск продолжит

401

Секрет отсутствует или неверен

Исправьте задание; маршрут не выполнил работу

404

MIRROR_SYNC_SECRET не задан на сервисе

Задайте его и переразверните

409

Другой запуск удерживает аренду

Ничего; она истекает через 15 минут

504

Запуск превысил таймаут запроса Cloud Run в 300 секунд

Ничего; см. ниже

503, reason: silent-failed

Токен обновления Microsoft пропал

Повторно выполните npm run bootstrap

503, reason: cache-unavailable

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.

Каждое из этих чтений — это три шага, в таком порядке.

  1. Обновление. Инструмент сам выполняет инкрементальную синхронизацию — тот же runIncremental, что и POST /sync, но с меньшими бюджетами: 12 запросов к OneNote и 15 секунд. После удаления задания обратного заполнения это единственное, что вообще выполняет инкрементальный проход.

  2. Чтение локальной копии. Попадание не стоит ни одного запроса к OneNote. Промах переходит к Microsoft Graph, как и раньше.

  3. Сообщение источника. source равен onenote, когда ответ совпадает с тем, что хранит OneNote, best-available, когда часть ответа взята из блокнота, который оператор заморозил, и mirror, когда он может отставать.

Полное правило source. «Подтверждено» означает, что обновление на шаге 1 завершилось, или ответ — это одна страница, которую ни одна запись не пометила устаревшей:

Ситуация

source

Чтение перешло к Microsoft Graph

onenote

Локальное попадание из активного блокнота, подтверждено

onenote

Локальное попадание из активного блокнота, не подтверждено

mirror

Локальное попадание из неактивного блокнота, подтверждено или нет

best-available

Неограниченный поиск, покрывающий оба, подтверждено

best-available

Неограниченный поиск, покрывающий оба, не подтверждено

mirror

Обновление «завершено» означает, что оно прочитало дерево блокнотов, посетило все нужные разделы, получило все нужные страницы и не исчерпало ни один из бюджетов. Всё остальное — запросный или временной бюджет исчерпан, дерево ответило 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 или как строка, которую никто не читает:

Событие

Означает

graph-auth-failure с retryable: "false"

Грант Microsoft мёртв. Кто-то должен выполнить npm run bootstrap.

token-cache-write-refused

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 падает на первом же шаге, называя недостающее, а не разворачивает наполовину сконфигурированный сервис.

Имя

Вид

Значение

GCP_PROJECT

variable

ID проекта

GCP_REGION

variable

Регион Cloud Run

GAR_REGION

variable

Регион Artifact Registry

WIF_PROVIDER

variable

Полное имя ресурса провайдера workload identity

DEPLOY_SA

variable

Email сервисного аккаунта развёртывания

RUNTIME_SA

variable

Email runtime-сервисного аккаунта

ONENOTE_CLIENT_ID

variable

Client ID регистрации приложения Azure

ONENOTE_AUTHORITY

variable

URL центра авторизации Entra

MCP_OAUTH_CLIENT_ID

variable

Client ID OAuth уровня 1

MCP_PUBLIC_URL

variable

Публичный URL сервиса; см. ниже

FIRESTORE_CACHE_DOC

variable

Необязательно; по умолчанию tokencache/msal

MCP_OAUTH_CLIENT_SECRET

secret

Секрет OAuth-клиента уровня 1

MCP_TOKEN_SIGNING_KEY

secret

Ключ подписи access-токенов, не менее 32 символов

MCP_KEEPALIVE_SECRET

secret

Необязательно; если не задан, POST /keepalive не монтируется

MIRROR_BUCKET

variable

Необязательно; Cloud Storage bucket зеркала страниц. scripts/gcp-bootstrap.sh выводит созданное имя

MIRROR_READ_ENABLED

variable

Необязательно; по умолчанию false. Переключатель, заставляющий read-инструменты обновлять и использовать зеркало

MIRROR_ROOT_DOC

variable

Необязательно; по умолчанию onenoteMirror/default

MIRROR_SYNC_REQUEST_BUDGET

variable

Необязательно; по умолчанию 120

MIRROR_SYNC_SECRET

secret

Необязательно; если не задан, POST /sync не монтируется

Только четыре из них — учётные данные. Имя 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 без стектрейса.

Переменная

Обязательна

По умолчанию

Назначение

ONENOTE_CLIENT_ID

да

—

Идентификатор клиента регистрации приложения Azure (публичный клиент)

ONENOTE_AUTHORITY

да

—

URL центра авторизации Entra ID для тенанта

MCP_OAUTH_CLIENT_ID

да

—

Идентификатор OAuth-клиента уровня 1, который предъявляет Claude

MCP_OAUTH_CLIENT_SECRET

да

—

Секрет OAuth-клиента уровня 1

MCP_TOKEN_SIGNING_KEY

да

—

Ключ для подписи выдаваемых токенов доступа (минимум 32 символа)

MCP_PUBLIC_URL

да

—

Собственный публичный URL сервиса: https, без запроса, без фрагмента, без завершающего слэша

FIRESTORE_CACHE_DOC

сервер: нет · bootstrap: да

tokencache/msal

Путь к документу Firestore, содержащему кэш токенов MSAL

GOOGLE_CLOUD_PROJECT

сервер: нет · bootstrap: да

—

Проект GCP; определяется автоматически на Cloud Run

PORT

нет

8080

Порт привязки. Cloud Run задаёт его; сервер никогда не задаёт его жёстко.

MCP_KEEPALIVE_SECRET

нет

—

Не менее 32 символов. Если задан, монтируется POST /keepalive; если не задан, путь возвращает 404. См. Keepalive.

MIRROR_ROOT_DOC

нет

onenoteMirror/default

Документ Firestore, содержащий вручную редактируемые списки notebookIds и activeNotebookIds. Его подколлекции и есть зеркало. Путь к документу, чётное число сегментов, то же правило, что и для FIRESTORE_CACHE_DOC.

MIRROR_SYNC_SECRET

нет

—

Не менее 32 символов. Если задан, монтируется POST /sync; если не задан, путь возвращает 404.

MIRROR_BUCKET

условно

—

Сегмент Cloud Storage для отрисованных чернил и HTML страниц большого размера. Обязателен, если задан MIRROR_SYNC_SECRET или MIRROR_READ_ENABLED; см. ниже.

MIRROR_READ_ENABLED

нет

false

true или false, и ничего больше. Если true, инструменты чтения обновляют зеркало, отвечают из него и обращаются к Graph при промахе.

MIRROR_SYNC_REQUEST_BUDGET

нет

120

Запросы к 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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables 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 npm
    3
    MIT
  • A
    license
    D
    quality
    C
    maintenance
    Enables 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.
    20
    3
    MIT