Skip to main content
Glama

onenote-mcp

Microsoft Graph を通じて Microsoft OneNote を公開する MCP サーバー — ノートブックと セクションの構造、ページコンテンツ、そして呼び出し元モデルが読める画像にレンダリングされた手書き文字。

project-spec.md が権威ある設計ドキュメントです。 インク再構築パイプライン、2つの独立した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

test/**/*.test.ts に対して node --test を実行

テストは test/ にあり、src/ をミラーリングしています。Node のネイティブ型ストリッピングを使用して TypeScript ソースに対して直接実行されるため、npm test はビルドを必要としません。これによりソースに制約が生じます: enum なし、namespace なし、コンストラクタパラメータプロパティなし、型のみのインポートは import type と書く必要があります。erasableSyntaxOnly と verbatimModuleSyntax コンパイラオプションがこれを強制します。

ディレクトリレイアウトとそれに伴う規約については CLAUDE.md を参照してください。

トークンキャッシュ

src/token-cache.ts は、単一の Firestore ドキュメントに対する MSAL の ICachePlugin を実装しており、そのパスは FIRESTORE_CACHE_DOC から取得されます。beforeCacheAccess はドキュメントの cache フィールドを読み取り、その文字列を MSAL に渡します。afterCacheAccess は、MSAL がキャッシュが変更されたと報告した場合にのみ、シリアライズされたキャッシュを Firestore トランザクション内で書き戻します。存在しないドキュメントは空のキャッシュとして読み取られ、これは npm run bootstrap が実行される前の状態です。両方のエントリポイントがこの同じプラグインを使用します: ブートストラップ CLI はそれを通じてキャッシュを書き込み、サーバーはそれを通じて読み取るため、シリアライザは1つで、同期を保つための2番目の形式はありません。

そのブロブがリフレッシュトークンの唯一のコピーであるため、2つのことがそれを保護します。

ドキュメントを空にする書き込みは拒否されます。 MSAL は一部の障害時にインメモリキャッシュから資格情報を削除し、afterCacheAccess は MSAL の finally ブロック内で実行されるため、アカウントを失ったシリアライゼーションがこのコードに到達する可能性がありますが、保存されているものはまだ有効です。overwriteWouldEmptyCache がそれを停止し、{"event":"token-cache-write-refused"} をログに記録します。空チェックは MSAL のキー名を読み取りません — キャッシュは、すべての値が空のコンテナであるオブジェクトに解析されるときに空です — そのため、MSAL が形式を変更しても反転できず、認識されないものはブロックされるのではなく通過が許可されます。

各書き込みが置き換えるブロブは previousCache フィールドに保持されます。 履歴ではなく1世代: キャッシュはリフレッシュのたびに書き換えられ、有用なコピーは常に最新の良好なものです。悪い書き込みからの回復は、Firestore コンソールでそのフィールドを cache にコピーすることであり、代替手段がデバイスコードサインインであるため、それだけの価値があります。ポイントインタイムリカバリを有効にして2番目のレイヤーを追加します:

gcloud firestore databases update --enable-pitr

バックエンドの障害は資格情報の障害ではありません。 Firestore が到達不能であるか、roles/datastore.user バインディングが失効している場合、TokenCacheUnavailableError が発生し、期限切れのリフレッシュトークンが生成するエラーとして表面化しません。書き込みはその前に3回再試行されます。この区別がコードの価値がある理由については、以下の表の cache-unavailable 行を参照してください。

npm test は、ドキュメントスナップショットをデコードする関数 readCache のみをカバーします。2つのコールバック、トランザクション、および createFirestoreTokenCachePlugin には自動テストがありません — それらは Firestore バックエンドを必要とします。それらを実行するにはエミュレータが必要で、PATH に java とそれ自身のインストールが必要です:

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 は、ONENOTE_CLIENT_ID、ONENOTE_AUTHORITY、および Firestore キャッシュプラグインから1つの PublicClientApplication を構築し、プロセスの存続期間中保持します。getAccessToken() はキャッシュされたアカウントを読み取り、acquireTokenSilent を呼び出し、トークンを返します。要求されるスコープは Notes.Read と Notes.ReadWrite で、完全修飾されています。

デプロイされたサーバーは対話的にサインインすることはありません。誰かにプロンプトを表示する方法がなく、Graph の OneNote エンドポイントはアプリ専用認証をサポートしていないため、保存されたリフレッシュトークンが失効した場合のフォールバックはありません — 人間が npm run bootstrap を再実行します。したがって、すべての障害はそれを示す GraphAuthError であり、呼び出し元に Graph からの素の 401 として届く生の MSAL エラーではありません:

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 がその区別を伝え、その理由だけがそれを設定します。

これらのそれぞれも stderr に1行書き込みます:

{"event":"graph-auth-failure","reason":"silent-failed","documentPath":"tokencache/msal","retryable":"false"}

その行が要点です。ツールの障害はそれ以外は Claude 会話内にのみ現れるため、それがなければコネクタが動作を停止したことをオペレーターに伝えるものはありません。下の アラート を参照してください。

メッセージはドキュメントパスと基になる MSAL エラーを名前で示し、意図的にアカウント識別子を含みません: username はユーザーの UPN であり、homeAccountId はテナント ID を埋め込んでおり、どちらもログに含めるべきではありません。

npm test はフェイククライアントを通じて取得ロジックをカバーします。createGraphAuth 自体には自動テストがありません: 実際のデバイスコードサインインによってシードされたキャッシュが必要であり、それをシードできる資格情報はコミットしてはなりません。npm run bootstrap を実行し、同じドキュメントに対してサーバーを実行してそれを実行します。その消費者は以下の Graph 構造クライアントです。どちらもまだ createApp に配線されていません。

Graph 構造

src/graph-structure.ts は OneNote ツリーを読み取ります: ノートブック、セクショングループ、セクション、および1つのセクション内のページリスト。new GraphStructure(auth) は getAccessToken() を持つ任意のものを受け取るため、サーバーは上記の GraphAuth を渡します。

メソッド

戻り値

listNotebooks()

表示名によるすべてのノートブック

listSections(containerKind, containerId)

ノートブックまたはセクショングループの直下のセクション

listSectionGroups(containerKind, containerId)

ノートブックまたはセクショングループの直下のセクショングループ

listContainerChildren(containerKind, containerId)

上記の両方をまとめて取得

listPagesInSection(sectionId, top?)

1つのセクション内のページ、最近変更された順、最大 top(デフォルト 50)

getNotebookTree(notebook)

ネストされたすべてのセクショングループが解決された1つのノートブック

getFullTree()

すべてのノートブック、それぞれのツリーが解決されたもの

getExpandedTree()

セクションと1レベルのセクショングループを持つすべてのノートブックを、単一のリクエストで取得

findSectionsByName(displayName)

アカウント内の名前がそのテキストを含むセクション、それぞれの親ノートブックとセクショングループ付き

findPagesByTitle(sectionId, title)

タイトルが一致する1つのセクション内のページ、Graph によって大文字小文字を区別せず比較

containerKind は notebooks または sectionGroups — 2つの Graph リレーションシップ名です。両方のコンテナ種類が同じ子リレーションシップを公開するため、リストメソッドは2回存在する代わりに種類を受け取ります。

