onenote-mcp
onenote-mcp
Ein MCP-Server, der Microsoft OneNote über Microsoft Graph verfügbar macht – Notizbuch- und Abschnittsstruktur, Seiteninhalte und Handschrift, die als Bild gerendert wird, das das aufrufende Modell lesen kann.
project-spec.md ist das maßgebliche Designdokument. Es behandelt die Ink-Rekonstruktions-Pipeline, die zwei unabhängigen OAuth-Ebenen, das Cloud-Run-Bereitstellungsmodell und den Firestore-gestützten Token-Cache. Lesen Sie es, bevor Sie hier etwas ändern.
Anforderungen
Node >= 24. @google-cloud/firestore erfordert Node >= 22, und Node 24 ist die aktuelle Active LTS.
Related MCP server: OneNoteMCP
Schnellstart
npm ci
npm run build
npm testSkripte
Skript | Was es tut |
| Kompiliert |
| Typprüfung ohne Ausgabe |
| Führt den Server aus dem Quellcode mit |
| Führt den kompilierten Server aus |
| Lokale Gerätecode-Anmeldung, die den Firestore-Token-Cache befüllt |
|
|
Tests befinden sich in test/ und spiegeln src/ wider. Sie laufen direkt gegen den TypeScript-Quellcode mit Natives Type-Stripping von Node, daher benötigt npm test keinen Build. Das schränkt den Quellcode ein: kein enum, kein namespace, keine Konstruktor-Parametereigenschaften, und typspezifische Importe müssen als import type geschrieben werden. Die Compiler-Optionen erasableSyntaxOnly und verbatimModuleSyntax erzwingen dies.
Siehe CLAUDE.md für das Verzeichnislayout und die damit verbundenen Konventionen.
Token-Cache
src/token-cache.ts implementiert MSALs ICachePlugin gegen ein einzelnes Firestore-Dokument, dessen Pfad aus FIRESTORE_CACHE_DOC stammt. beforeCacheAccess liest das cache-Feld des Dokuments und übergibt den String an MSAL. afterCacheAccess schreibt den serialisierten Cache innerhalb einer Firestore-Transaktion zurück, und nur wenn MSAL meldet, dass sich der Cache geändert hat. Ein nicht vorhandenes Dokument wird als leerer Cache gelesen, was dem Zustand vor der Ausführung von npm run bootstrap entspricht. Beide Einstiegspunkte verwenden dasselbe Plugin: Die Bootstrap-CLI schreibt den Cache darüber und der Server liest darüber, sodass es einen Serialisierer gibt und kein zweites Format, das synchron gehalten werden muss.
Dieser Blob ist die einzige Kopie des Aktualisierungstokens, daher schützen ihn zwei Dinge.
Ein Schreibvorgang, der das Dokument leeren würde, wird abgelehnt. MSAL entfernt Anmeldeinformationen aus seinem In-Memory-Cache bei einigen Fehlern, und afterCacheAccess läuft im finally-Block von MSAL, sodass eine Serialisierung, die das Konto verloren hat, diesen Code erreichen kann, während die gespeicherte noch gültig ist. overwriteWouldEmptyCache stoppt dies und protokolliert {"event":"token-cache-write-refused"}. Die Leerheitsprüfung liest keinen MSAL-Schlüsselnamen – ein Cache ist leer, wenn er zu einem Objekt geparst wird, dessen jeder Wert ein leerer Container ist –, sodass sie sich nicht umkehren kann, wenn MSAL sein Format ändert, und alles, was sie nicht erkennt, wird durchgelassen statt blockiert.
Der Blob, den jeder Schreibvorgang ersetzt, wird in einem previousCache-Feld aufbewahrt. Eine Generation, keine Historie: Der Cache wird bei jeder Aktualisierung neu geschrieben, und die nützliche Kopie ist immer die neueste gute. Die Wiederherstellung nach einem fehlerhaften Schreibvorgang besteht darin, dieses Feld in der Firestore-Konsole über cache zu kopieren, was sich lohnt, weil die Alternative eine Gerätecode-Anmeldung ist. Aktivieren Sie Point-in-Time-Recovery für eine zweite Ebene:
gcloud firestore databases update --enable-pitrEin Backend-Fehler ist kein Anmeldefehler. Wenn Firestore nicht erreichbar ist oder eine widerrufene roles/datastore.user-Bindung vorliegt, wird TokenCacheUnavailableError ausgelöst, anstatt als der Fehler zu erscheinen, den ein totes Aktualisierungstoken erzeugt. Schreibvorgänge werden dreimal wiederholt, bevor das passiert. Siehe die Zeile cache-unavailable in der Tabelle unten, warum die Unterscheidung den Code wert ist.
npm test deckt nur readCache ab, die Funktion, die eine Dokument-Snapshot dekodiert. Die beiden Callbacks, die Transaktion und createFirestoreTokenCachePlugin haben keinen automatisierten Test – sie benötigen ein Firestore-Backend. Sie zu testen bedeutet den Emulator, der java auf PATH und eine eigene Installation benötigt:
sudo apt-get install google-cloud-cli-firestore-emulatorgcloud components install cloud-firestore-emulator installiert es nicht auf einer Debian-paketierten Google Cloud CLI. Der Komponentenmanager ist in diesem Build deaktiviert, und gcloud gibt stattdessen den obigen apt-get-Befehl aus.
Graph-Authentifizierung
src/graph-auth.ts wandelt den befüllten Token-Cache in ein Microsoft-Graph-Zugriffstoken um. createGraphAuth erstellt eine PublicClientApplication aus ONENOTE_CLIENT_ID, ONENOTE_AUTHORITY und dem Firestore-Cache-Plugin und hält sie für die Lebensdauer des Prozesses. getAccessToken() liest das zwischengespeicherte Konto, ruft acquireTokenSilent auf und gibt das Token zurück. Angeforderte Bereiche sind Notes.Read und Notes.ReadWrite, vollständig qualifiziert.
Der bereitgestellte Server meldet sich nie interaktiv an. Er hat keine Möglichkeit, jemanden aufzufordern, und die OneNote-Endpunkte von Graph unterstützen keine App-only-Authentifizierung, daher gibt es keinen Fallback, wenn das gespeicherte Aktualisierungstoken stirbt – ein Mensch führt npm run bootstrap erneut aus. Jeder Fehler ist daher ein GraphAuthError, der dies sagt, anstatt ein roher MSAL-Fehler zu sein, der den Aufrufer als nackten 401 von Graph erreichen würde:
| Was passiert ist | Was zu tun ist |
| Das Firestore-Dokument fehlt, oder sein |
|
| Firestore hat nicht geantwortet, oder das Laufzeitdienstkonto hat | Erneut versuchen. Keine Anmeldung. |
| Der Cache wurde gelesen, enthält aber kein angemeldetes Konto |
|
| Das gespeicherte Aktualisierungstoken ist abgelaufen oder widerrufen, oder der Token-Endpunkt hat nichts Brauchbares zurückgegeben |
|
cache-unavailable ist die Zeile, die sich lohnt. Firestore wird innerhalb von acquireTokenSilent über das Cache-Plugin gelesen und geschrieben, daher kam ein Backend-Ausfall früher als dieselbe Ablehnung an, die ein totes Aktualisierungstoken erzeugt – und diese Meldung sagt dem Betreiber, er solle zu einem Browser gehen und eine Anmeldeinformation ersetzen, die funktioniert. GraphAuthError.retryable trägt die Unterscheidung, und nur dieser Grund setzt sie.
Jeder dieser Fälle schreibt auch eine Zeile nach stderr:
{"event":"graph-auth-failure","reason":"silent-failed","documentPath":"tokencache/msal","retryable":"false"}Diese Zeile ist der Punkt. Ein Tool-Fehler erscheint sonst nur innerhalb einer Claude-Konversation, daher sagt ohne sie nichts dem Betreiber, dass der Connector nicht mehr funktioniert. Siehe Alerting unten.
Die Meldungen nennen den Dokumentpfad und den zugrunde liegenden MSAL-Fehler und tragen bewusst keine Kontoidentifikation: username ist die UPN des Benutzers und homeAccountId enthält die Mandanten-ID, beides gehört nicht in ein Log.
npm test deckt die Erfassungslogik über einen Fake-Client ab. createGraphAuth selbst hat keinen automatisierten Test: Es benötigt einen Cache, der durch eine echte Gerätecode-Anmeldung befüllt wurde, und keine Anmeldeinformation, die einen solchen befüllen könnte, darf committet werden. Führen Sie npm run bootstrap und dann den Server gegen dasselbe Dokument aus, um es zu testen. Sein Konsument ist der Graph-Struktur-Client unten; nichts verdrahtet eines davon bisher in createApp.
Graph-Struktur
src/graph-structure.ts liest den OneNote-Baum: Notizbücher, Abschnittsgruppen, Abschnitte und die Seitenliste innerhalb eines Abschnitts. new GraphStructure(auth) akzeptiert alles mit einer getAccessToken()-Methode, daher übergibt der Server ihm die obige GraphAuth.
Methode | Rückgabe |
| Jedes Notizbuch, nach Anzeigename |
| Abschnitte direkt unter einem Notizbuch oder einer Abschnittsgruppe |
| Abschnittsgruppen direkt unter einem Notizbuch oder einer Abschnittsgruppe |
| Beide oben genannten, zusammen abgerufen |
| Seiten in einem Abschnitt, zuletzt geändert zuerst, höchstens |
| Ein Notizbuch mit jeder verschachtelten Abschnittsgruppe aufgelöst |
| Jedes Notizbuch, jeweils mit aufgelöstem Baum |
| Jedes Notizbuch mit seinen Abschnitten und einer Ebene von Abschnittsgruppen, in einer einzigen Anfrage |
| Abschnitte überall im Konto, deren Name diesen Text enthält, jeweils mit übergeordnetem Notizbuch und Abschnittsgruppe |
| Seiten in einem Abschnitt, deren Titel übereinstimmt, von Graph case-insensitiv verglichen |
containerKind ist notebooks oder sectionGroups – die beiden Graph-Beziehungsnamen. Beide Containerarten legen dieselben untergeordneten Beziehungen offen, weshalb die Listenmethoden die Art annehmen, anstatt zweimal zu existieren.
getExpandedTree() ist die günstige Methode. Sie bittet Graph, die Beziehungen zu erweitern, anstatt sie zu durchlaufen:
GET /me/onenote/notebooks?$select=id,displayName
&$expand=sections($select=id,displayName),
sectionGroups($select=id,displayName;$expand=sections($select=id,displayName))Gemessen an einem Konto mit 54 Notizbüchern: eine Anfrage und 78 KB, gegenüber 195 Anfragen für getFullTree(), was wichtig ist, weil OneNote 400 Anfragen pro Stunde und 5 gleichzeitige erlaubt. Das $select in jeder Expand-Klausel reduziert die Antwort von 441 KB auf 78 KB, und das Trennzeichen innerhalb einer Klausel, die sowohl $select als auch $expand trägt, ist ein Semikolon. Was es nicht erreicht, ist eine Abschnittsgruppe, die in einer Abschnittsgruppe verschachtelt ist – Graph begrenzt die $expand-Verschachtelung auf zwei Ebenen –, daher deckt findSectionsByName diesen Fall in einer einzigen Anfrage ab, indem es die kontoweite Abschnittsliste filtert und die übergeordneten Elemente jedes Abschnitts erweitert.
api-overview.md dokumentiert, was diese Endpunkte akzeptieren, einschließlich der Stellen, an denen der Dienst seiner eigenen Dokumentation widerspricht.
Drei Dinge, die die Traversierung behandelt, die ein einzelner Graph-Aufruf nicht tut:
Verschachtelung. Abschnittsgruppen sind die "Tab-Gruppen" der Benutzeroberfläche, und sie enthalten weitere Abschnittsgruppen.
getNotebookTreerekursiert.Paginierung. Jeder Listenaufruf folgt
@odata.nextLink, bis es nicht mehr erscheint. Graph wählt seine eigene Seitengröße und ignoriert ein größeres$top, daher ist eine Antwort nie ein Beweis dafür, dass eine Sammlung vollständig ist.listPagesInSectionstoppt, sobaldtopElemente in der Hand sind, also isttopeine Ergebnisanzahl und keine Seitengröße.Die kontoweite Seitenliste wird nie aufgerufen.
GET /me/onenote/pagesschlägt mit Fehler 20266, "maximum sections exceeded", bei einer Notizbuch-pro-Jahr-Struktur fehl. Die Seitenauflistung ist immer auf/me/onenote/sections/{id}/pagesbeschränkt, und ein Test scanntsrc/nach dem kontoweiten Pfad.
Fehler sind GraphRequestError für eine Nicht-2xx-Antwort – er trägt status, statusText und den Antwort-body, weil Fehler 20266 nur durch diesen Text von jedem anderen 400 zu unterscheiden ist – und GraphResponseError für eine 2xx-Antwort, deren Body nicht die erwartete Form hat, eine Auflistung, die nicht endet, oder Abschnittsgruppen, die tiefer als 20 Ebenen verschachtelt sind. Keine Nachricht enthält einen Notizbuch-, Abschnitts- oder Seitennamen.
npm test treibt alles über einen gefälschten fetch an, der nach exakter URL schlüsselt. Was das nicht prüfen kann, ist, ob Graph diese URLs akzeptiert; die Query-Strings stammen aus dem validierten Recon-Skript in Anhang A von project-spec.md und werden nur durch Ausführung gegen den echten Mandanten bestätigt.
Ink
Graphs normaler Seiteninhalt-Endpunkt verwirft Handschrift und hinterlässt <!-- InkNode is not supported -->, und Graph kann eine Seite nicht als Bild oder PDF exportieren. Handschrift wird daher aus rohen Strichdaten rekonstruiert: GET /me/onenote/pages/{id}/content?includeInkML=true antwortet mit multipart/mixed, ein Teil das gleiche HTML und ein anderer das InkML. Die Striche werden zu einem SVG und dann zu einem PNG, das dem aufrufenden Modell als Bild zur eigenen visuellen Auswertung übergeben wird. Kein OCR-Dienst ist beteiligt.
Modul | Was es tut |
|
|
|
|
|
|
Vier Details entscheiden, ob das überhaupt funktioniert, und alle vier stammen aus dem validierten Recon-Skript in Anhang A von project-spec.md:
Namespaces werden entfernt. Graph emittiert
inkml:ink,inkml:trace,inkml:traceFormat.fast-xml-parserist mitremoveNSPrefix: truekonfiguriert, und jede Suche verwendet den nackten Namen.Die Kanalreihenfolge stammt aus
<traceFormat>. Die Punkte dieses Kontos sind X, Y, F, wobei F der Stiftdruck ist. Das Lesen der ersten beiden Zahlen jedes Punkts zeichnet den Druck als Koordinate.Koordinaten sind himetrisch.
px = himetric * 96 / 2540. Das ist derselbe Koordinatenraum, in dem das Seiten-HTML getippten Inhalt positioniert, sodass Tinte und getippter Inhalt später rechnerisch aufeinander registriert werden könnten.Spuren sind überall im Baum. Eine Seite kann mehr als eine
<ink>-Wurzel tragen, und<traceGroup>-Elemente verschachteln sich. Alle werden gesammelt.
Eine Seite ohne Tinte rendert zu null. Das ist die normale Antwort für eine getippte Seite, kein Fehler. Die Fehler, die auftreten, sind InkParseError für Spurgruppen, die tiefer als 50 Ebenen verschachtelt sind, und InkRenderError für ein Dokument, das resvg ablehnt; keine der Nachrichten reproduziert Teile des Dokuments, weil Strichkoordinaten die Handschrift des Benutzers sind.
test/fixtures/*.inkml sind handgeschrieben – ein paar Striche, X/Y/F-Kanalreihenfolge, himetrische Einheiten, eine Datei mit zwei <ink>-Wurzeln und verschachtelten <traceGroup>-Elementen. Es darf kein erfasster Seiten-Dump eingecheckt werden: gerenderte Tinte ist vollständig lesbare persönliche Notizen.
MCP endpoint
Der Server spricht MCP über zustandsloses Streamable HTTP unter POST /mcp. Jede Anfrage erstellt ihren eigenen MCP-Server, antwortet und baut ihn wieder ab; nichts überlebt bis zur nächsten. Es gibt keine Sitzungs-ID und kein SSE – GET /mcp und DELETE /mcp werden mit 405 beantwortet, und ein POST antwortet mit einem JSON-Body, anstatt einen Stream zu öffnen. Ein offener Stream würde eine Cloud-Run-Instanz am Leben halten und für Leerlaufzeit abrechnen.
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}Beide Accept-Typen sind laut Streamable-HTTP-Spezifikation erforderlich, obwohl dieser Server nie streamt. createTools in src/tools.ts ist die Registrierung – sechs Browsing-Tools (list_notebooks, list_sections, list_pages, search_pages, find_page_by_name, list_pages_by_name), ein Lesetool (get_page_content) und fünf Schreibwerkzeuge (append_to_page, append_to_page_by_name, create_page, create_page_by_name, update_page_title) – und src/mcp-server.ts ist die JSON-RPC-Oberfläche darum herum.
Ein Tool, das eine Ausnahme auslöst, kommt als Tool-Ergebnis mit isError: true und einer lesbaren Nachricht zurück – ein abgelaufenes Refresh-Token, eine Seite, die nicht mehr existiert, und ein Dokument, das resvg ablehnt, sind normale Ergebnisse, keine Protokollfehler. Nur ein Aufruf eines Tools, das nie registriert wurde, ist ein JSON-RPC-Fehler.
Jede Anfrage schreibt eine JSON-Logzeile: das HTTP-Verb, den Pfad, den Status, die Dauer, die JSON-RPC-Methode und den Toolnamen bei einem tools/call. Niemals die Query-String, die Header, die Argumente oder das Ergebnis – siehe src/logging.ts.
/mcp ist hinter einem Bearer-Token geschlossen – siehe Bearer-Tokens auf dem MCP-Endpunkt. Der Health-Endpunkt bleibt offen.
OAuth discovery
Claude muss den Autorisierungsserver finden, bevor es einen Ablauf starten kann. src/oauth-router.ts mountet den mcpAuthRouter des SDKs an der Anwendungswurzel – er baut seine Pfade aus der Issuer-URL und nicht aus einem Mount-Punkt, sodass er nicht hinter einem Präfix liegen kann – und bedient fünf Routen, die alle notgedrungen unauthentifiziert sind:
Pfad | Was es ist |
| RFC-8414-Metadaten des Autorisierungsservers |
| RFC-9728-Metadaten der geschützten Ressource |
| Autorisierungs-Endpunkt |
| Wohin das Einwilligungsformular zurücksendet; der |
| Token-Endpunkt |
curl -s localhost:8080/.well-known/oauth-authorization-server
curl -s localhost:8080/.well-known/oauth-protected-resource/mcpAlles in beiden Dokumenten wird von MCP_PUBLIC_URL abgeleitet: Es ist der Issuer, und die resource-Kennung ist es plus /mcp. MCP_PUBLIC_URL wird beim Start abgelehnt, wenn es einen abschließenden Schrägstrich trägt, damit jede URL, die durch Anhängen eines Pfads daran entsteht, wohlgeformt ist; das issuer-Feld meldet dann die URL-normalisierte Form, die für einen reinen Origin-Wert dieselbe Zeichenfolge mit wieder hinzugefügtem abschließendem Schrägstrich ist. Das Dokument der geschützten Ressource wird nur unter der pfadsuffigierten URL ausgeliefert – die nackte /.well-known/oauth-protected-resource ist eine 404, ebenso wie /.well-known/openid-configuration. Claude testet zuerst den suffigierten Pfad.
scopes_supported listet offline_access, was Claude dazu bringt, ein Refresh-Token anzufordern, anstatt bei jedem Ablauf eines Zugriffstokens erneut einzuwilligen. Es gibt keinen registration_endpoint: Client-ID und -Geheimnis sind konfiguriert, sodass Dynamic Client Registration nichts zu tun hat. Ein Client ist registriert, mit drei Redirect-URIs – https://claude.ai/api/mcp/auth_callback für die gehosteten Claude-Oberflächen und http://localhost/callback plus http://127.0.0.1/callback für Claude Code, dessen Port gemäß RFC 8252 ignoriert wird.
GET /authorize rendert eine Einwilligungsseite, anstatt umzuleiten: eine Schaltfläche „Genehmigen“, die benennt, was gewährt wird, und den Host, an den der Autorisierungscode gesendet wird. Das Genehmigen sendet an POST /consent zurück, das einen 60-Sekunden-Einmalcode erzeugt und zur Client-Callback-URL umleitet. Die gesamte Autorisierungsanfrage überquert diese Seite in einem versteckten Feld, das mit MCP_TOKEN_SIGNING_KEY signiert ist, sodass ein Instanzwechsel während der Einwilligung den Ablauf nicht bricht und das Formular nicht bearbeitet werden kann; ein Feld, das die Verifizierung nicht besteht, ist eine 400 ohne Umleitung und ohne erzeugten Code.
Beide Einwilligungsantworten tragen Cache-Control: no-store, Referrer-Policy: no-referrer – das Formular sendet von der /authorize-URL, die state und die PKCE-Challenge in ihrer Query-String hat – X-Frame-Options: DENY und eine CSP von default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'. Es gibt bewusst kein form-action: Browser waren sich uneinig, ob es gegen ein Redirect-Ziel geprüft wird, und der Einwilligungs-POST antwortet mit einer Umleitung zu claude.ai.
POST /consent hat ein eigenes Ratenlimit – 200 in 15 Minuten –, weil es absichtlich vor dem /authorize-Limiter des SDKs montiert ist und ein gerendertes Formular zehn Minuten lang postbar bleibt, sodass ein Durchlauf durch /authorize ein Feld ergibt, das wiedergegeben werden kann. Das Limit liegt über der Obergrenze von 100 ausstehenden Codes, sodass die eigene Verdrängung des Speichers, deren Verhalten spezifiziert ist, das ist, worauf ein Burst zuerst trifft.
POST /token stellt ein Zugriffstoken aus, das eine Stunde gültig ist, und ein Refresh-Token, das 30 Tage gültig ist. Beide sind ein HMAC-SHA256 über eine kompakte Nutzlast unter MCP_TOKEN_SIGNING_KEY und sonst nichts – es wird kein Speicher konsultiert, um eines zu verifizieren, was verhindert, dass ein Cloud-Run-Revisionsaustausch eine erneute Verbindung erzwingt. Die Nutzlast trägt die Audience, die MCP_PUBLIC_URL plus /mcp ist, sodass ein Token nur für diesen MCP-Endpunkt und keinen anderen gültig ist. Wie lange diese Token leben und wie man einen Menschen dazu bringt, häufiger zuzustimmen, ist zwei Abschnitte weiter unten.
Bearer tokens on the MCP endpoint
Jede Anfrage an /mcp benötigt Authorization: Bearer <access token>. Das requireBearerAuth des SDKs sitzt vor dem MCP-Router in createApp, und verifyAccessToken in src/oauth-provider.ts ist das, was es aufruft: die HMAC-Signatur unter MCP_TOKEN_SIGNING_KEY, die Token-Art, das Ablaufdatum und die Audience. Ein Token, das korrekt signiert und nicht abgelaufen ist, aber die Ressourcenkennung eines anderen Servers trägt, wird abgelehnt – das SDK prüft keine eigene Audience, sodass ohne diese Prüfung ein Token, das für einen anderen MCP-Server von einem Server mit diesem Signaturschlüssel ausgestellt wurde, akzeptiert würde.
Eine Anfrage ohne Token, mit abgelaufenem Token oder mit einem Token, das eine dieser Prüfungen nicht besteht, wird mit 401 und einem Challenge-Header beantwortet:
WWW-Authenticate: Bearer error="invalid_token", error_description="…",
resource_metadata="https://<MCP_PUBLIC_URL>/.well-known/oauth-protected-resource/mcp"Der Parameter resource_metadata ist der entscheidende Teil: So findet Claude den Autorisierungsserver und startet den Ablauf, daher ist eine 401 ohne ihn eine Sackgasse und keine Anmeldeaufforderung. Claude aktualisiert reaktiv bei einer 401 und proaktiv einige Minuten vor dem gespeicherten Ablauf, sodass eine 401 hier ein gewöhnliches Ereignis ist.
Das Token wird aus dem Authorization-Header und von sonst nichts gelesen. ?access_token= in der Query-String wird nicht berücksichtigt – die MCP-Autorisierungsspezifikation verbietet es, und src/logging.ts lässt die Query-String aus der Logzeile aus genau diesem Grund weg.
Welche Routen offen sind, ist die Ausnahmeliste, und sie ist länger als „alles außer /mcp“, weil der gesamte Autorisierungsablauf Anrufer beantworten muss, die noch kein Token haben: /healthz und /health, beide .well-known-Dokumente, /authorize, /consent und /token. Ein Test in test/server.test.ts zählt die Routen auf, die createApp tatsächlich registriert, und stellt fest, dass jede nicht auf dieser Liste ohne Token mit 401 antwortet, sodass eine später hinzugefügte Route geschlossen ist, es sei denn, jemand öffnet sie absichtlich.
Es sind keine Scopes erforderlich. offline_access, der einzige Scope, den dieser Server ausstellt, betrifft, ob ein Refresh-Token gewährt wird, und nicht, was ein Anrufer tun darf; ihn zu verlangen würde für ansonsten gültige Token mit 403 antworten. Falls jemals eine Scope-Prüfung hinzugefügt wird, muss die 403 WWW-Authenticate: Bearer error="insufficient_scope" tragen – was diese Middleware tut –, weil Claude jede andere 403 als endgültig behandelt und zu nichts auffordert.
Token lifetime and forcing revalidation
Dieser Server ist dafür gebaut, unbeaufsichtigt zu laufen. Die Standardeinstellungen spiegeln das wider und geben dafür etwas von der Fähigkeit auf, ein durchgesickertes Zugangsdatum zu kappen. Lesen Sie dies, bevor Sie ihn an einem Ort bereitstellen, der wichtig ist, und ändern Sie die Zahlen, wenn der Kompromiss für Sie falsch ist.
Was die Standardeinstellungen bewirken
Token | Lebensdauer | Was es erneuert |
Access-Token | 1 Stunde | Das Refresh-Token, automatisch |
Refresh-Token | 30 Tage | Jede Erneuerung erzeugt ein neues mit frischen 30 Tagen |
Zustimmungsformular | 10 Minuten | Nichts; ein veraltetes Formular wird abgelehnt und der Ablauf startet neu |
Claude aktualisiert von selbst – proaktiv vor Ablauf der Stunde und reaktiv bei einem 401. Ein Mensch klickt also auf „Genehmigen“, wenn der Connector zum ersten Mal hinzugefügt wird, und danach nur, wenn der Connector 30 Tage lang ungenutzt bleibt. Das ist das gleitende Fenster: Die 30 Tage begrenzen, wie lange die Verbindung inaktiv bleiben darf, nicht wie lange sie bestehen darf.
Warum gleitend, und was es kostet
Jedes Token, das dieser Server ausgibt, ist zustandslos. Es ist eine signierte Nutzlast und sonst nichts – keine Datenbankzeile, kein Sitzungsdatensatz, nichts, was beim Zurückkommen nachgeschlagen werden müsste. Das macht einen Austausch einer Cloud-Run-Revision unsichtbar: Die neue Instanz verifiziert ein Token, das die alte Instanz ausgestellt hat, ohne gemeinsamen Zustand zwischen ihnen. Ein Token-Speicher würde bei jeder Bereitstellung eine Wiederverbindung bedeuten.
Der Preis ist, dass nichts einzeln widerrufen werden kann. Es gibt keinen Widerruf-Endpunkt, weil es nichts gibt, das er löschen könnte. Konkret:
Ein durchgesickertes Refresh-Token gewährt bis zu 30 Tage lang Zugriff, und jede Verwendung verlängert den Zugriff des Inhabers um weitere 30. Es gibt keinen serverseitigen Datensatz, der ungültig gemacht werden könnte, und keine Möglichkeit, ein gestohlenes Refresh-Token von dem legitimen zu unterscheiden – beide sind dieselben Bytes, die mit demselben Schlüssel signiert sind.
Das Gleiten des Fensters ist keine Rotation. Wenn eine Erneuerung ein neues Refresh-Token erzeugt, funktioniert das ersetzte weiterhin bis zum Ablauf des darin eingestempelten Zeitstempels. Echte Rotation bedeutet, das alte Token als verbraucht zu markieren, was den Speicher erfordert, den dieses Design nicht hat.
Ein Access-Token kann innerhalb seiner Stunde nicht gekappt werden, aus demselben Grund.
Was bleibt, ist ein grober Hebel, und er wirkt sofort: Ändern Sie MCP_TOKEN_SIGNING_KEY und stellen Sie neu bereit. Jedes Access-Token, jedes Refresh-Token und jede offene Zustimmungsseite wird sofort ungültig, weil alle gegen diesen Schlüssel verifiziert werden. Die nächste Claude-Anfrage erhält einen 401, und der Betreiber klickt einmal auf „Genehmigen“. Den Schlüssel regelmäßig zu rotieren ist für sich genommen eine vernünftige Richtlinie.
Der Zustimmungsbildschirm authentifiziert, so viel sei gesagt, niemanden – er hat einen Button und kein Passwort. Was zwischen einem Fremden und Ihren Notizbüchern steht, ist MCP_OAUTH_CLIENT_SECRET, das POST /token verlangt, die Redirect-URI-Zulassungsliste, die jeden Autorisierungscode an claude.ai oder an Loopback sendet, und PKCE, das den Code an den Client bindet, der den Ablauf gestartet hat.
Einen Menschen häufiger genehmigen lassen
Jede dieser Änderungen ist eine Quellcodeänderung, kein Konfigurationswert. Das ist beabsichtigt: Ein Betreiber, der das Fenster verkürzt, verändert die Sicherheitslage der Bereitstellung, und das gehört in einen Commit, den jemand lesen kann, und nicht in eine Umgebungsvariable, die jemand vergessen kann.
Verkürzen Sie das Inaktivitätsfenster. In 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 weekSo lange ungenutzt, benötigt der Connector einen Klick. Bei regelmäßiger Nutzung fragt er weiterhin nie – das Fenster gleitet weiter nach vorne. Das begrenzt, wie lange ein durchgesickertes Refresh-Token nach dem Ende der Nutzung des Lecks überlebt, und sonst nichts.
Stoppen Sie das Gleiten des Fensters. Das war ursprünglich in Issue #22 spezifiziert, und es begrenzt die Gesamtlebensdauer einer Verbindung statt ihrer Inaktivitätszeit: Ein Mensch genehmigt alle 30 Tage, egal wie beschäftigt der Connector ist. Eine Zeile in exchangeRefreshToken, in 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);Verweigern Sie die Ausgabe von Refresh-Tokens vollständig. Die strengste Einstellung: Ein Mensch genehmigt jede Stunde, weil ein abgelaufenes Access-Token nichts hat, das es erneuert. Zwei Änderungen, beide erforderlich – der Metadaten-Schalter allein stoppt die Ausgabe des Tokens nicht.
In
src/oauth-router.tsleeren SieSCOPES_SUPPORTED. Claude hängtoffline_accessnur dann an eine Autorisierungsanfrage an, wenn die Metadaten es bewerben, und das ist der Schalter, der entscheidet, ob es ein Refresh-Token anfordert.In
src/oauth-provider.tsentfernen Sie das Feldrefresh_tokenaus dem, wasissueTokenszurückgibt. Es wird heute unabhängig von den angeforderten Scopes ausgegeben.
Erwarten Sie, dass diese Einstellung sichtbar ist: Claude sendet den Browser mitten in der Sitzung zurück zum Zustimmungsbildschirm, wenn die Stunde abläuft.
Verkürzen Sie das Access-Token. ACCESS_TOKEN_TTL_S in src/oauth-provider.ts verengt das Fenster, in dem ein durchgesickertes Access-Token funktioniert. Es kostet eine Token-Anfrage pro Ablauf und keinerlei menschliches Eingreifen, also ist es günstig – aber es bewirkt nichts gegen ein durchgesickertes Refresh-Token, das die Zugangsdaten sind, um die man sich Sorgen machen sollte.
Keepalive
Microsofts delegierte Refresh-Tokens verfallen nach etwa 90 Tagen ohne Nutzung. Das Token gleitet nur nach vorne, wenn es tatsächlich ausgetauscht wird, und es wird nur ausgetauscht, wenn nach Ablauf des gehaltenen Access-Tokens ein Tool-Aufruf eintrifft – ein Connector, den drei Monate lang niemand nutzt, ist also ein Connector, der eine Person an einem Browser benötigt, die npm run bootstrap ausführt. Nichts im Server kann das von sich aus verhindern, weil nichts im Server läuft, wenn niemand es aufruft.
POST /keepalive ist die Lösung. Es ruft acquireTokenSilent mit forceRefresh: true auf, was das gehaltene Access-Token überspringt und das Refresh-Token austauscht, sodass Entra ein Ersatz-Token mit frischem Fenster ausgibt und src/token-cache.ts es in Firestore schreibt. forceRefresh ist der tragende Teil: Ohne es antwortet MSAL aus seinem eigenen Cache, keine Anfrage erreicht Entra, und das Fenster bewegt sich nicht.
Setzen Sie MCP_KEEPALIVE_SECRET auf mindestens 32 zufällige Zeichen, und die Route wird bereitgestellt; lassen Sie es ungesetzt, und der Pfad antwortet mit 404. Ein Scheduler präsentiert das Geheimnis im X-Keepalive-Secret-Header, der in konstanter Zeit verglichen wird, bevor irgendeine Arbeit erledigt wird. Es ist ein gemeinsames Geheimnis und kein Bearer-Token, weil ein Scheduler den OAuth-Ablauf nicht ausführen kann – er hat keinen Browser und keinen Ort, um ein Refresh-Token aufzubewahren – und es ist eine eigene Variable und nicht das Layer-1-Client-Geheimnis, damit eine Zugangsdaten, die die gesamte MCP-Oberfläche erreichen kann, nicht auch in einem Scheduler-Job liegt.
Einrichtung auf Ihrem eigenen Konto
Vier Schritte: ein Geheimnis generieren, es auf den Dienst bringen, den Job erstellen und prüfen, dass der Job den Dienst tatsächlich erreicht. Das Ganze ist optional – überspringen Sie es, und der Server funktioniert, bis zu dem Tag, an dem ihn 90 Tage lang niemand aufgerufen hat.
# 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 ist erforderlich und ist die eigene Region des Schedulers, die nichts mit dem Ziel des Jobs zu tun hat; die Cloud-Run-Region zu verwenden, spart einen Wert im Kopf. scripts/gcp-bootstrap.sh aktiviert auch cloudscheduler.googleapis.com, also ist die Aktivierung in Schritt 3 nur bei einem Projekt nötig, das vor der Existenz dieser Zeile gebootstrapt wurde.
Dann beweisen Sie es, denn jede Fehlerart hier ist still – ein falsches Geheimnis, ein Job, der auf die alte URL zeigt, und ein Dienst, der ohne die Variable bereitgestellt wurde, sehen alle wie ein Scheduler-Job aus, der aktiviert dasitzt:
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)'Ein 200 bedeutet, dass das Token ausgetauscht wurde. Was beweist, dass es gespeichert wurde, ist das updatedAt-Feld des Firestore-Dokuments, das sich auf den Zeitpunkt der Ausführung verschiebt – das ist der einzige Beweis, dass das Ersatz-Refresh-Token den Aufruf überlebt hat. Gemessen auf dieser Bereitstellung am 2026-08-19: Der erzwungene Lauf antwortete mit 200, und tokencache/msal rückte von 14:15:53Z auf 14:30:54Z vor.
Wöchentlich ist reichlich gegen ein 90-Tage-Fenster und lässt Platz für mehrere verpasste Läufe. Der Job kostet eine Token-Endpunkt-Roundtrip und einen Firestore-Schreibvorgang.
Um das Geheimnis zu rotieren: Setzen Sie das neue GitHub-Geheimnis, stellen Sie bereit, dann gcloud scheduler jobs update http onenote-mcp-keepalive --update-headers=…. Jeder Lauf zwischen der Bereitstellung und der Job-Aktualisierung antwortet mit 401 und tut keine Arbeit, was bei einem wöchentlichen Zeitplan ein Fenster ist, in das nichts fällt.
Der Header liegt in der Job-Definition, lesbar für jeden mit roles/cloudscheduler.viewer auf dem Projekt, genau wie die Umgebungsvariablen in der Cloud-Run-Revisionsspezifikation lesbar sind. Das ist derselbe Kompromiss, der in Bereitstellung für den Verzicht auf Secret Manager eingegangen wird.
Status | Bedeutung | Was der Scheduler tun sollte |
200 | Das Refresh-Token wurde ausgetauscht und das neue gespeichert | Nichts |
401 | Das Geheimnis fehlt oder ist falsch | Job korrigieren; die Route hat keine Arbeit getan |
404 |
| Setzen und neu bereitstellen |
503 mit | Firestore war nicht erreichbar | Erneut versuchen |
503 mit | Die Genehmigung ist tot |
|
Wovor das nicht schützt: eine Conditional-Access-Richtlinie zur Anmeldehäufigkeit, eine Passwortänderung, ein MFA-Reset oder ein Admin, der die Genehmigung widerruft. Jede dieser Maßnahmen tötet das Refresh-Token, egal was der Zeitplan sagt, und keine Codeänderung vermeidet das. Wenn der Entra-Mandant Ihnen gehört, schließen Sie diese App-Registrierung von Richtlinien zur Anmeldehäufigkeit aus; wenn nicht, behandeln Sie 90 Tage als Obergrenze, die jemand anderes verkürzen kann, ohne es Ihnen zu sagen.
Die Keepalive-Route hat auch nichts mit dem Layer-1-30-Tage-Fenster in Token-Lebensdauer unten zu tun. Dieses Refresh-Token lebt im Connector-Speicher von Claude, und nur Claude kann es präsentieren oder sein Ersatz erhalten, also kann nichts, was hier läuft, es am Leben halten. Es zu verlieren kostet einen Klick auf die Schaltfläche „Genehmigen“; das Microsoft-Token zu verlieren kostet eine Gerätecode-Anmeldung.
Seitenspiegel-Sync
Der Spiegel hält Firestore-Kopien der Seiten in einer handverlesenen Auswahl von Notizbüchern, damit die Lese-Tools antworten können, ohne OneNotes Anfragebudget zu verbrauchen. POST /sync ist, wie er gefüllt wird. Nichts daran ist automatisch: Die Route ist ohne ein Geheimnis nicht bereitgestellt, und die Lese-Tools konsultieren den Spiegel nicht ohne MIRROR_READ_ENABLED.
Was ihn aktuell hält
Drei Mechanismen. Nur zwei sind zeitgesteuert, und einer davon ist vorübergehend.
Was ihn ausführt | Was er abdeckt | |
Initialer Backfill | Ein | Jedes gespiegelte Notizbuch, aktiv oder nicht, einmal gefüllt |
Laufendes Nachholen | Jede abgedeckte Leseoperation, inline, bevor sie antwortet | Seiten, die in aktiven Notizbüchern erstellt und bearbeitet wurden |
Löschdurchlauf | Ein nächtlicher Scheduler-Job | Seiten, die aus aktiven Notizbüchern gelöscht wurden |
Es gibt im Dauerzustand keine geplante inkrementelle Synchronisierung, weil die Leseoperationen sie erledigen. Jede abgedeckte Leseoperation führt runIncremental selbst aus, bevor sie antwortet, mit höchstens 12 Anfragen und 15 Sekunden und höchstens einmal alle 30 Sekunden – siehe Lesen daraus unten. Der Spiegel wird also aktualisiert, wenn jemand kurz davor ist, ihn anzusehen, und sonst nicht. Ein geplanter inkrementeller Lauf daneben würde dieselben 400 Anfragen pro Stunde ausgeben, um Daten zu aktualisieren, die niemand angefordert hat, und die nächste Leseoperation würde ohnehin wieder aktualisieren.
Ein Lesevorgang kann eine Löschung nicht bemerken, was der eigentliche Grund für den nächtlichen Sweep ist.
Der inkrementelle Durchlauf stützt sich auf das lastModifiedDateTime der übergeordneten Sektion – eine Erstellung, eine Bearbeitung und eine Löschung verschieben es jeweils – und fragt dann diese Sektion nach ihren geänderten Seiten ab. Eine gelöschte Seite erscheint in keiner Auflistung, also sagt nichts in dieser Antwort, dass sie weg ist. Das herauszufinden bedeutet, die Seiten-IDs der Sektion aufzulisten und sie mit dem Mirror abzugleichen, weil Graph kein /delta auf irgendeine OneNote-Ressource und kein Tombstone für eine gelöschte Seite hat. Das ist ein Sweep, er ist viel zu teuer, um ihn vor einen Lesevorgang zu setzen, und bis einer läuft, bedient der Mirror weiterhin eine Seite, die nicht mehr existiert. Eine Nacht ist die Veralterung, die dies akzeptiert.
Der Backfill ist keines von beidem, also bekommt er einen eigenen Zeitplan, bis er fertig ist. Es ist eine Auflistung pro Sektion seit der Epoche plus ein Inhaltsabruf pro Seite – Tausende von Anfragen auf diesem Konto – und das 12-Anfragen-Budget des Inline-Refresh würde Wochen brauchen, um das abzudecken. Lösche den Job, sobald sync/state.backfillComplete wahr ist, was der Fall ist, wenn jede gespiegelte Sektion ein nicht-null pagesSyncedThrough hat. Nichts anderes ruft POST /sync auf.
Der erste Struktur-Schreibvorgang nach einem Upgrade schreibt jedes Dokument einmal neu. Strukturdokumente tragen einen identity-String, der angibt, was der Baum über sie gesagt hat, und ein Dokument, das geschrieben wurde, bevor dieses Feld existierte, hat keins – also stimmt es nicht überein und wird geschrieben. Es ist ein Durchlauf von Firestore-Schreibvorgängen und keine Graph-Anfragen, und es passiert einmal.
Ein inaktives Notizbuch wird einmal zurückbefüllt und dann eingefroren. activeNotebookIds im Auswahldokument zieht diese Linie – siehe Auswählen, was gespiegelt wird. Ein ausgewähltes Notizbuch, das nicht aktiv ist, wird durch den Backfill gefüllt und für immer aus dem Mirror bedient; kein Inline-Refresh und kein Sweep besucht es erneut. Das macht ein Konto mit 55 Notizbüchern erschwinglich zu spiegeln: Ein Archiv wird einmal aufgelistet und abgerufen und nie wieder.
Die vier Routen
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 notZwei davon laufen nach einem Zeitplan: /sync, während der Backfill läuft, und /sync/sweep/full nächtlich. /sync/sweep ist die günstigere, eingeschränkte Variante, nützlich von Hand. /sync/sweep/all ist der einzige Modus ohne Aktivitätsfilter und der einzige Weg, eine Löschung innerhalb eines inaktiven Notizbuchs zu bemerken; er kostet eine Anfrage pro gespiegelter Sektion, also führe ihn bewusst aus und nicht per Timer.
Was es kostet
OneNote erlaubt einer delegierten App 400 Anfragen pro Stunde, und jeder interaktive Tool-Aufruf stammt aus denselben 400. Das Konto, gegen das dies läuft, enthält 55 Notizbücher und 568 Sektionen; 4 Notizbücher und 202 Sektionen werden gespiegelt, und 2 Notizbücher und 70 Sektionen sind aktiv.
Lauf | Anfragen |
Inline-Refresh, nichts geändert | 1 – der erweiterte Baum und sonst nichts |
Inline-Refresh, ein paar bearbeitete Seiten | 2–12; ein Abruf nur für eine Seite, die wirklich geändert wurde, und 12 ist ein hartes Budget |
Nächtlicher | ~70 hier, plus eine pro geänderter Seite |
| eine pro besuchter Sektion |
| ~202 hier – die Hälfte des Stundenbudgets |
Ein Backfill-Lauf | 49–120 gemessen; er stoppt bei dem Budget, das zuerst bindet |
Eine Seite, die sich nicht geändert hat, kostet keine Anfrage. Die Auflistung geänderter Seiten trägt den Stempel und Titel jeder Seite, und eine gespeicherte Kopie, die mit beiden übereinstimmt, wird nicht erneut abgerufen. Die Kosten eines Laufs richten sich also nach den Seiten, die wirklich geändert wurden, nicht nach den in der letzten Stunde bearbeiteten Seiten, und pagesSkipped im Laufbericht gibt an, wie viele auf diese Weise abgelehnt wurden.
Die beiden Überlappungsfenster haben unterschiedliche Breiten, und das ist beabsichtigt.WATERMARK_OVERLAP_MS beträgt eine Stunde und entscheidet, welche Seiten die Auflistung einer Sektion zurückgibt; nichts, was es an die Oberfläche bringt, kostet eine Anfrage, also behält es den Spielraum. SECTION_SCAN_OVERLAP_MS beträgt fünfzehn Minuten und entscheidet, wie lange eine Sektion, die einmal bearbeitet wurde, weiterhin besucht wird, bei einer Auflistungsanfrage pro Lauf – eine Stunde davon war der letzte Begriff, den es wert war zu kürzen. Beides sind Spielräume gegen Graphs Ausbreitungsverzögerung und die Lücke zwischen seiner Uhr und der dieses Dienstes; die beiden Log-Abfragen unter Beweis, dass es funktioniert sagen, ob fünfzehn Minuten richtig sind.
MIRROR_SYNC_REQUEST_BUDGET (Standard 120) ist ein harter Stopp. Ein Lauf, der es erreicht, committet, was er getan hat, antwortet mit 200 und "done": false und setzt beim nächsten Zeitplan fort. Das macht den ersten Backfill überhaupt erst möglich.
Welcher der beiden Stopps bindet, variiert, also dimensioniere die Kadenz gegen das Anfragebudget und nicht gegen einen beobachteten Durchschnitt. Gemessen über sechs Läufe am 2026-08-20: 49, 57, 60, 63, 120 und 120 Anfragen. Die ersten vier endeten auf der 240-Sekunden-Uhr mit Budget übrig – eine Seite kostet einen Graph-Abruf, einen resvg-Tinten-Render und zwei Blob-Schreibvorgänge, und die Render füllen die Uhr auf einer 1-CPU-Instanz. Die letzten beiden gaben das volle Budget weit innerhalb der Uhr aus, bei Sektionen, deren Seiten billiger zu rendern waren.
Kadenz | Anfragen/Stunde bei Budget 120 | |
| bis zu 360 | die schnellste Kadenz, die das Limit nicht überschreiten kann |
| bis zu 480 | darunter, solange Läufe uhrgebunden bleiben, darüber, wenn sie es nicht sind |
| bis zu 720 | über dem Limit, wann immer Läufe anfragegebunden sind |
| bis zu 1440 | gemessen gegen dieses Konto am 2026-08-20: 429s innerhalb von 45 Minuten |
Über 400 pro Stunde antwortet Graph mit 429 und OData-Code 10007. Eine abgelehnte Anfrage tut keine Arbeit und verbraucht trotzdem den Versuch, der Wasserzeichen der Sektion schreitet nicht voran, und die Strafe überdauert eine kurze Backoff-Zeit – fünf Wiederholungen über drei Minuten wurden alle nach einem Burst abgelehnt, dokumentiert in docs/graph.md. Das Überschreiten des Limits
beendet den Backfill also nicht früher. Die Halbierung von MIRROR_SYNC_REQUEST_BUDGET erkauft ein halbiertes Intervall bei gleichem Stundenverbrauch, auf Kosten eines zusätzlichen Baum-Lesevorgangs pro Lauf.
Die Backfill-Größe auf diesem Konto beträgt ungefähr eine Anfrage pro Sektion plus eine pro Seite. 202 Sektionen mit etwa 24 Seiten pro Sektion – geschätzt aus den ersten vier Sektionen, deren Auflistung abgeschlossen wurde, nicht gemessen – sind ungefähr 4.800 Seiten und 5.000 Anfragen, also liegt die Untergrenze bei etwa 12,5 Stunden an Scheiben, egal welche Kadenz.
Mit MIRROR_READ_ENABLED=false gibt es überhaupt kein fortlaufendes Nachholen. Das ist die derzeit bereitgestellte Einstellung, also sind der Backfill-Job und der nächtliche Sweep die einzigen Dinge, die den Mirror berühren. Das Einschalten von Lesevorgängen macht das oben beschriebene lesegetriebene Design real, und es fügt bis zu 12 Anfragen pro Refresh hinzu, bei höchstens einer pro 30 Sekunden – ungefähr 120 Anfragen pro Stunde aus einem kontinuierlich beschäftigten Gespräch und bis zu 144, während der Mirror hinterherhinkt. Dieser Begriff passt unter 400 neben den nächtlichen Sweep, sobald der Backfill-Job weg ist.
Einrichtung
# 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"Der Backfill-Job auf dem bereitgestellten Konto liegt bei */15. Das ist unter dem Limit, solange Läufe uhrgebunden bleiben – gemessen bei 61–81 Anfragen pro Lauf am 2026-08-20 – und darüber, wenn sie wieder das volle 120-Anfragen-Budget ausgeben. */20 ist die Kadenz, die es in beide Richtungen nicht überschreiten kann; das Senken von MIRROR_SYNC_REQUEST_BUDGET auf 100 macht auch */15 sicher.
Wiederholungen sind bei beiden sicher: Jeder Modus ist idempotent, und eine Wiederholung nach einem budgeterschöpften Lauf ist während des Backfills aktiv nützlich. Während beide Jobs existieren, macht der Lauf-Lease eine Überlappung zu einem 409 statt einer doppelten Ausgabe des Graph-Budgets.
/sync/sweep/all bekommt keinen Job. Führe ihn von Hand aus, nachdem du activeNotebookIds bearbeitet hast, oder wenn ein Notizbuch außerhalb dieses Servers neu organisiert wurde und der Mirror gegen das, was OneNote tatsächlich enthält, abgeglichen werden muss.
Auswählen, was gespiegelt wird
Die Auswahl ist ein von Hand bearbeitetes Firestore-Dokument, MIRROR_ROOT_DOC, StandardonenoteMirror/default. Es ist das einzige Dokument, das der Dienst nie schreibt.
Feld | Typ | Bedeutung |
| Array von String | Notizbuch-IDs, deren Seiten gespiegelt werden |
| Array von String, optional | Welche davon noch bearbeitet werden. Fehlend oder |
| String | Freitext für dich. Wird nie gelesen. |
Hole die IDs aus list_notebooks. Struktur – jeder Notizbuch- und Sektionsname im Konto – wird unabhängig davon gespiegelt, weil der Baum-Lesevorgang alles für dieselbe eine Anfrage zurückgibt und eine partielle Struktur list_notebooks und eine unbegrenzte search_pages selbstbewusst und teilweise antworten lassen würde. Nur Seiteninhalt folgt der Auswahl.
activeNotebookIds verengt die laufende Arbeit und sonst nichts. Die Seiten, Inhalte und Tinte eines inaktiven Notizbuchs werden genau wie die eines aktiven gespeichert, Schreibvorgänge über diesen Server erreichen es weiterhin und aktualisieren weiterhin den Mirror, und ein Lesevorgang antwortet weiterhin daraus. Was sich ändert, ist, dass kein inkrementeller Durchlauf und kein Sweep es erneut überprüft, sobald sein Backfill abgeschlossen ist, also erreicht eine im OneNote-Client vorgenommene Bearbeitung eines inaktiven Notizbuchs den Mirror erst, wenn das Notizbuch wieder aktiv gemacht wird oder /sync/sweep/all von Hand ausgeführt wird.
Das Verschieben eines Notizbuchs zurück in activeNotebookIds ist sicher und kostet nur dieses Notizbuch: Der nächste Lauf vergleicht die gespeicherten Listen, findet die eine ID, die aktiv wurde, und listet die Sektionen dieses Notizbuchs gegen ihre eigenen sektionsspezifischen Wasserzeichen neu auf. Jedes andere Notizbuch behält sein Wasserzeichen und wird nicht aufgelistet. Nur Seiten, die wirklich geändert wurden, werden abgerufen. Das Hinzufügen eines Notizbuchs zu notebookIds verhält sich genauso.
Eine ID in notebookIds, die keinem Notizbuch entspricht, wird in jedem Laufbericht als unknownNotebookIds gemeldet. Eine ID in activeNotebookIds, die kein gespiegeltes Notizbuch benennt – ein Tippfehler oder ein echtes Notizbuch, das nie zu notebookIds hinzugefügt wurde – wird als unknownActiveNotebookIds gemeldet und als mirror-selection-unknown-active protokolliert. Überprüfe beide nach dem Bearbeiten: Die IDs sind undurchsichtige Strings, und ein Tippfehler ist sonst ein Notizbuch, das stillschweigend nie synchronisiert.
Beweis, dass es funktioniert
# 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)'Ein 200 ist für sich genommen kein Beweis. Die drei Dinge, die es sind:
sections/{id}.pagesSyncedThroughhat sich in Firestore bewegt. Ein Lauf, der mit 200 antwortete und kein Wasserzeichen voranbrachte, hat nichts getan.Der Bucket enthält ein Objekt pro Seite mit Tinte, und seine Byte-Anzahl stimmt mit
ink.bytesdes Seitendokuments überein.sectionsSkippedInactiveist die Zahl, die du erwartest. Eine Anzahl, die jede gespiegelte Sektion abdeckt, bedeutet, dass die aktive Menge leer oder falsch getippt ist;unknownActiveNotebookIdsim selben Bericht sagt, welche.
Zwei logbasierte Zahlen sagen, ob die Überlappungsfenster die richtige Breite haben. Keine ist dringend; lies sie, nachdem der Dienst eine Woche lang gelaufen ist.
# 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)'Beobachte den Backfill, indem du den Job erneut ausführst und done in aufeinanderfolgenden Berichten liest: false mit steigendem pagesUpdated, dann true. sync/state.backfillComplete sagt dasselbe, und es ist das Signal, den onenote-mcp-sync-Job zu löschen.
Achten Sie während eines Laufs im Anforderungsprotokoll auf eine 429. Während der Backfill-Job mit mehr als 400 Anforderungen pro Stunde läuft, ist eine 429 die erwartete Folge dieser Wahl, und die Abhilfe ist ein längerer Takt. Zu jeder anderen Zeit bedeutet sie, dass etwas das gemeinsame Anforderungs-Gate umgeht – das ist der Fehler, der das gesamte Konto drosseln lässt.
Status | Bedeutung | Was zu tun ist |
200, | Der Lauf hat seine Arbeit beendet | Nichts |
200, | Er wurde durch das Anforderungs- oder Zeitbudget gestoppt | Nichts; der nächste Lauf setzt fort |
401 | Das Secret fehlt oder ist falsch | Job korrigieren; die Route hat keine Arbeit geleistet |
404 |
| Setzen und neu bereitstellen |
409 | Ein anderer Lauf hält die Lease | Nichts; sie läuft nach 15 Minuten ab |
504 | Der Lauf hat Cloud Runs 300-Sekunden-Request-Timeout überschritten | Nichts; siehe unten |
503, | Das Microsoft-Refresh-Token ist weg |
|
503, | Firestore hat nicht geantwortet | Wiederholen; die Anmeldedaten sind in Ordnung |
Lesen daraus
MIRROR_READ_ENABLED ist der Schalter. Mit false – der Standardeinstellung – antwortet jedes Tool genau wie zuvor über Microsoft Graph, als ob der Mirror nicht existierte. Mit true laufen sieben Tools über die lokale Kopie: list_notebooks, list_sections, list_pages, list_pages_by_name, find_page_by_name, get_page_content und search_pages.
Jeder dieser Lesevorgänge besteht aus drei Schritten, in dieser Reihenfolge.
Aktualisierung. Das Tool führt selbst eine inkrementelle Synchronisierung durch – dieselbe
runIncremental, die auchPOST /syncausführt, mit kleineren Budgets: 12 OneNote-Anforderungen und 15 Sekunden. Sobald der Backfill-Job gelöscht ist, ist dies das Einzige, was überhaupt einen inkrementellen Durchlauf ausführt.Lokale Kopie lesen. Ein Treffer kostet keine OneNote-Anforderung. Ein Fehlschlag fällt auf Microsoft Graph zurück, genau wie zuvor.
Quelle melden.
sourceistonenote, wenn die Antwort dem entspricht, was OneNote enthält,best-available, wenn ein Teil davon aus einem Notebook stammt, das der Betreiber eingefroren hat, undmirror, wenn sie veraltet sein könnte.
Die vollständige source-Regel. „Bestätigt" bedeutet, dass die Aktualisierung in Schritt 1 abgeschlossen wurde oder die Antwort eine einzelne Seite ist, die kein Schreibvorgang als veraltet markiert hat:
Situation |
|
Der Lesevorgang fiel auf Microsoft Graph zurück |
|
Lokaler Treffer aus einem aktiven Notebook, bestätigt |
|
Lokaler Treffer aus einem aktiven Notebook, nicht bestätigt |
|
Lokaler Treffer aus einem inaktiven Notebook, bestätigt oder nicht |
|
Eine unbegrenzte Suche über beide, bestätigt |
|
Eine unbegrenzte Suche über beide, nicht bestätigt |
|
Eine Aktualisierung ist „abgeschlossen", wenn sie den Notebook-Baum gelesen, jeden benötigten Abschnitt besucht, jede benötigte Seite abgerufen und keines der Budgets ausgeschöpft hat. Alles andere – das Anforderungs- oder Zeitbudget lief ab, der Baum-Lesevorgang gab 500 zurück, ein Seitenabruf schlug fehl, der Scheduler hielt die Sync-Lease, Firestore antwortete nicht – ist mirror, und die Antwort trägt mirroredAt mit dem Zeitpunkt, zu dem die Kopie erstellt wurde. get_page_content ist die Ausnahme in den ersten drei Zeilen: Ein Seitendokument trägt sein eigenes Veraltungs-Flag, und jeder Schreibvorgang markiert seine Seite als veraltet, bevor er OneNote berührt, sodass ein Treffer dort eine Seite ist, die nichts überholt hat.
best-available ist keine herabgestufte Antwort und wird nicht zu mirror, wenn eine Aktualisierung fehlschlägt: Diese Aktualisierung hätte ein inaktives Notebook ohnehin nie geprüft, sodass die Meldung einen Fehler beschreiben würde, der die Antwort nicht hätte ändern können. Was es aussagt, ist, dass die Kopie nur durch einen Schreibvorgang über diesen Server oder durch /sync/sweep/all erneut geprüft wird. list_notebooks meldet pagesActive pro Notebook, und eine unbegrenzte search_pages meldet inactiveNotebooks neben notebooksSearched, sodass ein Modell sehen kann, wie groß der Übersprung war. list_notebooks und list_sections melden nie best-available – die Struktur wird bei jedem Lauf für das gesamte Konto gespeichert, unabhängig davon, was der aktive Satz sagt.
Eine Aktualisierung läuft nicht bei jedem Aufruf. Eine abgeschlossene gilt 30 Sekunden, eine unvollständige 5 Minuten, sodass ein Schub von Tool-Aufrufen eine Aktualisierung bezahlt statt dreißig. Der schlimmste Fall sind etwa 120 zusätzliche OneNote-Anforderungen pro Stunde aus einem durchgehend beschäftigten Gespräch auf einem ruhigen Konto, gegenüber dem Limit von 400 – und eine Aktualisierung auf einem ruhigen Konto ist eine einzelne Anforderung, der Notebook-Baum.
Es gibt kein Pro-Aufruf-Argument, um einen Live-Lesevorgang zu erzwingen. Es gab eines (useLiveData); es ist weg, weil ein Lesevorgang, der zuerst aktualisiert und dann sagt, ob die Aktualisierung abgeschlossen wurde, dieselbe Frage beantwortet, ohne ein Schema-Feld, über das das aufrufende Modell nachdenken muss.
Das Ausschalten ist ein vollständiger Rollback: Setzen Sie die Variable auf false, stellen Sie neu bereit, und die Tools sind byte-identisch zu ihrem Verhalten vor dem Mirror. Keine Aktualisierung läuft, kein Tool-Aufruf gibt eine Anforderung für eine Synchronisierung aus, und jede Antwort sagt source: "onenote". Keine Datenmigration, und der Mirror füllt sich weiterhin im Hintergrund.
Schreibvorgänge synchronisieren ihre Seite sofort neu. Alle fünf Schreib-Tools lesen die Seite nach einem erfolgreichen Schreibvorgang erneut aus OneNote und speichern sie, sodass ein get_page_content direkt nach einem append_to_page aus der lokalen Kopie mit dem gerade hinzugefügten Text antwortet. Das kostet eine zusätzliche OneNote-Anforderung pro Schreibvorgang. Wenn die Neusynchronisierung fehlschlägt, wird die Seite stattdessen als veraltet markiert, was den nächsten Lesevorgang zu OneNote schickt – langsamer, aber nie falsch – und die Aktualisierung vor einem späteren Lesevorgang repariert es in beiden Fällen. Ein fehlgeschlagener Neusync schlägt den Schreibvorgang nie fehl.
Zwei Dinge, die Sie prüfen sollten, bevor Sie es einschalten:
Alle vier zusammengesetzten Indizes lesen
READY. Eine Abfrage gegen einen nochCREATING-Index schlägt mitFAILED_PRECONDITIONfehl.sync/state.backfillCompleteisttrue. Es früher einzuschalten ist sicher – eine nicht gespiegelte Seite ist ein Fehlschlag und geht zu Graph – aber die meisten Lesevorgänge würden durchfallen, was langsamer ist, als den Mirror gar nicht zu haben, und Budget verbraucht, das der Backfill benötigt.
Die eine Verhaltensänderung, die man kennen sollte, ist search_pages. Von Graph beantwortet, durchläuft es Abschnitte und stoppt bei 60 davon oder nach 25 Sekunden, sodass es sectionsSearched, sectionsFound und stoppedEarly meldet. Vom Mirror beantwortet gibt es keinen Durchlauf und keine Grenze – aber Seiteninhalte werden nur für die Notebooks in Ihrer Auswahl gehalten, sodass es stattdessen notebooksSearched gegen notebooksInAccount meldet und dies in seiner Notiz erwähnt, wenn sie abweichen. Ein Modell, das „keine Treffer" liest, muss wissen, welche davon es erhalten hat. Um ein Notebook außerhalb der Auswahl zu durchsuchen, übergeben Sie sectionId: Das verfehlt die lokale Kopie und durchläuft den einen Abschnitt in OneNote.
Wenn ein Lauf überzieht
MIRROR_SYNC_REQUEST_BUDGET und das 240-Sekunden-Wall-Clock-Budget stoppen einen Lauf beide deutlich innerhalb von Cloud Runs 300-Sekunden-Request-Timeout, sodass die normale Antwort eine 200 mit "done": false ist. Eine 504 bedeutet, dass etwas länger gedauert hat als erwartet – fast immer eine 429 von OneNote mit einem langen Retry-After.
Was dann passiert, ist wissenswert. Der Dienst ist mit CPU-Drosselung und ohne Mindestinstanzanzahl bereitgestellt, sodass eine abgeschnittene Anforderung einem von zwei Schicksalen begegnet und Cloud Run keines verspricht: Wenn die Instanz noch warm ist, wird der Prozess suspendiert und setzt fort, wenn die nächste Anforderung eintrifft, und wenn sie auf null skaliert wurde, ist die Arbeit einfach weg. In beiden Fällen beendet der Lauf nicht, seine Lease wird nicht freigegeben, und Wiederholungen antworten mit 409, bis die Lease 15 Minuten später abläuft. Der nächste Lauf – der Backfill-Job, der nächtliche Sweep oder die Aktualisierung vor dem nächsten abgedeckten Lesevorgang – läuft dann normal weiter.
In beiden Fällen geht nichts verloren. Wasserstände schreiten pro Abschnitt voran und erst nachdem die Seiten dieses Abschnitts gespeichert sind, sodass ein abgeschnittener Lauf alles behält, was er festgeschrieben hat, und der nächste Lauf von dort fortfährt. Dasselbe gilt für eine Instanz, die zwischen Läufen einfach verschwindet: Der Mirror, das Microsoft-Refresh-Token und jedes Token, das dieser Server ausgibt, werden gespeichert oder signiert, nicht im Speicher gehalten, sodass eine Instanz jederzeit verschwinden kann, ohne mehr als einen Token-Roundtrip zu kosten.
Das Einzige, was Sie bei wiederholten 504ern prüfen sollten, ist das Anforderungsprotokoll auf 429er. Eine anhaltende Drosselung ist der einzige realistische Weg, das Timeout zu erreichen, und die Abhilfe ist ein kleineres MIRROR_SYNC_REQUEST_BUDGET oder ein längeres Intervall, nicht ein längeres Cloud-Run-Timeout.
Wiederherstellung nach einem schlechten Mirror
Löschen Sie die Unterkollektionen notebooks, sectionGroups, sections, pages, pageContent und tombstones von MIRROR_ROOT_DOC und leeren Sie structureHash und sectionsScannedThrough in sync/state. Erstellen Sie dann den Job onenote-mcp-sync aus Einrichtung neu und lassen Sie ihn, bis backfillComplete wieder true ist: Die Inline-Aktualisierung vor einem Lesevorgang hat ein Budget von 12 Anforderungen und wird einen Mirror nicht aus dem Nichts neu aufbauen. Das Auswahldokument und der Token-Cache bleiben davon unberührt, und kein Graph-Schreibvorgang ist beteiligt – der Mirror ist eine Kopie, und Microsoft Graph bleibt die Quelle der Wahrheit.
Alarmierung
Zwei Fehler sind ohne eine logbasierte Metrik unsichtbar, weil beide nur als Nachricht in einer Claude-Konversation oder als Zeile erscheinen, die niemand liest:
Ereignis | Bedeutung |
| Die Microsoft-Genehmigung ist tot. Jemand muss |
| MSAL hat einen Cache ohne Anmeldedaten übergeben. Die gespeicherte Kopie hat überlebt; etwas stimmt nicht. |
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"'Dann eine Alarmrichtlinie auf dieser Metrik über Null. Eine Zustimmungsgenehmigung ist ebenfalls zu beobachten: POST /consent mit Antwort 302 sollte nur passieren, wenn Sie den Connector hinzufügen, und das Anforderungsprotokoll trägt es bereits.
jsonPayload.event="request" jsonPayload.path="/consent" jsonPayload.status=302Bootstrap
npm run bootstrap ist die einzige interaktive Microsoft-Anmeldung im Projekt, und sie läuft auf Ihrem Rechner, nicht auf Cloud Run. Sie meldet sich mit dem Gerätecode-Flow an und schreibt den resultierenden MSAL-Cache in das Firestore-Dokument, das der Server liest.
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 bootstrapEs gibt Microsofts Gerätecode-Nachricht aus, wartet darauf, dass Sie in einem Browser zustimmen, listet dann einmal Ihre Notebooks auf und gibt die Anzahl als Beweis aus, dass das Token funktioniert. Die Schlusszeilen nennen das Firestore-Projekt und das geschriebene Dokument sowie den Heimandanten des Kontos, sodass Sie sehen können, dass Sie sich im richtigen Verzeichnis angemeldet haben. Diese Ausgabe trägt die Mandanten-ID; halten Sie sie von Issues, Pull Requests und Workflow-Protokollen fern.
GOOGLE_CLOUD_PROJECT und FIRESTORE_CACHE_DOC sind hier erforderlich, anders als auf dem Server, wo das erste abgeleitet und das zweite standardmäßig gesetzt wird. Die CLI schreibt mit Ihren eigenen Application Default Credentials, sodass ein nicht gesetzter Wert ein echtes Dokument in dem Projekt anlegen würde, auf das Ihr gcloud-Login zeigt, und trotzdem eine Erfolgszeile ausgeben würde. Die MCP_OAUTH_*-Werte werden nicht gelesen, sodass das Ausführen dieses Skripts das Layer-1-Client-Secret nie auf Ihren Rechner bringt.
Führen Sie es erneut aus, wenn ein graph-auth-failure-Ereignis mit retryable: "false" in den Serverprotokollen erscheint. Das Refresh-Token wird bei jeder Verwendung rotiert und stirbt, wenn der Dienst etwa 90 Tage lang im Leerlauf bleibt; es gibt keine automatische Wiederherstellung. Die Konfiguration des Keepalive-Jobs oben ist das, was verhindert, dass Leerlauf einer der Wege dorthin ist.
Container
Der Dienst wird auf Cloud Run bereitgestellt, das linux/amd64 ausführt. Das Image wird explizit für diese Plattform gebaut, damit das native Binary von @resvg/resvg-js passt. Die Laufzeitbasis ist Debian node:24-slim und darf nicht Alpine werden — das resvg-Prebuild ist nur für glibc verfügbar.
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 antwortet mit demselben Inhalt und wird von Cloud Runs eigenen Probes verwendet. Rufen Sie es nicht von außen auf: Das Google-Frontend beantwortet https://<service>.run.app/healthz mit einer eigenen 404-Seite, und die Anfrage erreicht den Container nie. Ein externer Uptime-Check muss daher /health verwenden. Gemessen am bereitgestellten Dienst am 2026-08-19 — /health, /healthz2 und sogar /Healthz kommen alle an, und nur das exakt kleingeschriebene /healthz wird verschluckt.
Diese Werte sind Platzhalter, die nur gut genug geformt sind, um die Startvalidierung zu bestehen; sie authentifizieren sich gegen nichts. Auf Cloud Run wird PORT von der Plattform bereitgestellt, der Rest kommt aus dem Deploy-Workflow. Wenn Host-Port 8080 bereits belegt ist, mappen Sie einen anderen: -p 8081:8080 mit -e PORT=8080.
So testen Sie das Image:
RUN_DOCKER_TESTS=1 bash scripts/test/run.shDas baut das Image, prüft, dass das resvg-glibc-Binary den Production-only-Install überlebt hat und keine Dev-Abhängigkeiten mitgekommen sind, rendert ein SVG zu PNG im Container und stellt fest, dass /healthz auf dem in PORT angegebenen Port mit 200 antwortet. Ohne RUN_DOCKER_TESTS=1 wird die Docker-Suite übersprungen und der Rest läuft trotzdem.
Deploy
.github/workflows/deploy.yml läuft bei jedem Push auf main und bei workflow_dispatch. Er führt die Typprüfung aus, führt npm test aus, baut, baut und pusht das Container-Image in Artifact Registry, getaggt mit dem Commit-SHA, und stellt dieses Image auf Cloud Run bereit. Eine fehlgeschlagene Typprüfung oder ein fehlgeschlagener Test stoppt den Lauf, bevor ein Image gebaut wird.
Es gibt keine langlebige Anmeldeinformation in GitHub. Der Job authentifiziert sich über Workload Identity Federation: permissions: id-token: write erlaubt es ihm, ein GitHub-OIDC-Token anzufordern, und google-github-actions/auth@v2 tauscht dieses gegen kurzlebige Google-Anmeldeinformationen. Es wird kein Service-Account-JSON-Schlüssel von scripts/gcp-bootstrap.sh erstellt und nirgendwo benötigt. Der Provider akzeptiert nur Token, deren repository-Anspruch dieses Repository ist.
Das Image wird auf ubuntu-latest gebaut, also linux/amd64 — die Plattform, auf der Cloud Run läuft, und die, für die das Prebuild von @resvg/resvg-js kompiliert ist. Deshalb baut der Workflow das Image selbst, statt gcloud run deploy --source zu verwenden, was auch bedeuten würde, Cloud Build zu aktivieren und die dazugehörigen Rollen zu gewähren.
Der Deploy läuft mit --max-instances=1 und --allow-unauthenticated, als Laufzeit-Service-Account, der roles/datastore.user für den Firestore-Token-Cache hält. --allow-unauthenticated ist es, das Claude überhaupt Zugriff auf den Dienst ermöglicht; der MCP-Endpunkt wird stattdessen durch das Bearer-Token geschlossen. Siehe Bearer-Tokens auf dem MCP-Endpunkt.
Was das Repository enthalten muss
scripts/gcp-bootstrap.sh richtet die GCP-Seite ein und gibt die gh variable set-Befehle für die ersten sechs aus. Der Workflow schlägt in seinem ersten Schritt fehl und benennt, was fehlt, statt halb konfiguriert bereitzustellen.
Name | Art | Wert |
| Variable | Projekt-ID |
| Variable | Cloud-Run-Region |
| Variable | Artifact-Registry-Region |
| Variable | Vollständiger Ressourcenname des Workload-Identity-Providers |
| Variable | E-Mail des Deploy-Service-Accounts |
| Variable | E-Mail des Laufzeit-Service-Accounts |
| Variable | Client-ID der Azure-App-Registrierung |
| Variable | Entra-Autoritäts-URL |
| Variable | OAuth-Client-ID der Schicht 1 |
| Variable | Die öffentliche URL des Dienstes; siehe unten |
| Variable | Optional; Standardwert |
| Geheimnis | OAuth-Client-Geheimnis der Schicht 1 |
| Geheimnis | Signaturschlüssel für Zugriffstokens, mindestens 32 Zeichen |
| Geheimnis | Optional; nicht gesetzt bedeutet, dass |
| Variable | Optional; der Cloud-Storage-Bucket des Seiten-Mirrors. |
| Variable | Optional; Standardwert |
| Variable | Optional; Standardwert |
| Variable | Optional; Standardwert |
| Geheimnis | Optional; nicht gesetzt bedeutet, dass |
Nur vier davon sind Anmeldeinformationen. Der WIF-Providername und die Service-Account-E-Mails sind Identifikatoren, die für jeden nutzlos sind, der nicht die OIDC-Identität dieses Repositorys vorweisen kann, also sind sie Variablen statt Geheimnisse.
Der Deploy übergibt env_vars_update_strategy: overwrite, sodass die Liste im Workflow bei jeder Revision die gesamte Umgebung des Dienstes ist. Der Standardwert der Aktion ist merge, wobei eine aus dem Workflow entfernte Variable stillschweigend aus der vorherigen Revision überleben würde. PORT und GOOGLE_CLOUD_PROJECT sind bewusst nicht in der Liste: Cloud Run stellt beide bereit und lehnt PORT als Eingabe ab.
Der erste Deploy und MCP_PUBLIC_URL
MCP_PUBLIC_URL ist der OAuth-Issuer und die Audience jedes Zugriffstokens, das dieser Server ausstellt, und es existiert kein Dienst, der eine URL hätte, bis der erste Deploy stattgefunden hat. Der Workflow löst es in drei Schritten auf: die Repository-Variable MCP_PUBLIC_URL, dann die URL, die Cloud Run dem Dienst bereits zugewiesen hat, dann — nur wenn keines von beiden existiert — https://placeholder.invalid, das er unmittelbar nach dem Deploy durch die echte URL ersetzt. Ein erster Lauf funktioniert also unbeaufsichtigt und endet mit dem korrekten Wert an Ort und Stelle. Er hinterlässt eine Warnung, die die URL benennt; setzen Sie die Repository-Variable darauf, denn das ist die einzige der drei Quellen, die das Anbringen einer benutzerdefinierten Domain vor dem Dienst überlebt.
Das Ändern von MCP_PUBLIC_URL invalidiert für sich genommen nichts, aber jedes bereits ausgestellte Zugriffstoken ist an die alte Audience gebunden und wird abgelehnt. Claude führt in diesem Fall den Autorisierungsfluss erneut aus.
Rollback
Das Image-Tag ist der Commit-SHA, sodass ein früheres Image noch in Artifact Registry liegt:
gcloud run services update-traffic onenote-mcp --region "$GCP_REGION" --to-revisions <revision>=100Den Workflow von einem früheren Commit mit workflow_dispatch erneut auszuführen, funktioniert ebenfalls und ist die Methode, die die bereitgestellte Umgebung mit der Workflow-Datei dieses Commits in Schritt hält.
Konfiguration
Jeder Wert stammt aus einer Umgebungsvariable und wird beim Start validiert. Eine fehlende oder fehlerhafte Variable erzeugt einen ConfigError, der alles auflistet, was auf einmal falsch ist, und der Prozess beendet sich mit Exit-Code 1 ohne Stack-Trace.
Variable | Erforderlich | Standard | Zweck |
| ja | — | Azure-App-Registrierungs-Client-ID (öffentlicher Client) |
| ja | — | Entra-ID-Autoritäts-URL für den Mandanten |
| ja | — | Layer-1-OAuth-Client-ID, die Claude präsentiert |
| ja | — | Layer-1-OAuth-Clientgeheimnis |
| ja | — | Schlüssel zum Signieren ausgestellter Zugriffstokens (mind. 32 Zeichen) |
| ja | — | Die eigene öffentliche URL des Dienstes: |
| Server: nein · Bootstrap: ja |
| Firestore-Dokumentpfad, der den MSAL-Tokencache enthält |
| Server: nein · Bootstrap: ja | — | GCP-Projekt; wird auf Cloud Run automatisch abgeleitet |
| nein |
| Bindungsport. Cloud Run legt diesen fest; der Server setzt keinen fest. |
| nein | — | Mindestens 32 Zeichen. Wenn gesetzt, wird |
| nein |
| Firestore-Dokument mit den manuell bearbeiteten Listen |
| nein | — | Mindestens 32 Zeichen. Wenn gesetzt, wird |
| bedingt | — | Cloud-Storage-Bucket für gerenderte Tinte und übergroßes Seiten-HTML. Erforderlich, sobald |
| nein |
|
|
| nein |
| Graph-Anfragen, die ein Synchronisierungslauf ausgeben darf, bevor er stoppt und weitere ausstehende meldet. 10–350, bei einem Stundenlimit von 400. |
Die fünf MIRROR_*-Variablen konfigurieren den Firestore-Seitenspiegel. Alle sind optional, sodass ein Dienst, der ohne eine von ihnen bereitgestellt wird, sich genau so verhält wie vor der Existenz des Spiegels — genau das macht MIRROR_READ_ENABLED zu einem vollständigen Rollback-Schalter und nicht zu einer Codeänderung.
MIRROR_BUCKET ist die einzige feldübergreifende Regel in loadConfig. Ein VarSpec gibt pro Variable an, ob sie erforderlich ist oder nicht, und kann nicht sagen „erforderlich, wenn eine andere vorhanden ist", aber eine Synchronisierung hat ohne Bucket keinen Ort, an dem sie ein gerendertes Tinten-PNG ablegen kann, und ein Spiegel-Lesezugriff hat keinen Ort, von dem er eines abrufen kann. Das Setzen von MIRROR_SYNC_SECRET oder das Setzen von MIRROR_READ_ENABLED auf true ohne Bucket schlägt daher beim Containerstart mit MIRROR_BUCKET in der Liste der fehlenden Variablen fehl — statt Stunden nach Beginn einer Backfill beim ersten Objektschreiben.
MIRROR_READ_ENABLED akzeptiert nur true und false, ohne Beachtung der Groß-/Kleinschreibung. Es ist bewusst nicht nachsichtig: 1, yes und on lesen sich für einen Menschen alle als wahr, und ein Parser, der sie akzeptierte, müsste auch entscheiden, was off und 0 bedeuten — an diesem Punkt wird ein Tippfehler wie ture zu false und schaltet den Spiegel ab, ohne etwas dazu zu sagen.
FIRESTORE_CACHE_DOC benennt das Dokument, das das MSAL-Cache-Plugin in src/token-cache.ts liest und schreibt. Sein Wert muss ein Dokumentpfad sein, also eine gerade Anzahl von durch Schrägstriche getrennten Segmenten; loadConfig lehnt einen Sammlungspfad beim Start ab.
ONENOTE_CLIENT_ID und ONENOTE_AUTHORITY identifizieren die Azure-App-Registrierung, die src/graph-auth.ts gegenüber Entra ID präsentiert. Es ist ein öffentlicher Client, daher gibt es bewusst kein Layer-2-Clientgeheimnis; die MCP_OAUTH_*-Werte darunter gehören zu Layer 1, zwischen Claude und diesem Server, und stehen in keinem Zusammenhang.
MCP_PUBLIC_URL ist die URL, unter der Claude diesen Dienst erreicht. Der OAuth-Aussteller, die resource-Kennung, an die ein Token gebunden ist, und die URL des Metadaten-Dokuments für geschützte Ressourcen werden alle daraus aufgebaut. Nichts auf Cloud Run teilt dem Prozess mit, unter welcher URL er erreicht wird, und ein aus dem Host-Header entnommener Wert wäre das, was der Aufrufer gesendet hat, daher wird er konfiguriert. Er kann erst ausgefüllt werden, nachdem die erste Bereitstellung die URL erzeugt hat.
npm run bootstrap liest nur ONENOTE_CLIENT_ID, ONENOTE_AUTHORITY, FIRESTORE_CACHE_DOC und GOOGLE_CLOUD_PROJECT — nicht die MCP_OAUTH_*-Werte — und es verlangt die letzten beiden, statt sie zu standardisieren. Siehe Bootstrap.
Repository-Hygiene
Dieses Repository ist öffentlich. Committen Sie keine echten Seiteninhalte, gerenderte Tinte, Entra-Mandantennamen oder -IDs oder Firestore-Dokumentinhalte. .gitignore schließt output/ und die Token-Cache-Dateimuster aus; siehe den Abschnitt „Repo-Hygiene" in project-spec.md.
This server cannot be deployed
Maintenance
Related MCP Connectors
Manage Microsoft 365 email, calendar, contacts and inbox rules via the Graph API with OAuth 2.0.
Permissioned access to Outlook, OneDrive and Teams via the user's own Microsoft account
Microsoft OneNote (Microsoft 365) MCP Pack
MCP-native open-source Notion alternative: read & write pages, databases and kanban boards.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables AI language models to interact with Microsoft OneNote via a standardized interface, supporting notebook and page management through natural language.21 npm27MIT
- AlicenseNot gradedqualityDmaintenanceEnables interaction with Microsoft OneNote via the Microsoft Graph API, allowing users to list notebooks and retrieve page content. It supports both personal and organization notebooks with credential caching for efficient authentication.21 npm3MIT
- AlicenseDqualityCmaintenanceEnables AI assistants to securely interact with Microsoft OneNote data through the Microsoft Graph API. It supports comprehensive management tasks including searching page content, creating and editing notes, and automating productivity workflows like daily note creation.203MIT
- AlicenseNot gradedqualityFmaintenanceEnables natural language access to Microsoft OneNote notebooks, sections, and pages for reading and listing content.53MIT