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. Обе точки входа используют один и тот же плагин: 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-emulator

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

reason

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

Что делать

cache-unreadable

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

npm run bootstrap

cache-unavailable

Firestore не ответил, или сервисный аккаунт времени выполнения потерял roles/datastore.user

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

no-account

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

npm run bootstrap

silent-failed

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

npm run bootstrap

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.

Метод

Возвращает

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, "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.

Модуль

Что делает

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

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

Путь

Что это

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

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

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

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

Задать его и передеплойте

503 с retryable: true

Firestore недоступен

Попробовать ещё раз

503 с retryable: false

Грант мёртв

npm run bootstrap

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

Маршрут keepalive также не связан одним сокно в 30 дней, описанном в Time life токена ниже. Тот refresh-токен живёт в модуле коннектора Claude, и только Claude может его предъявить или получить все его замены. Так что ничего на текущем сервере не может его оживить. Терять его — это один клик по Approve; терять Microsoft-токен — это вход с кодом устройства.

Alerting

Два сбоя в без классическ for log-based mock. Оба появляются только как сообщение внутри диалога 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"'

Теперь настройте оповещения о том, что эта точка больше нуля. По из aux also не лучше смотреть: POST /consent отвечает кодом 302 только когда вы добавляете коннектор, и это уже есть в логе запросов.

jsonPayload.event="request" jsonPayload.path="/consent" jsonPayload.status=302

Bootstrap

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

Имя

Тип

Значение

GCP_PROJECT

переменная

ID проекта

GCP_REGION

переменная

регион Cloud Run

GAR_REGION

переменная

регион Artifact Registry

WIF_PROVIDER

переменная

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

DEPLOY_SA

переменная

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

RUNTIME_SA

переменная

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

ONENOTE_CLIENT_ID

переменная

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

ONENOTE_AUTHORITY

переменная

authority URL Entra

MCP_OAUTH_CLIENT_ID

переменная

OAuth client ID уровня 1

MCP_PUBLIC_URL

переменная

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

FIRESTORE_CACHE_DOC

переменная

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

MCP_OAUTH_CLIENT_SECRET

секрет

OAuth client secret уровня 1

MCP_TOKEN_SIGNING_KEY

секрет

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

MCP_KEEPALIVE_SECRET

секрет

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

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

Переменная

Обязательность

По умолчанию

Назначение

ONENOTE_CLIENT_ID

да

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

ONENOTE_AUTHORITY

да

authority URL Entra ID для тенанта

MCP_OAUTH_CLIENT_ID

да

OAuth client ID уровня 1, который предъявляет Claude

MCP_OAUTH_CLIENT_SECRET

да

OAuth client secret уровня 1

MCP_TOKEN_SIGNING_KEY

да

ключ для подписи выпускаемых access-токенов (мин. 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.

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.

A
license - permissive license
Not graded
quality - not tested
B
maintenance

Maintenance

Maintainers
7hResponse time
Release cycle
Releases (12mo)
Commit activity

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

  • 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.
    15
    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
    2
    MIT

View all related MCP servers

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.

View all MCP Connectors

Latest Blog Posts

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