getExpandedTree() は安価なものです。Graph にリレーションシップをウォークする代わりに展開するよう要求します:

GET /me/onenote/notebooks?$select=id,displayName
    &$expand=sections($select=id,displayName),
             sectionGroups($select=id,displayName;$expand=sections($select=id,displayName))

54ノートブックのアカウントに対して測定: 1リクエストと78 KB、getFullTree() の195リクエストに対して。OneNote が1時間に400リクエストと5つの同時実行を許可するため、これは重要です。各展開句内の $select がレスポンスを441 KBから78 KBに減らすものであり、$select と $expand の両方を持つ句内のセパレータはセミコロンです。到達しないのはセクショングループ内にネストされたセクショングループです — Graph は $expand のネストを2レベルに制限します — そのため findSectionsByName は代わりに1リクエストでそのケースをカバーし、アカウント全体のセクションリストをフィルタリングし、各セクションの親を展開します。

api-overview.md は、これらのエンドポイントが受け入れるもの、サービスが独自のドキュメントと矛盾する場所を含めて記録しています。

単一の Graph 呼び出しが処理しない3つのことをトラバーサルが処理します:

  • ネスト。 セクショングループは UI の「タブグループ」であり、さらにセクショングループを含みます。getNotebookTree は再帰します。

  • ページング。 すべてのリスト呼び出しは、@odata.nextLink が表示されなくなるまでそれに従います。Graph は独自のページサイズを選択し、より大きな $top を無視するため、1つのレスポンスがコレクションが完全であることの証明にはなりません。listPagesInSection は top アイテムが手元にあるとすぐに停止するため、top はページサイズではなく結果数です。

  • アカウント全体のページリストは決して呼び出されません。 GET /me/onenote/pages は、ノートブックごとの年の構造でエラー 20266「最大セクション数を超えました」で失敗します。ページリストは常に /me/onenote/sections/{id}/pages にスコープされ、テストは src/ をアカウント全体のパスについてスキャンします。

失敗は、2xx以外のレスポンスの場合 GraphRequestError です。これは status、statusText、レスポンスの body を保持します。エラー20266は、そのテキストによってのみ他の400と区別できるためです。また、2xxでもボディが期待した形でない場合、終了しないリスト、または20レベルを超えてネストされたセクショングループの場合は GraphResponseError です。どのメッセージにもノートブック、セクション、ページ名は含まれません。

npm test は、正確なURLをキーにしたフェイクの fetch を通してすべてを駆動します。これで確認できないのは、GraphがそれらのURLを受け入れるかどうかです。クエリ文字列は project-spec.md の付録Aにある検証済みの recon スクリプトから来ており、実際のテナントに対して実行することでのみ確認されます。

インク

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

src/ink.ts

parseInkStrokes(text) → ストローク; strokesToSvg; rasterizeSvg; renderInk(text, width?) → PNGまたは null

src/page-content.ts

GraphPageContent.fetchRaw(pageId) → 分割されたレスポンス; .fetchInk(pageId) → PNGまたは null

これが機能するかどうかを決定する4つの詳細があり、そのすべてが project-spec.md の付録Aにある検証済みの recon スクリプトから来ています。

  • 名前空間は削除されます。 Graphは inkml:ink、inkml:trace、inkml:traceFormat を出力します。 fast-xml-parser は removeNSPrefix: true で構成され、すべてのルックアップはベア名を使用します。

  • チャネル順序は <traceFormat> から来ます。 このアカウントのポイントは X、Y、F で、F はペンの筆圧です。各ポイントの最初の2つの数値を読み取ると、筆圧が座標として描画されます。

  • 座標は himetric です。 px = himetric * 96 / 2540。これはページHTMLが入力されたコンテンツを配置するのと同じ座標空間であるため、インクと入力されたコンテンツは後で算術によって互いに位置合わせできる可能性があります。

  • トレースはツリー内のどこにでもあります。 ページには複数の <ink> ルートを含めることができ、<traceGroup> 要素はネストされます。それらはすべて収集されます。

インクのないページは null にレンダリングされます。これは入力されたページの通常の応答であり、エラーではありません。実際に発生する失敗は、50レベルを超えてネストされたトレースグループの InkParseError と、resvgが拒否するドキュメントの InkRenderError です。どちらのメッセージもドキュメントの内容を再現しません。ストローク座標はユーザーの手書きだからです。

