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. Обе точки входа используют один и тот же плагин: bootstrap-CLI записывает кэш через него, а сервер читает через него, так что есть один сериализатор и не нужно поддерживать второй формат.
Этот блоб — единственная копия токена обновления, поэтому его защищают две вещи.
Запись, которая опустошила бы документ, отклоняется. 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, а не проявляется как ошибка, которую даёт мёртвый токен обновления. Перед этим записи повторяются три раза. См. строку cache-unavailable в таблице ниже, чтобы понять, почему это различие стоит кода.
npm test покрывает только readCache — функцию, которая декодирует снимок документа. Два колбэка, транзакция и createFirestoreTokenCachePlugin не имеют автоматических тестов — им нужен бэкенд Firestore. Их проверка означает эмулятор, которому нужны java в PATH и собственная установка:
sudo apt-get install google-cloud-cli-firestore-emulatorgcloud components install cloud-firestore-emulator не устанавливает его в Google Cloud CLI из пакета Debian. В такой сборке менеджер компонентов отключён, и 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 не поддерживают аутентификацию только для приложения, поэтому нет запасного варианта, когда сохранённый токен обновления умирает, — человек заново запускает npm run bootstrap. Поэтому каждый сбой — это GraphAuthError, сообщающий об этом, а не сырая ошибка MSAL, которая дошла бы до вызывающего кода как голый 401 от Graph:
| Что произошло | Что делать |
| Документ Firestore отсутствует, или его поле |
|
| Firestore не ответил, или сервисный аккаунт времени выполнения потерял | Повторите. Не вход. |
| Кэш был прочитан, но не содержит выполнившей вход учётной записи |
|
| Сохранённый токен обновления истёк или отозван, либо эндпоинт токена не вернул ничего пригодного |
|
cache-unavailable — строка, которая оправдывает своё существование. Firestore читается и записывается внутри acquireTokenSilent через плагин кэша, поэтому сбой бэкенда раньше приходил как тот же отказ, который даёт мёртвый токен обновления, — и это сообщение говорило оператору идти в браузер и заменять работающие учётные данные. 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, "maximum sections exceeded", в структуре «одна записная книжка на год». Вывод страниц всегда ограничен/me/onenote/sections/{id}/pages, и тест сканируетsrc/в поисках пути по всему аккаунту.
Failures are GraphRequestError for a non-2xx response — it carries status,
statusText, and the response body, because error 20266 is only distinguishable from
any other 400 by that text — and GraphResponseError for a 2xx whose body is not the
expected shape, a listing that will not terminate, or section groups nested past 20
levels. No message contains a notebook, section, or page name.
npm test drives all of it through a fake fetch keyed by exact URL. What that cannot
check is whether Graph accepts those URLs; the query strings come from the validated
recon script in Appendix A of project-spec.md and are confirmed only by running against
the real tenant.
Ink
Graph's normal page-content endpoint drops handwriting and leaves
<!-- InkNode is not supported --> behind, and Graph cannot export a page as an image or
a PDF. Handwriting is therefore rebuilt from raw stroke data:
GET /me/onenote/pages/{id}/content?includeInkML=true answers multipart/mixed, one
part the same HTML and another the InkML. The strokes become an SVG and then a PNG, which
goes to the calling model as an image for its own vision to read. No OCR service is
involved.
Модуль | Что делает |
|
|
|
|
|
|
Four details decide whether this works at all, and all four come from the validated recon
script in Appendix A of 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 через 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, create_page,
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.
Эндпоинт здоровья остаётся открытым.
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 обратно на 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 без токена, поэтому добавленный позже маршрут закрыт, если кто-то
не откроет его намеренно.
Никакие scopes не требуются. offline_access — единственный scope, который выдаёт
этот сервер, — касается того, выдаётся ли refresh-токен, а не того, что может делать
вызывающий; требование его давало бы 403 для токенов, которые в остальном хороши.
Если проверка scope когда-нибудь будет добавлена, 403 должен нести
WWW-Authenticate: Bearer error="insufficient_scope" — что это middleware и делает, —
потому что Claude считает любой другой 403 терминальным и ни о чём не спрашивает.
Время жизни токена и принудительная повторная проверка
Этот сервер создан для работы без присмотра. Настройки по умолчанию отражают это и жертвуют некоторой способностью отозвать утёкшие учётные данные. Прочтите это, прежде чем разворачивать его где-то, где это важно, и измените числа, если такой обмен вам не подходит.
Что делают настройки по умолчанию
Токен | Время жизни | Что его продлевает |
Токен доступа | 1 час | Токен обновления, автоматически |
Refresh token | 30 дней | Каждое обновление выпускает новый refresh token с новым сроком 30 дней |
Форма согласия | 10 минут | Ничего; устаревшая форма отклоняется, и поток авторизации начинается заново |
Claude обновляется сам — превентивно, до истечения часа, и реактивно по коду 401. Поэтому человек нажимает Approve при первом добавлении коннектора, а потом только если коннектор не использовался 30 дней. В этом суть скользящего окна: 30 дней ограничивают время, в течение которого соединение может оставаться неактивным, а не то, сколько оно может жить.
Почему скользящее окно и чего это стоит
Каждый токен, выдаваемый этим сервером, не имеет состояния. Это подписанная полезная нагрузка и ничего больше — ни строки в базе данных, ни записи о сессии, ни того, что нужно искать при возврате токена. Именно поэтому замена ревизии Cloud Run незаметна: новый экземпляр проверяет токен, выданный старым, без какого-либо общего состояния между ними. Хранилище токенов означало бы переподключение при каждом деплое.
Цена такого решения — невозможно отозвать что-либо по отдельности. Эндпоинта отзыва нет, потому что удалять нечего. В частности:
Утёкший токен обновления выдаёт доступ на срок до 30 дней, и каждое его использование продлевает доступ его обладателю ещё на 30 дней. На стороне сервера нет записи, которую можно было бы аннулировать, и невозможно отличить украденный токен обновления от легитимного — это одни и те же байты, подписанные одним и тем же ключом.
Скользящее окно — не ротация. Когда обновление выпускает новый токен обновления, тот, который он заменяет, продолжает работать до своего собственного срока истечения. Настоящая ротация означала бы пометку старого токена как использованного, а для этого нужно хранилище, которого в этой схеме нет.
Токен доступа нельзя заблокировать в течение его часа — по той же причине.
С этим остаётся один грубый рычаг, который действует немедленно: смените MCP_TOKEN_SIGNING_KEY и передеплойте. Одномоментно будут аннулированы все токены доступа, все токены обновления и все открытые страницы согласия, потому что все они проверяются через этот ключ. Следующий запрос Claude получит 401, и оператор один раз нажмёт Approve. Ротация ключа по расписанию сама по себе — разумная политика.
Экран согласия, как ни странно, никого не аутентифицирует — на нём одна кнопка и никакого пароля. Между незнакомцем и вашими записными книжками стоят 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Если коннектор не используется так долго, он требует нажатия кнопки. При регулярном использовании он вообще не требует подтверждений — окно продолжает уезжать вперёд. Это ограничивает время жизни утёкшего токена обновления после того, как утечка перестала использоваться, и ничего более.
Остановить сдвижение окна. Именно так изначально была сформулирована 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-токенов. Самый строгий вариант: человек подтверждает каждый час, потому что истекшему токену доступа нечем обновляться. Нужны обе правки — только включения в метаданных недостаточно, чтобы перес ставить выдачу токена.
В
src/oauth-router.tsочиститеSCOPES_SUPPORTED. Claude добавляетoffline_accessк запросу авторизации только тогда, когда это объявлено в метаданных, — именно это переключатель определяет, просит ли клиент refresh-токен.В
src/oauth-provider.tsуберите полеrefresh_tokenиз того, что возвращаетissueTokens. Сейчас он выдаётся независимо от запрошенных scope.
Учтите, что этот вариант будет заметен: Claude возвращает браузер к экрану подтверждения посреди сессии, когда истекает час.
Сократить токен доступа. ACCESS_TOKEN_TTL_S в src/oauth-provider.ts сужает окно, в котором работает утёкший access token. Это стоит один запрос к токену при каждом истечении и не требует участия человека, так что дёшево, но никак не помогает при утечке refresh-токена, а именно он учётные данные, о которых стоит беспокоиться.
Keepalive
Делегированные refresh-токены Microsoft через 30 дней без использования примерно дня. Token сдвигается вперёд только в момент реальной его замене, а замена происходит только тогда, когда после истечения удерживаемого access token приходит запрос инструмента — так что коннектор, которым никто не пользуется, три месяца, это коннектор, которому нужен человек, который откроет браузер и запустит npm run bootstrap. На сервере после того, как подключено никого не осталось.
Soft
События отзыва не будет — сокрытие, то отправка.
POST /keepalive — исправление. Он вызывает acquireTokenSilent с forceRefresh: true, который пропускает токен доступа и принуждает сётку к расходу refresh-токена, так что Entra выдаёт новую функцию с новым окном, а src/token-cache.ts записывает её в Firestore. forceRefresh — ключевая часть: без него MSAL отвечает из собственного кэша, запрос до Entra не доходит, и окно не двигается.
Задайте MCP_KEEPALIVE_SECRET как минимум из 32 случайных символов, и маршрут будет подключён; оставьте переменную незаданной, и путь будет возвращать 404. Планировщик передаёт секрет в заголовке X-Keepalive-Secret, и он сравнивается за константное время до выполнения любой работы. Это общий секрет, а не bearer-токен, потому что планировщик не проходит OAuth-поток — нет браузера и нет места для refresh-токена. Это отдельная собственная переменная, а не клиентский секрет Layer-1, чтобы учётные данные для доступа ко всей MCP-поверхности не хранились в свою очередь в планировщике.
gcloud scheduler jobs create http onenote-mcp-keepalive \
--schedule="0 4 * * 1" \
--time-zone=UTC \
--uri="https://YOUR-SERVICE-URL/keepalive" \
--http-method=POST \
--headers="X-Keepalive-Secret=YOUR-SECRET" \
--attempt-deadline=60s \
--max-retry-attempts=3Еженедельного запуска достаточно над 90-дневным окном и оставляет запас на несколько пропущённых запусков. Такая задача стоит одного запроса к токен-эндпоинту и одной записи в Firebase.
Статус Стороны | Значение | Что должен делать планировщик |
200 | Токен обновления успешно заменён, новый сохранил | Ничего |
401 | Секрет отсутствует или неверен | Чинить задачу; маршрут ничего не работал |
404 |
| Задать его и передеплойте |
503 с | Firestore недоступен | Попробовать ещё раз |
503 с | Грант мёртв |
|
Что это не защищает: политика условного доступа с частотой входовнарушений, смена применения нового пароля, сброс MFA или отзыв грант админинистратором. Любое из этих убивает refresh-токен, это упоминания в расписании, и никаким изменением кода и руб.Если сеть Entra — ваш, то эту регистрацию приложения политики. Если нет, то стоило 90 дней как верхнюю границу, которую кто-то другой минимизирует с вашим.
Маршрут keepalive также не связан одним сокно в 30 дней, описанном в Time life токена ниже. Тот refresh-токен живёт в модуле коннектора Claude, и только Claude может его предъявить или получить все его замены. Так что ничего на текущем сервере не может его оживить. Терять его — это один клик по Approve; терять Microsoft-токен — это вход с кодом устройства.
Alerting
Два сбоя в без классическ for log-based mock. Оба появляются только как сообщение внутри диалога 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"'Теперь настройте оповещения о том, что эта точка больше нуля. По из aux also не лучше смотреть: POST /consent отвечает кодом 302 только когда вы добавляете коннектор, и это уже есть в логе запросов.
jsonPayload.event="request" jsonPayload.path="/consent" jsonPayload.status=302Bootstrap
npm run bootstrap — единственный способ интерактивно войти в Microsoft, и он выполняется на вашей машине, а не на Cloud Run. Он использует device-code flow и записывает полученный кэш MSQL в документ 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, документ записи и домашний 901 tenantного. В том арендаторе, который вы увидите. Такой вывод содержит tenant id, поэтому не оставляйте его в пулл реквестах, issue или в журналах workflow.
GOOGLE_CLOUD_PROJECT и FIRESTORE_CACHE_DOC обязательны здесь, в отличие от сервера, где первый выводится автоматически, а второй имеет заданное по умолчанию. CLI пишет с вашими Application Default Credentials: если переменная не задана, он создаст настоящий документ в том проекте, а в вашем gcloud login, и всё равно напечатает успех. MCP_OAUTH_* не используются, и это никогда не положит на вашу машинку клиентский секрет Layer-1.
Запускайте её заново, как только в логами серверного появится событие graph-auth-failure с retryable: "false". Refresh-токен ротируется при каждом использовании и умирает, если сервис не активен больше днявелюду 90 дней; системы отказа не сработают. Задача keepalive выше — именно то, что защищает от смерти среди и активного времени.
Контейнер
Сервис развертывается на Cloud Run, которое работает под linux/amd64. От из-за для этой платформы собран отдельно, чтобы бинарник native-модуля @resvg/resvg-js соответствовал. Базовый runtime — Debian node:24-slim, и замена на него фатально: node:24-slim — glibc-based, для Alpine это не подойдёт.
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 front отдаёт https://<service>.run.app/healthz собственную страницу 404, и запрос до контейнера не доходит, поэтому внешний сценарий должен использовать /health (может). Соблюдения на прод на 2026-08-19 — /health, /healthz2 и даже /Healthz доходят, и не доходит только lowercase /healthz.
Эти значения необхощены only as goed and enough to pass start. дальше. Только PORT подставляет платформа. Если порт 8080 кем-то занят, сопоставьте другой: -p 8081:8080 и -e PORT=8080.
Тест образа:
RUN_DOCKER_TESTS=1 bash scripts/test/run.shЭто строит образ, проверяет, что resvg-glibc-бинарник пережил установку в production-режиме и в нём нет dev-зависимостей, рендерит SVG-в-PNG внутри контейнера; и утверждает,/healthz answer 200 not given in PORT. If not RUN_DIRTY_TESTS=1, docker-тесты и quietly ignored.
Деплой
.github/workflows/deploy.yml запускается при каждом push в 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. scripts/gcp-bootstrap.sh не создаёт JSON-ключ сервисного аккаунта, и он нигде не требуется. Провайдер принимает только токены, у которых утверждение 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 падает на первом шаге, называя недостающее, вместо того чтобы развернуть наполовину настроенный сервис.
Имя | Тип | Значение |
| переменная | ID проекта |
| переменная | регион Cloud Run |
| переменная | регион Artifact Registry |
| переменная | полное имя ресурса провайдера workload identity |
| переменная | email сервисного аккаунта развёртывания |
| переменная | email runtime-сервисного аккаунта |
| переменная | client ID регистрации приложения Azure |
| переменная | authority URL Entra |
| переменная | OAuth client ID уровня 1 |
| переменная | публичный URL сервиса; см. ниже |
| переменная | необязательно; по умолчанию |
| секрет | OAuth client secret уровня 1 |
| секрет | ключ подписи access-токенов, не менее 32 символов |
| секрет | необязательно; если не задан, |
Только три из них — учётные данные. Имя WIF-провайдера и email сервисных аккаунтов — это идентификаторы: они бесполезны для того, кто не может предъявить OIDC-идентичность этого репозитория, поэтому они переменные, а не секреты.
Развёртывание передаёт env_vars_update_strategy: overwrite, поэтому список в workflow — это всё окружение сервиса на каждой ревизии. По умолчанию у action — 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 без стектрейса.
Переменная | Обязательность | По умолчанию | Назначение |
| да | — | client ID регистрации приложения Azure (публичный клиент) |
| да | — | authority URL Entra ID для тенанта |
| да | — | OAuth client ID уровня 1, который предъявляет Claude |
| да | — | OAuth client secret уровня 1 |
| да | — | ключ для подписи выпускаемых access-токенов (мин. 32 символа) |
| да | — | собственный публичный URL сервиса: |
| сервер: нет · bootstrap: да |
| путь к документу Firestore, содержащему кэш токенов MSAL |
| сервер: нет · bootstrap: да | — | проект GCP; определяется автоматически на Cloud Run |
| нет |
| порт привязки. Cloud Run задаёт его; сервер никогда не зашивает его жёстко |
| нет | — | не менее 32 символов. Если задан, |
FIRESTORE_CACHE_DOC задаёт документ, который читает и пишет плагин кэша MSAL в src/token-cache.ts. Его значение должно быть путём к документу, то есть содержать чётное число сегментов, разделённых слешами; loadConfig отклоняет путь к коллекции при запуске.
ONENOTE_CLIENT_ID и ONENOTE_AUTHORITY идентифицируют регистрацию приложения Azure, которую src/graph-auth.ts предъявляет Entra ID. Это публичный клиент, поэтому client secret уровня 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.
Гигиена репозитория
Этот репозиторий публичный. Не коммитьте реальное содержимое страниц, отрисованные чернила, имена или ID тенантов Entra, а также содержимое документов Firestore. .gitignore исключает output/ и шаблоны файлов кэша токенов; см. раздел «Repo hygiene» в project-spec.md.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
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.1527MIT
- 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.153MIT
- 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.202MIT
- AlicenseNot gradedqualityFmaintenanceEnables natural language access to Microsoft OneNote notebooks, sections, and pages for reading and listing content.45MIT
Related MCP Connectors
Microsoft OneNote (Microsoft 365) MCP Pack
MCP-native open-source Notion alternative: read & write pages, databases and kanban boards.
Access the Notra API for managing posts, brand identities, integrations, and schedules.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/dovrosenberg/onenote-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server