test/fixtures/*.inkml は手書きで作成されています。数ストローク、X/Y/Fチャネル順序、himetric単位、2つの <ink> ルートとネストされた <traceGroup> 要素を持つ1つのファイルです。キャプチャされたページダンプをコミットしてはなりません。レンダリングされたインクは完全に読み取れる個人メモだからです。

MCPエンドポイント

サーバーは、ステートレスなStreamable HTTP で POST /mcp においてMCPを話します。すべてのリクエストは独自のMCPサーバーを構築し、応答し、破棄します。次のリクエストまで何も残りません。セッションIDはなく、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}

Streamable HTTP仕様では、このサーバーがストリーミングしない場合でも、両方の Accept タイプが必要です。src/tools.ts の createTools がレジストリです。6つのブラウジングツール(list_notebooks、list_sections、list_pages、search_pages、find_page_by_name、list_pages_by_name)、1つの読み取りツール(get_page_content)、5つの書き込みツール(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 と読み取り可能なメッセージを持つツール結果として返されます。期限切れのリフレッシュトークン、消えたページ、resvgが拒否するドキュメントはすべて通常の結果であり、プロトコル障害ではありません。登録されていないツールへの呼び出しだけがJSON-RPCエラーです。

すべてのリクエストは1つのJSONログ行を書き込みます。HTTP動詞、パス、ステータス、期間、JSON-RPCメソッド、および tools/call のツール名です。クエリ文字列、ヘッダー、引数、結果は決して含まれません。src/logging.ts を参照してください。

/mcp はベアラートークンの背後に閉じられています。MCPエンドポイントのベアラートークン を参照してください。ヘルスエンドポイントは開いたままです。

OAuthディスカバリ

Claudeはフローを開始する前に認可サーバーを見つける必要があります。src/oauth-router.ts はSDKの mcpAuthRouter をアプリケーションルートにマウントします。これはマウントポイントではなく発行者URLからパスを構築するため、プレフィックスの背後に置くことはできません。そして5つのルートを提供します。これらはすべて必然的に認証なしです。

パス

内容

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

RFC 8414 認可サーバーメタデータ

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

RFC 9728 保護リソースメタデータ

GET,POST /authorize

認可エンドポイント

POST /consent

同意フォームが投稿される場所。SDKの /authorize ルーターは再開パスを所有しません

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正規化された形式を報告します。これはオリジンのみの値の場合、末尾にスラッシュが追加された同じ文字列です。保護リソースドキュメントは、パスが接尾辞されたURLでのみ提供されます。ベアの /.well-known/oauth-protected-resource は404であり、/.well-known/openid-configuration も同様です。Claudeは最初に接尾辞付きパスをプローブします。

scopes_supported は offline_access をリストします。これにより、Claudeはアクセストークンが期限切れになるたびに再同意するのではなく、リフレッシュトークンを要求します。registration_endpoint はありません。クライアントIDとシークレットが構成されているため、動的クライアント登録は何もすることがありません。1つのクライアントが登録されており、3つのリダイレクトURIがあります。ホスト型Claudeサーフェス用の https://claude.ai/api/mcp/auth_callback と、Claude Code用の http://localhost/callback と http://127.0.0.1/callback です。ポートはRFC 8252に従って無視されます。

GET /authorize はリダイレクトではなく同意ページをレンダリングします。付与される内容と認可コードが送信されるホストを指定する1つの承認ボタンがあります。承認すると POST /consent に投稿され、60秒の単回使用コードが作成され、クライアントのコールバックにリダイレクトされます。認可リクエスト全体は、MCP_TOKEN_SIGNING_KEY で署名された1つの隠しフィールドでそのページを通過するため、同意中のインスタンス置換がフローを壊さず、フォームを編集できません。検証に失敗するフィールドは、リダイレクトもコード発行もない400です。

両方の同意レスポンスは Cache-Control: no-store、Referrer-Policy: no-referrer を運びます。フォームは /authorize URLから投稿され、そのクエリ文字列には 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 には独自のレート制限があります。15分間に200回です。これは、SDKの /authorize リミッターの前に意図的にマウントされており、レンダリングされたフォームは10分間投稿可能なままなので、/authorize を1回通過すると、リプレイ可能なフィールドが生成されるためです。制限は100エントリの保留コードキャップの上にあり、ストア自身の退避(その動作は指定されています)がバーストが最初に遭遇するものになるようにします。

POST /token は、1時間有効なアクセストークンと30日有効なリフレッシュトークンを発行します。どちらも MCP_TOKEN_SIGNING_KEY の下でのコンパクトなペイロードに対するHMAC-SHA256であり、他には何もありません。検証のためにストアは参照されません。これにより、Cloud Runリビジョン置換が再接続を強制しないようになります。ペイロードはオーディエンスを運びます。これは MCP_PUBLIC_URL に /mcp を加えたものであり、トークンはこのMCPエンドポイントにのみ有効で、他には有効ではありません。これらのトークンがどれだけ長く生きるか、そして人間にもっと頻繁に承認させる方法は、2つのセクション下にあります。

MCPエンドポイントのベアラートークン

/mcp へのすべてのリクエストには Authorization: Bearer <アクセストークン> が必要です。SDKの requireBearerAuth は createApp のMCPルーターの前にあり、src/oauth-provider.ts の verifyAccessToken がそれが呼び出すものです。MCP_TOKEN_SIGNING_KEY の下でのHMAC署名、トークンの種類、有効期限、オーディエンスです。正しく署名され、期限切れでないが、別のサーバーのリソース識別子を運ぶトークンは拒否されます。SDKは独自のオーディエンスをチェックしないため、そのチェックがなければ、この署名キーを共有するサーバーによって別のMCPサーバー用に発行されたトークンが受け入れられるでしょう。

トークンがない、期限切れ、またはこれらのチェックのいずれかに失敗するリクエストは、チャレンジヘッダー付きの 401 です。

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で応答することをアサートします。そのため、後で追加されたルートは、誰かが意図的に開かない限り閉じられます。

スコープは必要ありません。このサーバーが発行する唯一のスコープである offline_access は、呼び出し元が何をしてもよいかではなく、リフレッシュトークンが付与されるかどうかに関するものであり、それを要求すると、それ以外は有効なトークンに対して403で応答することになります。スコープチェックが追加された場合、403は WWW-Authenticate: Bearer error="insufficient_scope" を運ぶ必要があります。このミドルウェアはそれを実行します。Claudeは他の403を終端として扱い、何もプロンプトしないためです。

トークンの有効期間と再検証の強制

このサーバーは無人で動作するように作られています。デフォルト設定はその方針を反映しており、漏洩した認証情報を遮断する能力の一部を犠牲にしています。重要な環境にデプロイする前にこれを読み、そのトレードオフが適切でない場合は数値を変更してください。

デフォルト設定の内容

トークン

有効期間

更新するもの

アクセストークン

1時間

リフレッシュトークン(自動)

リフレッシュトークン

30日

更新のたびに新しい30日間のトークンが発行される

同意フォーム

10分

なし。期限切れのフォームは拒否され、フローが再開する

Claudeは自動的に更新します。時間切れの前に積極的に、また401を受信した際に反応的に更新します。つまり、コネクタが最初に追加されたときに人間が「承認」をクリックし、その後はコネクタが30日間使用されない場合にのみクリックが必要です。これがスライディングウィンドウです。30日という期間は接続がアイドル状態でいられる時間を制限するものであり、接続の存続期間を制限するものではありません。

スライディング方式の理由とそのコスト

このサーバーが発行するすべてのトークンはステートレスです。署名されたペイロードに過ぎず、データベースの行もセッションレコードも、戻ってきたときに参照するものは何もありません。これこそが、Cloud Runのリビジョン置き換えを透過的にする理由です。新しいインスタンスは、古いインスタンスが発行したトークンを、両者の間で共有状態なしに検証します。トークンストアがあると、デプロイのたびに再接続が必要になります。

そのコストは、個別に失効させることができないことです。失効エンドポイントはありません。削除するものがないからです。具体的には:

  • 漏洩したリフレッシュトークンは最大30日間アクセスを許可し、使用のたびに保持者のアクセスをさらに30日延長します。無効化するためのサーバー側の記録はなく、盗まれたリフレッシュトークンと正規のものを区別する方法もありません。両者は同じ鍵で署名された同じバイト列だからです。

  • ウィンドウをスライドさせることはローテーションではありません。リフレッシュ時に新しいリフレッシュトークンが発行されても、置き換えられたトークンは内部に刻印された有効期限まで動作し続けます。真のローテーションとは古いトークンを使用済みとマークすることであり、この設計にはないストアが必要です。

  • アクセストークンも、同じ理由で1時間の途中で遮断することはできません。

残されているのは1つの単純な手段で、即座に効果があります。MCP_TOKEN_SIGNING_KEYを変更して再デプロイすることです。すべてのアクセストークン、すべてのリフレッシュトークン、すべての未完了の同意ページが同時に無効化されます。なぜなら、それらはすべてその鍵に対して検証されるからです。次のClaudeのリクエストは401を受け取り、オペレーターは一度「承認」をクリックします。鍵を定期的にローテーションすることは、それ自体が合理的なポリシーです。

同意画面は、言ってしまえば誰も認証しません。ボタンが1つあるだけで、パスワードはありません。部外者とあなたのノートブックの間に立ちはだかるのは、POST /tokenが要求するMCP_OAUTH_CLIENT_SECRET、すべての認可コードをclaude.aiまたはループバックに送るリダイレクトURI許可リスト、そしてコードをフローを開始したクライアントに結び付ける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日ごとに承認します。src/oauth-provider.tsのexchangeRefreshToken内の1行:

// 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);

リフレッシュトークンの発行を完全に拒否する。 最も厳格な設定です。アクセストークンの有効期限が切れても更新するものがないため、人間は毎時間承認します。2つの編集が必要で、両方とも必須です。メタデータの切り替えだけではトークンの発行は止まりません。

  1. src/oauth-router.tsでSCOPES_SUPPORTEDを空にします。Claudeは、メタデータがoffline_accessを宣伝している場合にのみ、認可リクエストにそれを追加します。これがリフレッシュトークンを要求するかどうかを決定するスイッチです。

  2. src/oauth-provider.tsで、issueTokensが返すものからrefresh_tokenフィールドを削除します。現在は要求されたスコープに関係なく発行されています。

これは使用中に目に見える形で現れると予想されます。時間が切れると、Claudeはセッションの途中でブラウザを同意画面に戻します。

アクセストークンを短縮する。 src/oauth-provider.tsのACCESS_TOKEN_TTL_Sは、漏洩したアクセストークンが機能するウィンドウを狭めます。有効期限ごとに1回のトークンリクエストがかかり、人間の関与はまったく不要なので安価です。しかし、漏洩したリフレッシュトークンには何の効果もありません。リフレッシュトークンこそが心配すべき認証情報です。

キープアライブ

Microsoftの委任されたリフレッシュトークンは、約90日間使用されないと失効します。トークンは実際に交換されたときだけスライドし、交換されるのは保持しているアクセストークンの有効期限が切れた後にツール呼び出しが到着したときだけです。つまり、3ヶ月間誰も使用しないコネクタは、ブラウザでnpm run bootstrapを実行する人間が必要なコネクタです。サーバー内の何もそれを単独で防ぐことはできません。誰も呼び出していないときは、サーバー内の何も実行されないからです。

POST /keepaliveがその修正策です。これはforceRefresh: trueを指定してacquireTokenSilentを呼び出します。これにより保持されているアクセストークンをスキップしてリフレッシュトークンを交換するため、Entraは新しいウィンドウを持つ代替トークンを発行し、src/token-cache.tsがそれをFirestoreに書き込みます。forceRefreshが重要な部分です。これがないとMSALは自身のキャッシュから応答し、Entraにリクエストが届かず、ウィンドウは動きません。

MCP_KEEPALIVE_SECRETを少なくとも32文字のランダムな文字列に設定するとルートがマウントされ、未設定のままにするとパスは404を返します。スケジューラーはX-Keepalive-Secretヘッダーでシークレットを提示し、これは何らかの作業が行われる前に定数時間で比較されます。ベアラートークンではなく共有シークレットである理由は、スケジューラーはOAuthフローを実行できないからです。ブラウザがなく、リフレッシュトークンを保持する場所もありません。また、レイヤー1のクライアントシークレットとは別の変数である理由は、MCPサーフェス全体に到達できる認証情報がスケジューラージョブにも置かれないようにするためです。

自分のアカウントでの設定

4つのステップがあります。シークレットを生成し、サービスに設定し、ジョブを作成し、ジョブが実際にサービスに到達することを確認します。これはすべて任意です。スキップしてもサーバーは動作します。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のリージョンを使用すれば、覚えておく値が1つ減ります。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はトークンが交換されたことを意味します。保存されたことを証明するのは、FirestoreドキュメントのupdatedAtフィールドが実行時刻に移動することです。これが、置き換えられたリフレッシュトークンが呼び出しを生き延びた唯一の証拠です。このデプロイメントで2026-08-19に測定した結果: 強制実行は200を返し、tokencache/msalは14:15:53Zから14:30:54Zに進みました。

90日のウィンドウに対して毎週で十分であり、数回の実行失敗の余地も残ります。ジョブのコストはトークンエンドポイントへの1往復とFirestoreへの1書き込みです。

シークレットをローテーションするには: 新しいGitHubシークレットを設定し、デプロイし、その後gcloud scheduler jobs update http onenote-mcp-keepalive --update-headers=…を実行します。デプロイとジョブ更新の間の実行は401を返し、何の作業も行いません。毎週のスケジュールでは、その間に何も入り込まないウィンドウです。

ヘッダーはジョブ定義内にあり、プロジェクトに対するroles/cloudscheduler.viewerを持つ誰でも読むことができます。これは、環境変数がCloud Runリビジョン仕様で読めるのとまったく同じです。これはデプロイでSecret Managerを使用しないことと同じトレードオフです。

ステータス

意味

スケジューラーがすべきこと

200

リフレッシュトークンが交換され、新しいものが保存された

何もしない

401

シークレットがないか間違っている

ジョブを修正する。ルートは何の作業も行わなかった

404

MCP_KEEPALIVE_SECRETがサービスに設定されていない

設定して再デプロイする

503("retryable": true付き)

Firestoreに到達できなかった

再試行する

503("retryable": false付き)

許可が失効している

npm run bootstrap

これが保護しないもの: 条件付きアクセスのサインイン頻度ポリシー、パスワード変更、MFAリセット、管理者による許可の取り消し。これらのいずれも、スケジュールがどうであれリフレッシュトークンを無効化し、コード変更では回避できません。Entraテナントが自分のものであれば、このアプリ登録をサインイン頻度ポリシーから除外してください。そうでなければ、90日を誰かがあなたに知らせずに短縮できる上限として扱ってください。

キープアライブルートは、以下のトークンの有効期間にあるレイヤー1の30日ウィンドウとも無関係です。そのリフレッシュトークンはClaudeのコネクタストアに存在し、Claudeだけがそれを提示したり、その代替を受け取ったりできます。したがって、ここで実行されているものはそれを生かし続けることはできません。それを失うと、承認ボタンを1回クリックするだけです。Microsoftのものを失うと、デバイスコードによるサインインが必要になります。

ページミラー同期

ミラーは、手動で選んだノートブックセットのページのFirestoreコピーを保持し、読み取りツールがOneNoteのリクエスト予算を消費せずに応答できるようにします。POST /syncがそれを満たす方法です。自動的なものは何もありません。シークレットなしではルートはマウントされず、MIRROR_READ_ENABLEDなしでは読み取りツールはミラーを参照しません。

最新状態を維持するもの

3つのメカニズムがあります。スケジュールで実行されるのは2つだけで、そのうち1つは一時的です。

実行するもの

カバーする範囲

初期バックフィル

POST /syncスケジューラージョブ(完了時に削除)

すべてのミラー対象ノートブック(アクティブかどうかに関わらず)を一度だけ埋める

継続的キャッチアップ

カバーされる各読み取り(インライン、応答前)

アクティブなノートブックで作成・編集されたページ

削除スイープ

毎晩1回のスケジューラージョブ

アクティブなノートブックから削除されたページ

定常状態ではスケジュールされた増分同期はありません。読み取りがそれを行うからです。 カバーされる各読み取りは、応答する前にrunIncrementalを自身で実行します。最大12リクエスト、最大15秒、30秒に1回までです。以下の読み取り元を参照してください。したがって、ミラーは誰かがそれを見ようとしているときに更新され、それ以外のときは更新されません。その横でスケジュールされた増分実行を行うと、誰も要求していないデータを更新するために同じ1時間あたり400リクエストを消費し、次の読み取りはそれに関係なく再度更新することになります。

読み取りでは削除を検知できない。それこそが夜間スイープが存在する理由そのものである。 増分パスは親セクションの lastModifiedDateTime を鍵としており、作成・編集・削除のいずれもこの値を動かす。そしてそのセクションに対して変更されたページを問い合わせる。削除されたページはどの一覧にも現れないため、その応答には削除されたことを示す情報は一切ない。それを知るには、セクションのページIDを列挙してミラーと突き合わせる必要がある。Graph には OneNote リソースに対する /delta がなく、削除されたページの墓石も存在しないからだ。それはスイープであり、読み取りの前に置くにはあまりに高コストで、スイープが実行されるまでミラーは存在しないページを配信し続ける。一晩の陳腐化をこの設計は許容する。

バックフィルはどちらにも該当しないため、完了するまで独自のスケジュールを持つ。 エポック以降のセクションごとの一覧取得が1回と、ページごとのコンテンツ取得が1回——このアカウントでは数千リクエストになる——であり、インライン更新の12リクエスト予算では数週間かかってしまう。sync/state.backfillComplete が true になった時点でジョブを削除する。これはすべてのミラー対象セクションが非 null の pagesSyncedThrough を持つ時点である。POST /sync を呼び出すものは他にない。

アップグレード後の最初の構造書き込みは、すべてのドキュメントを一度だけ書き直す。 構造ドキュメントはツリーがそのドキュメントについて述べた内容を示す identity 文字列を保持しており、そのフィールドが存在する前に書かれたドキュメントにはそれがない——したがって一致せず、書き込まれる。これは Firestore 書き込みの1パスであり、Graph リクエストはなく、一度だけ発生する。

非アクティブなノートブックは一度バックフィルされ、その後は凍結される。 選択ドキュメント内の activeNotebookIds がその線引きを行う——ミラー対象の選択を参照。アクティブでない選択済みノートブックはバックフィルで埋められ、以後ずっとミラーから配信される。インライン更新もスイープも再び訪れることはない。それこそが55ノートブックのアカウントをミラー可能にする理由である。アーカイブは一度だけ一覧取得・取得され、二度と触れられない。

4つのルート

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

このうち2つはスケジュールに載っている。バックフィル実行中の /sync と、夜間の /sync/sweep/full である。/sync/sweep はより安価なスコープ限定の変種で、手動で使うのに適している。/sync/sweep/all はアクティビティフィルタを持たない唯一のモードであり、非アクティブなノートブック内の削除を検知する唯一の方法である。ミラー対象セクションごとに1リクエストかかるため、タイマーではなく意図的に実行すること。

コスト

OneNote は委任アプリに1時間あたり400リクエストを許可しており、すべての対話型ツール呼び出しも同じ400から消費される。このサービスが対象とするアカウントは55ノートブックと568セクションを保持し、4ノートブックと202セクションがミラー対象、2ノートブックと70セクションがアクティブである。

実行

リクエスト数

インライン更新、変更なし

1 — 展開されたツリーのみ、それ以外はなし

インライン更新、編集されたページが数件

2〜12。実際に変更されたページのみ取得し、12はハードな予算

アクティブなノートブックに対する夜間の /sync/sweep/full

ここでは約70、変更されたページごとに1追加

タイムスタンプが動いたセクションに対する /sync/sweep

訪問したセクションごとに1

すべてのミラー対象ノートブックに対する /sync/sweep/all

ここでは約202 — 時間予算の半分

バックフィルの1回の実行

49〜120を測定。先に達した予算で停止する

変更されていないページはリクエストを消費しない。 変更ページ一覧はすべてのページのスタンプとタイトルを保持しており、両方が一致する保存済みコピーは再取得されない。したがって、実行のコストは直近1時間に編集されたページではなく、実際に変更されたページに追従し、実行レポートの pagesSkipped がそのようにスキップした数を示す。

2つの重複ウィンドウは幅が異なる。これは意図的である。 WATERMARK_OVERLAP_MS は1時間で、セクションの一覧が返すページを決定する。それが表面化させるものはリクエストを消費しないため、余裕を維持する。SECTION_SCAN_OVERLAP_MS は15分で、一度編集されたセクションが訪問され続ける期間を決定し、実行ごとに1回の一覧リクエストがかかる。1時間のそれは削減する価値のある最後の項だった。両方とも Graph の伝播遅延と、Graph の時計とこのサービスの時計の間のギャップに対する余裕である。動作確認の下にある2つのログクエリが、15分が正しいかどうかを示す。

MIRROR_SYNC_REQUEST_BUDGET(デフォルト120)はハードストップである。これに達した実行は、行ったことをコミットし、"done": false で200を返し、次のスケジュールで再開する。それこそが最初のバックフィルを可能にしているものである。

2つの停止条件のどちらが先に効くかは変動するため、観測された平均ではなくリクエスト予算に対して周期を設計すること。 2026-08-20 の6回の実行で測定: 49、57、60、63、120、120リクエスト。最初の4回は240秒の時計で予算に余裕を持って終了した——ページは Graph フェッチ、resvg インクレンダリング、2回の blob 書き込みを要し、レンダリングが1-CPUインスタンスで時計を満たす。最後の2回は、ページのレンダリングがより安価なセクションで、時計内で全予算を使い切った。

周期

予算120でのリクエスト/時

*/20

最大360

制限を超えられない最速の周期

*/15

最大480

実行が時計制約の間は制限内、そうでないときは超過

*/10

最大720

実行がリクエスト制約のときは常に制限超過

*/5

最大1440

2026-08-20 にこのアカウントで測定: 45分以内に429秒

1時間あたり400を超えると、Graph は OData コード 10007 で429を返す。拒否されたリクエストは作業をせず、それでも試行を消費し、セクションのウォーターマークは進まず、ペナルティは短いバックオフよりも長く続く——3分間にわたる5回の再試行は、1回のバースト後にすべて拒否され、docs/graph.md に記録されている。したがって、制限を超えてもバックフィルは早く完了しない。MIRROR_SYNC_REQUEST_BUDGET を半分にすると、同じ時間あたりの消費で間隔が半分になり、実行ごとにツリー読み取りが1回余分にかかる。

このアカウントでのバックフィルサイズは、おおよそセクションごとに1リクエスト+ページごとに1リクエストである。202セクション×各約24ページ——一覧が完了した最初の4セクションから推定したもので、測定ではない——は約4,800ページと5,000リクエストになり、どの周期でも下限は約12.5時間のスライスとなる。

MIRROR_READ_ENABLED=false の場合、継続的なキャッチアップはまったくない。 これが現在のデプロイ設定であり、バックフィルジョブと夜間スイープだけがミラーに触れる。読み取りを有効にすることが、上記の読み取り駆動設計を現実のものにし、更新ごとに最大12リクエストを30秒に1回以下の頻度で追加する——継続的に忙しい会話から1時間あたり約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 である。実行が時計制約の間は制限内——2026-08-20 に実行ごと61〜81リクエストと測定——であり、再び120リクエスト予算を使い切るようになると超過する。*/20 はどちらの場合でも超えられない周期である。MIRROR_SYNC_REQUEST_BUDGET を100に下げれば */15 も安全になる。

再試行は両方で安全である。すべてのモードは冪等であり、予算を使い切った実行後の再試行はバックフィル中に積極的に有用である。両方のジョブが存在する間、実行リースにより重複は Graph 予算の二重消費ではなく409になる。

/sync/sweep/all にはジョブがない。activeNotebookIds を編集した後、またはノートブックがこのサーバーの外部で再編成され、ミラーを OneNote が実際に保持しているものと照合する必要がある場合に、手動で実行する。

ミラー対象の選択

選択は手編集される1つの Firestore ドキュメント、MIRROR_ROOT_DOC(デフォルト onenoteMirror/default)である。これはサービスが決して書き込まない唯一のドキュメントである。

フィールド

型

意味

notebookIds

文字列の配列

ページがミラーされるノートブックのID

activeNotebookIds

文字列の配列、任意

そのうちどれがまだ編集されているか。欠落または null はすべてを意味する。

note

文字列

あなたのための自由文。読み取られることはない。

IDは list_notebooks から取得する。構造——アカウント内のすべてのノートブック名とセクション名——は、ツリー読み取りが同じ1リクエストで全部を返し、部分的な構造では list_notebooks とスコープなしの search_pages が自信を持って部分的に答えてしまうため、関係なくミラーされる。ページのコンテンツのみが選択に従う。

activeNotebookIds は継続的な作業を絞り込むだけで、それ以外には影響しない。非アクティブなノートブックのページ、コンテンツ、インクはアクティブなものとまったく同じように保存され、このサーバーを通じた書き込みは依然として到達し、ミラーも更新し、読み取りも依然としてそこから応答する。変わるのは、バックフィルが完了すると増分パスもスイープも再チェックしないことだけであり、OneNote クライアントで非アクティブなノートブックに行われた編集は、ノートブックが再びアクティブになるか /sync/sweep/all が手動で実行されるまでミラーに届かない。

ノートブックを activeNotebookIds に戻すことは安全で、そのノートブックだけのコストで済む。次の実行で保存済みリストを差分し、アクティブになった1つのIDを見つけ、そのノートブックのセクションをそれぞれのセクション別ウォーターマークに対して再一覧する。他のすべてのノートブックはウォーターマークを維持し、一覧されない。実際に変更されたページだけが取得される。notebookIds にノートブックを追加する場合も同じように動作する。

notebookIds 内のどのノートブックにも一致しないIDは、すべての実行レポートで unknownNotebookIds として報告される。activeNotebookIds 内のミラー対象外ノートブックを指すID——タイプミス、または notebookIds に追加されたことのない実在のノートブック——は unknownActiveNotebookIds として報告され、mirror-selection-unknown-active としてログに記録される。編集後は両方を確認すること。IDは不透明な文字列であり、タイプミスはそうでなければ黙って同期されないノートブックになる。

動作確認

# 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だけでは証明にならない。証明になる3つのこと:

  • Firestore で sections/{id}.pagesSyncedThrough が動いたこと。200を返してウォーターマークを進めなかった実行は何もしていない。

  • バケットがインク付きページごとにオブジェクトを保持していること、そしてそのバイト数がページドキュメントの ink.bytes と一致すること。

  • sectionsSkippedInactive が期待する数であること。すべてのミラー対象セクションをカバーする数は、アクティブセットが空か誤入力されていることを意味する。同じレポート内の unknownActiveNotebookIds がどちらかを示す。

2つのログベースの数値が、重複ウィンドウの幅が正しいかどうかを示す。どちらも緊急ではない。サービスが1週間稼働した後に読むこと。

# 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 を読むことでバックフィルを監視する: pagesUpdated が上昇する false、次に true。sync/state.backfillComplete も同じことを示し、onenote-mcp-sync ジョブを削除する合図である。

リクエストログで実行中に429を監視してください。バックフィルジョブが1時間あたり400リクエストを超えて実行されている場合、429はその選択の当然の結果であり、対処法は間隔を長くすることです。それ以外の時間に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の場合、7つのツールがローカルコピーを経由します: list_notebooks、list_sections、list_pages、list_pages_by_name、find_page_by_name、get_page_content、search_pages。

これらの読み取りはそれぞれ3つのステップで構成され、この順序で行われます。

  1. 更新。 ツールはそれ自体で増分同期を実行します — POST /syncが実行するのと同じrunIncrementalで、より小さい予算: OneNoteリクエスト12件と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は最初の3行の例外です: ページドキュメントは独自の古さフラグを持ち、すべての書き込みはOneNoteに触れる前にそのページを古いとマークするため、そこでのヒットは何も置き換えていないページです。

best-availableは劣化した回答ではなく、更新が失敗してもmirrorにはなりません: その更新は非アクティブなノートブックをチェックする予定はなかったため、それを報告することは回答を変更できなかった失敗を説明することになります。それが言うのは、コピーがこのサーバーを通じた書き込みまたは/sync/sweep/allによってのみ再チェックされるということです。list_notebooksはノートブックごとにpagesActiveを報告し、スコープなしのsearch_pagesはnotebooksSearchedの横にinactiveNotebooksを報告するため、モデルはスキップの大きさを確認できます。list_notebooksとlist_sectionsはbest-availableを報告しません — 構造はアクティブなセットが何であれ、すべての実行でアカウント全体に対して保存されます。

更新はすべての呼び出しで実行されるわけではありません。 完了した更新は30秒間保持され、未完了の更新は5分間保持されるため、ツール呼び出しのバーストは30回ではなく1回の更新で済みます。最悪の場合、静かなアカウントで継続的にビジーな会話から1時間あたり約120件の追加のOneNoteリクエストが発生し、制限の400に対して — そして静かなアカウントでの更新は単一のリクエスト、つまりノートブックツリーです。

ライブ読み取りを強制する呼び出しごとの引数はありません。以前はありました(useLiveData); それは廃止されました。なぜなら、最初に更新してから更新が完了したかどうかを示す読み取りは、呼び出し側モデルが推論する必要があるスキーマフィールドなしで同じ質問に答えるからです。

オフにすることは完全なロールバックです: 変数をfalseに設定し、再デプロイすると、ツールはミラー以前の動作とバイト単位で同一になります。更新は実行されず、ツール呼び出しが同期にリクエストを費やすことはなく、すべての回答はsource: "onenote"と言います。データ移行はなく、ミラーはバックグラウンドで埋め続けます。

書き込みはそのページを即座に再同期します。 5つの書き込みツールはすべて、成功した書き込み後にOneNoteからページを再読み取りして保存するため、append_to_pageの直後のget_page_contentは、追加されたばかりのテキストでローカルコピーから回答します。これには書き込みごとにOneNoteリクエストが1件余分にかかります。再同期が失敗した場合、代わりにページが古いとマークされ、次の読み取りがOneNoteに送られます — 遅いですが、決して間違っていません — そして後の読み取りの前の更新がどちらの場合でも修復します。再同期の失敗が書き込みを失敗させることはありません。

オンにする前に確認すべき2つのこと:

  • 4つの複合インデックスすべてがREADYを読み取る。 まだCREATINGのインデックスに対するクエリはFAILED_PRECONDITIONで失敗します。

  • sync/state.backfillCompleteがtrueである。 それより早くオンにすることは安全です — ミラーされていないページはミスとなりGraphに送られます — しかし、ほとんどの読み取りはフォールスルーし、ミラーがない場合よりも遅く、バックフィルが必要とする予算を消費します。

知っておく価値のある1つの動作変更はsearch_pagesです。Graphから回答する場合、セクションを歩き、60セクションまたは25秒で停止するため、sectionsSearched、sectionsFound、stoppedEarlyを報告します。ミラーから回答する場合、歩行も境界もありません — ただし、ページコンテンツは選択したノートブックに対してのみ保持されるため、代わりにnotebooksInAccountに対するnotebooksSearchedを報告し、それらが異なる場合はそのメモでそれを示します。「一致なし」を読むモデルは、どちらを取得したかを知る必要があります。選択範囲外のノートブックを検索するには、sectionIdを渡します: それはローカルコピーでミスし、OneNoteの1つのセクションを歩きます。

実行がオーバーランする場合

MIRROR_SYNC_REQUEST_BUDGETと240秒の壁時計予算はどちらも、Cloud Runの300秒リクエストタイムアウトよりはるかに内側で実行を停止するため、通常の回答は"done": falseの200です。504は、何かが予想より長くかかったことを意味します — ほぼ常に長いRetry-Afterを持つOneNoteからの429です。

そのときに何が起こるかを知っておく価値があります。サービスはCPUスロットリングと最小インスタンス数なしでデプロイされているため、切断されたリクエストは2つの運命のいずれかに直面し、Cloud Runはどちらも保証しません: インスタンスがまだウォームであれば、プロセスは一時停止され、次のリクエストが到着したときに再開されます。ゼロにスケールダウンされた場合、作業は単に失われます。どちらの場合も実行は完了せず、リースは解放されず、再試行はリースが15分後に期限切れになるまで409で応答します。次の実行 — バックフィルジョブ、夜間スイープ、または次の対象読み取りの前の更新 — はその後正常に進行します。

どちらの場合も何も失われません。ウォーターマークはセクションごとに進み、そのセクションのページが保存された後にのみ進むため、切断された実行はコミットしたすべてを保持し、次の実行はそこから再開します。同じことは、実行間で単に消えるインスタンスにも当てはまります: ミラー、Microsoft更新トークン、およびこのサーバーが発行するすべてのトークンは、メモリに保持されるのではなく保存または署名されるため、インスタンスはいつでも消えることができ、トークンの往復1回以上のコストはかかりません。

繰り返し504が発生した場合に確認すべき唯一のことは、リクエストログの429です。持続的なスロットルがタイムアウトに達する唯一の現実的な方法であり、対処法はMIRROR_SYNC_REQUEST_BUDGETを小さくするか、間隔を長くすることであり、Cloud Runのタイムアウトを長くすることではありません。

不良ミラーからの回復

MIRROR_ROOT_DOCのnotebooks、sectionGroups、sections、pages、pageContent、tombstonesサブコレクションを削除し、sync/stateのstructureHashとsectionsScannedThroughをクリアします。次に、セットアップからonenote-mcp-syncジョブを再作成し、backfillCompleteが再びtrueになるまで放置します: 読み取りの前のインライン更新は12リクエストの予算を持ち、ゼロからミラーを再構築しません。選択ドキュメントとトークンキャッシュはこれによって影響を受けず、Graphへの書き込みは関与しません — ミラーはコピーであり、Microsoft Graphが真実のソースのままです。

アラート

2つの障害はログベースのメトリックなしでは見えません。どちらもClaude会話内のメッセージまたは誰も読んでいない行としてのみ表示されるためです:

イベント

意味

graph-auth-failure with 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プロジェクトとドキュメント、およびアカウントのホームテナントを指定するため、正しいディレクトリにサインインしたことを確認できます。その出力にはテナントIDが含まれます; それをissue、プルリクエスト、ワークフローログから遠ざけてください。

GOOGLE_CLOUD_PROJECTとFIRESTORE_CACHE_DOCはここでは必須です。サーバーでは前者が推測され、後者がデフォルト値を持つのとは異なります。CLIは独自のアプリケーションデフォルト資格情報で書き込むため、未設定の値はgcloudログインが指すプロジェクトに実際のドキュメントをシードし、それでも成功行を出力します。MCP_OAUTH_*値は読み取られないため、これを実行してもレイヤー1クライアントシークレットがマシンに置かれることはありません。

サーバーのログにretryable: "false"のgraph-auth-failureイベントが表示されるたびに、再度実行してください。更新トークンは使用のたびにローテーションされ、サービスが約90日間アイドル状態になると失効します; 自動回復はありません。上記のキープアライブジョブを設定することが、アイドル状態がそこに到達する方法の1つになるのを防ぐものです。

コンテナ

このサービスは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はプラットフォームによって提供され、残りはデプロイワークフローから供給されます。ホストのポート8080がすでに使用されている場合は、別のポートにマップしてください: -p 8081:8080と-e PORT=8080を使用します。

イメージをテストするには:

RUN_DOCKER_TESTS=1 bash scripts/test/run.sh

これにより、イメージがビルドされ、resvgのglibcバイナリが本番専用インストールで生き残ったこと、開発依存関係が同梱されていないことが確認され、コンテナ内でSVGがPNGにレンダリングされ、PORTで指定されたポートで/healthzが200を返すことが検証されます。RUN_DOCKER_TESTS=1がない場合、dockerスイートはスキップされ、残りは引き続き実行されます。

デプロイ

.github/workflows/deploy.ymlはmainへのプッシュのたびに、またworkflow_dispatchで実行されます。型チェックを行い、npm testを実行し、ビルドし、コンテナイメージをコミットSHAでタグ付けしてArtifact Registryにビルドしてプッシュし、そのイメージを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のプリビルドがコンパイルされているプラットフォームでもあります。そのため、ワークフローはgcloud run deploy --sourceを使用するのではなく、イメージ自体をビルドします。gcloud run deploy --sourceを使用すると、Cloud Buildを有効にして、それに伴うロールを付与することにもなります。

デプロイは--max-instances=1と--allow-unauthenticatedで実行され、ランタイムサービスアカウントとして動作します。このアカウントはFirestoreトークンキャッシュ用のroles/datastore.userを保持しています。--allow-unauthenticatedにより、Claudeがサービスに到達できるようになります。MCPエンドポイントは代わりにベアラートークンで閉じられています。MCPエンドポイントのベアラートークンを参照してください。

リポジトリが保持する必要があるもの

scripts/gcp-bootstrap.shはGCP側をプロビジョニングし、最初の6つに対するgh variable setコマンドを出力します。ワークフローは最初のステップで失敗し、何が欠けているかを名前で示すため、半分設定された状態でデプロイされることはありません。

名前

種類

値

GCP_PROJECT

変数

プロジェクトID

GCP_REGION

変数

Cloud Runリージョン

GAR_REGION

変数

Artifact Registryリージョン

WIF_PROVIDER

変数

完全なワークロードIDプロバイダーリソース名

DEPLOY_SA

変数

デプロイサービスアカウントのメールアドレス

RUNTIME_SA

変数

ランタイムサービスアカウントのメールアドレス

ONENOTE_CLIENT_ID

変数

Azureアプリ登録のクライアントID

ONENOTE_AUTHORITY

変数

EntraオーソリティURL

MCP_OAUTH_CLIENT_ID

変数

レイヤー1のOAuthクライアントID

MCP_PUBLIC_URL

変数

サービスの公開URL。以下を参照

FIRESTORE_CACHE_DOC

変数

任意。デフォルトはtokencache/msal

MCP_OAUTH_CLIENT_SECRET

シークレット

レイヤー1のOAuthクライアントシークレット

MCP_TOKEN_SIGNING_KEY

シークレット

アクセストークンの署名キー。少なくとも32文字

MCP_KEEPALIVE_SECRET

シークレット

任意。未設定の場合はPOST /keepaliveがマウントされない

MIRROR_BUCKET

変数

任意。ページミラーのCloud Storageバケット。scripts/gcp-bootstrap.shが作成した名前を出力する

MIRROR_READ_ENABLED

変数

任意。デフォルトはfalse。読み取りツールがミラーを更新して使用するようにするスイッチ

MIRROR_ROOT_DOC

変数

任意。デフォルトはonenoteMirror/default

MIRROR_SYNC_REQUEST_BUDGET

変数

任意。デフォルトは120

MIRROR_SYNC_SECRET

シークレット

任意。未設定の場合はPOST /syncがマウントされない

これらのうち、認証情報なのは4つだけです。WIFプロバイダー名とサービスアカウントのメールアドレスは識別子であり、このリポジトリのOIDC IDを提示できない者には役に立たないため、シークレットではなく変数になっています。

デプロイはenv_vars_update_strategy: overwriteを渡すため、ワークフロー内のリストが各リビジョンにおけるサービスの全環境になります。アクションのデフォルトはmergeであり、その場合、ワークフローから削除された変数が前のリビジョンから黙って残ってしまいます。PORTとGOOGLE_CLOUD_PROJECTは意図的にリストに含まれていません。Cloud Runが両方を提供し、PORTを入力として拒否するためです。

最初のデプロイとMCP_PUBLIC_URL

MCP_PUBLIC_URLはOAuth発行者であり、このサーバーが発行するすべてのアクセストークンのオーディエンスです。そして、最初のデプロイが行われるまで、URLを持つサービスは存在しません。ワークフローは3つのステップでこれを解決します: MCP_PUBLIC_URLリポジトリ変数、次にCloud Runがすでにサービスに割り当てたURL、そして—両方が存在しない場合にのみ—https://placeholder.invalidを使用し、デプロイ直後にそれを実際のURLに置き換えます。したがって、最初の実行は無人で機能し、正しい値が配置された状態で完了します。URLを名前で示す警告が残ります。リポジトリ変数にそれを設定してください。なぜなら、サービスの前にカスタムドメインを置いた場合に生き残るのは3つのソースのうちそれだけだからです。

MCP_PUBLIC_URLを変更しても、それ自体では何も無効になりませんが、すでに発行されたすべてのアクセストークンは古いオーディエンスにバインドされており、拒否されます。その場合、Claudeは認可フローを再実行します。

ロールバック

イメージタグはコミットSHAであるため、以前のイメージはまだArtifact Registryにあります:

gcloud run services update-traffic onenote-mcp --region "$GCP_REGION" --to-revisions <revision>=100

workflow_dispatchで以前のコミットからワークフローを再実行することも機能し、デプロイされた環境をそのコミットのワークフローファイルと同期させ続ける方法です。

設定

すべての値は環境変数から取得され、起動時に検証されます。欠落または不正な形式の変数は、一度にすべての問題を列挙するConfigErrorを生成し、プロセスはスタックトレースなしで終了コード1で終了します。

変数

必須

デフォルト

目的

ONENOTE_CLIENT_ID

はい

—

Azure アプリ登録のクライアント ID(パブリッククライアント)

ONENOTE_AUTHORITY

はい

—

テナントの Entra ID オーソリティ URL

MCP_OAUTH_CLIENT_ID

はい

—

Claude が提示するレイヤー 1 の OAuth クライアント ID

MCP_OAUTH_CLIENT_SECRET

はい

—

レイヤー 1 の OAuth クライアントシークレット

MCP_TOKEN_SIGNING_KEY

はい

—

発行されたアクセストークンの署名に使用するキー(最小 32 文字)

MCP_PUBLIC_URL

はい

—

サービス自身のパブリック URL: https、クエリなし、フラグメントなし、末尾スラッシュなし

FIRESTORE_CACHE_DOC

サーバー: いいえ・ブートストラップ: はい

tokencache/msal

MSAL トークンキャッシュを保持する Firestore ドキュメントパス

GOOGLE_CLOUD_PROJECT

サーバー: いいえ・ブートストラップ: はい

—

GCP プロジェクト。Cloud Run では自動的に推測される

PORT

いいえ

8080

バインドポート。Cloud Run が設定する。サーバーはポートをハードコードしない。

MCP_KEEPALIVE_SECRET

いいえ

—

最小 32 文字。設定すると POST /keepalive がマウントされ、未設定のままにするとそのパスは 404 を返す。Keepalive を参照。

MIRROR_ROOT_DOC

いいえ

onenoteMirror/default

手編集された notebookIds と activeNotebookIds のリストを保持する Firestore ドキュメント。そのサブコレクションがミラーとなる。ドキュメントパス、セグメント数が偶数であること、FIRESTORE_CACHE_DOC と同じ規則。

MIRROR_SYNC_SECRET

いいえ

—

最小 32 文字。設定すると POST /sync がマウントされ、未設定のままにするとそのパスは 404 を返す。

MIRROR_BUCKET

条件付き

—

レンダリングされたインクと特大ページ HTML 用の Cloud Storage バケット。MIRROR_SYNC_SECRET または MIRROR_READ_ENABLED が設定されると必須。以下を参照。

MIRROR_READ_ENABLED

いいえ

false

true または false のみ。true の場合、読み取りツールはミラーを更新し、ミラーから回答し、ミス時には Graph にフォールバックする。

MIRROR_SYNC_REQUEST_BUDGET

いいえ

120

1 回の同期実行で Graph リクエストが消費できる上限。超過すると停止し、未処理の残りを報告する。10〜350。時間あたり 400 の制限に対して。

5 つの 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 は src/token-cache.ts の MSAL キャッシュプラグインが読み書きする ドキュメントを指定する。その値はドキュメントパスでなければならず、つまりスラッシュ区切りの セグメント数が偶数である必要がある。loadConfig は起動時にコレクションパスを拒否する。

ONENOTE_CLIENT_ID と ONENOTE_AUTHORITY は、src/graph-auth.ts が Entra ID に提示する Azure アプリ登録を識別する。これはパブリッククライアントであるため、意図的にレイヤー 2 の クライアントシークレットは存在しない。その下の MCP_OAUTH_* の値はレイヤー 1、つまり Claude とこのサーバー間のものであり、無関係である。

MCP_PUBLIC_URL は Claude がこのサービスに到達する URL である。OAuth 発行者、トークンが バインドされる resource 識別子、保護リソースのメタデータドキュメントの URL はすべて これから構築される。Cloud Run 上では、プロセスがどの URL で到達されているかを知らせるものは 何もなく、Host ヘッダーから取得した値は呼び出し元が送信したものになってしまうため、 これは設定される。これは最初のデプロイで URL が生成された後にのみ埋めることができる。

npm run bootstrap は ONENOTE_CLIENT_ID、ONENOTE_AUTHORITY、 FIRESTORE_CACHE_DOC、GOOGLE_CLOUD_PROJECT のみを読み取る — MCP_OAUTH_* の値は 読まない — そして最後の 2 つはデフォルト化せず必須とする。Bootstrap を参照。

リポジトリの衛生管理

このリポジトリは公開されている。実際のページコンテンツ、レンダリングされたインク、Entra テナント名 または ID、Firestore ドキュメントの内容をコミットしてはならない。.gitignore は output/ と トークンキャッシュファイルのパターンを除外する。project-spec.md の「Repo hygiene」セクションを参照。

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