onenote-mcp
onenote-mcp
Un servidor MCP que expone Microsoft OneNote a través de Microsoft Graph: la estructura de blocs de notas y secciones, el contenido de las páginas y la escritura a mano renderizada a una imagen que el modelo que llama puede leer.
project-spec.md es el documento de diseño autoritativo. Cubre el pipeline de reconstrucción de tinta, las dos capas OAuth independientes, el modelo de despliegue en Cloud Run y la caché de tokens respaldada por Firestore. Léelo antes de cambiar cualquier cosa aquí.
Requisitos
Node >= 24. @google-cloud/firestore requiere Node >= 22, y Node 24 es la LTS activa actual.
Related MCP server: OneNoteMCP
Inicio rápido
npm ci
npm run build
npm testScripts
Script | Qué hace |
| Compila |
| Comprueba tipos sin emitir |
| Ejecuta el servidor desde el código fuente con |
| Ejecuta el servidor compilado desde |
| Inicio de sesión local con código de dispositivo que siembra la caché de tokens de Firestore |
|
|
Las pruebas viven en test/ y reflejan src/. Se ejecutan directamente contra el código fuente de TypeScript usando el despojado de tipos nativo de Node, por lo que npm test no necesita una compilación. Eso restringe el código fuente: sin enum, sin namespace, sin propiedades de parámetros de constructor, y las importaciones solo de tipo deben escribirse import type. Las opciones del compilador erasableSyntaxOnly y verbatimModuleSyntax lo hacen cumplir.
Consulta CLAUDE.md para la estructura de directorios y las convenciones asociadas.
Caché de tokens
src/token-cache.ts implementa ICachePlugin de MSAL contra un único documento de Firestore, cuya ruta proviene de FIRESTORE_CACHE_DOC. beforeCacheAccess lee el campo cache del documento y entrega la cadena a MSAL. afterCacheAccess escribe la caché serializada de vuelta dentro de una transacción de Firestore, y solo cuando MSAL informa que la caché cambió. Un documento que no existe se lee como una caché vacía, que es el estado antes de ejecutar npm run bootstrap. Ambos puntos de entrada usan este mismo plugin: el CLI de bootstrap escribe la caché a través de él y el servidor lee a través de él, por lo que hay un solo serializador y no hay un segundo formato que mantener sincronizado.
Ese blob es la única copia del token de actualización, por lo que dos cosas lo protegen.
Se rechaza una escritura que vaciaría el documento. MSAL elimina credenciales de su caché en memoria en algunos fallos, y afterCacheAccess se ejecuta dentro del bloque finally de MSAL, por lo que una serialización que ha perdido la cuenta puede llegar a este código mientras la almacenada sigue siendo válida. overwriteWouldEmptyCache lo detiene y registra {"event":"token-cache-write-refused"}. La comprobación de vacío no lee ningún nombre de clave de MSAL: una caché está vacía cuando se analiza como un objeto cuyo cada valor es un contenedor vacío, por lo que no puede invertirse cuando MSAL cambia su formato, y cualquier cosa que no reconozca se permite en lugar de bloquearse.
El blob que cada escritura reemplaza se guarda en un campo previousCache. Una generación, no un historial: la caché se reescribe en cada actualización, y la copia útil es siempre la más reciente buena. Recuperarse de una escritura mala es copiar ese campo sobre cache en la consola de Firestore, lo cual vale la pena porque la alternativa es un inicio de sesión con código de dispositivo. Activa la recuperación en un punto en el tiempo para una segunda capa:
gcloud firestore databases update --enable-pitrUn fallo de backend no es un fallo de credenciales. Que Firestore sea inalcanzable, o que se revoque un enlace roles/datastore.user, lanza TokenCacheUnavailableError en lugar de aparecer como el error que produce un token de actualización muerto. Las escrituras se reintentan tres veces antes de eso. Consulta la fila cache-unavailable en la tabla siguiente para ver por qué la distinción vale el código.
npm test cubre solo readCache, la función que decodifica una instantánea de documento. Los dos callbacks, la transacción y createFirestoreTokenCachePlugin no tienen prueba automatizada: necesitan un backend de Firestore. Ejercitarlos significa el emulador, que necesita java en PATH y una instalación propia:
sudo apt-get install google-cloud-cli-firestore-emulatorgcloud components install cloud-firestore-emulator no lo instala en una CLI de Google Cloud empaquetada con Debian. El administrador de componentes está deshabilitado en esa compilación, y gcloud imprime el comando apt-get anterior en su lugar.
Autenticación de Graph
src/graph-auth.ts convierte la caché de tokens sembrada en un token de acceso de Microsoft Graph. createGraphAuth construye una PublicClientApplication a partir de ONENOTE_CLIENT_ID, ONENOTE_AUTHORITY y el plugin de caché de Firestore, y la mantiene durante la vida del proceso. getAccessToken() lee la cuenta en caché, llama a acquireTokenSilent y devuelve el token. Los ámbitos solicitados son Notes.Read y Notes.ReadWrite, completamente calificados.
El servidor desplegado nunca inicia sesión de forma interactiva. No tiene forma de solicitar a nadie, y los endpoints de OneNote de Graph no admiten autenticación solo de aplicación, por lo que no hay respaldo cuando el token de actualización almacenado muere: un humano vuelve a ejecutar npm run bootstrap. Por lo tanto, cada fallo es un GraphAuthError que lo dice, en lugar de un error MSAL crudo que llegaría al llamador como un 401 desnudo de Graph:
| Qué sucedió | Qué hacer |
| El documento de Firestore está ausente, o su campo |
|
| Firestore no respondió, o la cuenta de servicio en tiempo de ejecución perdió | Reintentar. No es un inicio de sesión. |
| La caché se leyó pero no contiene una cuenta con sesión iniciada |
|
| El token de actualización almacenado está caducado o revocado, o el endpoint de token no devolvió nada utilizable |
|
cache-unavailable es la fila que se gana su lugar. Firestore se lee y escribe dentro de acquireTokenSilent, a través del plugin de caché, por lo que una interrupción del backend solía llegar como el mismo rechazo que produce un token de actualización muerto, y ese mensaje le dice al operador que vaya a un navegador y reemplace una credencial que está funcionando. GraphAuthError.retryable lleva la distinción y solo esa razón la establece.
Cada uno de estos también escribe una línea en stderr:
{"event":"graph-auth-failure","reason":"silent-failed","documentPath":"tokencache/msal","retryable":"false"}Esa línea es el punto. Un fallo de herramienta de otro modo aparece solo dentro de una conversación de Claude, por lo que sin ella nada le dice al operador que el conector ha dejado de funcionar. Consulta Alertas más abajo.
Los mensajes nombran la ruta del documento y el error MSAL subyacente, y deliberadamente no llevan identificador de cuenta: username es el UPN del usuario y homeAccountId incrusta el id de inquilino, ninguno de los cuales pertenece a un registro.
npm test cubre la lógica de adquisición a través de un cliente falso. createGraphAuth en sí no tiene prueba automatizada: necesita una caché sembrada por un inicio de sesión real con código de dispositivo, y ninguna credencial que pueda sembrar una puede ser confirmada. Ejecuta npm run bootstrap y luego el servidor contra el mismo documento para ejercitarlo. Su consumidor es el cliente de estructura de Graph a continuación; nada los conecta aún a createApp.
Estructura de Graph
src/graph-structure.ts lee el árbol de OneNote: blocs de notas, grupos de secciones, secciones y la lista de páginas dentro de una sección. new GraphStructure(auth) toma cualquier cosa con un getAccessToken(), por lo que el servidor le pasa el GraphAuth anterior.
Método | Devuelve |
| Cada bloc de notas, por nombre para mostrar |
| Secciones directamente bajo un bloc de notas o grupo de secciones |
| Grupos de secciones directamente bajo un bloc de notas o grupo de secciones |
| Ambos de los anteriores, obtenidos juntos |
| Páginas en una sección, más recientemente modificadas primero, como máximo |
| Un bloc de notas con cada grupo de secciones anidado resuelto |
| Cada bloc de notas, cada uno con su árbol resuelto |
| Cada bloc de notas con sus secciones y un nivel de grupo de secciones, en una sola solicitud |
| Secciones en cualquier parte de la cuenta cuyo nombre contenga ese texto, cada una con su bloc de notas y grupo de secciones padre |
| Páginas en una sección cuyo título coincida, comparado sin distinción de mayúsculas por Graph |
containerKind es notebooks o sectionGroups: los dos nombres de relación de Graph. Ambos tipos de contenedor exponen las mismas relaciones hijas, por lo que los métodos de lista toman el tipo en lugar de existir dos veces.
getExpandedTree() es el barato. Le pide a Graph que expanda las relaciones en lugar de recorrerlas:
GET /me/onenote/notebooks?$select=id,displayName
&$expand=sections($select=id,displayName),
sectionGroups($select=id,displayName;$expand=sections($select=id,displayName))Medido contra una cuenta de 54 blocs de notas: una solicitud y 78 KB, frente a 195 solicitudes para getFullTree(), lo cual importa porque OneNote permite 400 solicitudes por hora y 5 concurrentes. El $select dentro de cada cláusula de expansión es lo que reduce la respuesta de 441 KB a 78 KB, y el separador dentro de una cláusula que lleva tanto $select como $expand es un punto y coma. Lo que no alcanza es un grupo de secciones anidado dentro de otro grupo de secciones: Graph limita el anidamiento de $expand a dos niveles, por lo que findSectionsByName cubre ese caso en una sola solicitud en su lugar, filtrando la lista de secciones de toda la cuenta y expandiendo los padres de cada sección.
api-overview.md registra lo que estos endpoints aceptan, incluidos los lugares donde el servicio contradice su propia documentación.
Tres cosas que el recorrido maneja y que una sola llamada a Graph no:
Anidamiento. Los grupos de secciones son los "grupos de pestañas" de la interfaz, y contienen más grupos de secciones.
getNotebookTreerecurre.Paginación. Cada llamada de lista sigue
@odata.nextLinkhasta que deja de aparecer. Graph elige su propio tamaño de página e ignora un$topmayor, por lo que una respuesta nunca es prueba de que una colección esté completa.listPagesInSectionse detiene tan pronto como tienetopelementos en mano, por lo quetopes un recuento de resultados en lugar de un tamaño de página.La lista de páginas de toda la cuenta nunca se llama.
GET /me/onenote/pagesfalla con el error 20266, "máximo de secciones excedido", en una estructura de un bloc de notas por año. El listado de páginas siempre está limitado a/me/onenote/sections/{id}/pages, y una prueba escaneasrc/en busca de la ruta de toda la cuenta.
Los fallos son GraphRequestError para una respuesta no-2xx — lleva status, statusText y el body de la respuesta, porque el error 20266 solo se distingue de cualquier otro 400 por ese texto — y GraphResponseError para un 2xx cuyo cuerpo no tiene la forma esperada, una lista que no termina, o grupos de secciones anidados más de 20 niveles. Ningún mensaje contiene un nombre de bloc de notas, sección o página.
npm test lo impulsa todo a través de un fetch falso claveado por URL exacta. Lo que eso no puede comprobar es si Graph acepta esas URLs; las cadenas de consulta provienen del script de reconocimiento validado en el Apéndice A de project-spec.md y se confirman solo ejecutando contra el tenant real.
Tinta
El endpoint normal de contenido de página de Graph elimina la escritura a mano y deja <!-- InkNode is not supported --> detrás, y Graph no puede exportar una página como imagen o PDF. Por lo tanto, la escritura a mano se reconstruye a partir de los datos de trazos en bruto: GET /me/onenote/pages/{id}/content?includeInkML=true responde multipart/mixed, una parte el mismo HTML y otra el InkML. Los trazos se convierten en un SVG y luego en un PNG, que va al modelo llamante como imagen para que su propia visión la lea. No interviene ningún servicio de OCR.
Módulo | Qué hace |
|
|
|
|
|
|
Cuatro detalles deciden si esto funciona en absoluto, y los cuatro provienen del script de reconocimiento validado en el Apéndice A de project-spec.md:
Los espacios de nombres se eliminan. Graph emite
inkml:ink,inkml:trace,inkml:traceFormat.fast-xml-parserestá configurado conremoveNSPrefix: truey cada búsqueda usa el nombre desnudo.El orden de canales proviene de
<traceFormat>. Los puntos de esta cuenta son X, Y, F, donde F es la presión del lápiz. Leer los dos primeros números de cada punto dibuja la presión como coordenada.Las coordenadas son himétricas.
px = himetric * 96 / 2540. Ese es el mismo espacio de coordenadas en el que el HTML de la página posiciona el contenido escrito, por lo que la tinta y el contenido escrito podrían registrarse entre sí mediante aritmética.Los trazos están en cualquier parte del árbol. Una página puede llevar más de una raíz
<ink>, y los elementos<traceGroup>se anidan. Todos se recogen.
Una página sin tinta se renderiza como null. Esa es la respuesta normal para una página escrita, no un error. Los fallos que sí se lanzan son InkParseError para grupos de trazos anidados más de 50 niveles e InkRenderError para un documento que resvg rechaza; ningún mensaje reproduce parte del documento, porque las coordenadas de los trazos son la escritura a mano del usuario.
test/fixtures/*.inkml están escritos a mano — unos pocos trazos, orden de canales X/Y/F, unidades himétricas, un archivo con dos raíces <ink> y elementos <traceGroup> anidados. No se puede confirmar ningún volcado de página capturado: la tinta renderizada son notas personales totalmente legibles.
Endpoint MCP
El servidor habla MCP sobre HTTP Streamable sin estado en POST /mcp. Cada solicitud construye su propio servidor MCP, responde y lo desmonta; nada sobrevive a la siguiente. No hay id de sesión, y no hay SSE — GET /mcp y DELETE /mcp responden 405, y un POST responde con un cuerpo JSON en lugar de abrir un stream. Un stream abierto mantendría viva una instancia de Cloud Run y facturaría por tiempo inactivo.
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}Ambos tipos Accept son requeridos por la especificación Streamable HTTP aunque este servidor nunca transmite. createTools en src/tools.ts es el registro — seis herramientas de navegación (list_notebooks, list_sections, list_pages, search_pages, find_page_by_name, list_pages_by_name), una herramienta de lectura (get_page_content) y cinco herramientas de escritura (append_to_page, append_to_page_by_name, create_page, create_page_by_name, update_page_title) — y src/mcp-server.ts es la superficie JSON-RPC alrededor de ellas.
Una herramienta que lanza una excepción vuelve como resultado de herramienta con isError: true y un mensaje legible — un token de refresco caducado, una página que ya no existe y un documento que resvg rechaza son resultados normales, no fallos de protocolo. Solo una llamada a una herramienta que nunca fue registrada es un error JSON-RPC.
Cada solicitud escribe una línea de registro JSON: el verbo HTTP, la ruta, el estado, la duración, el método JSON-RPC y el nombre de la herramienta en un tools/call. Nunca la cadena de consulta, los encabezados, los argumentos o el resultado — ver src/logging.ts.
/mcp está cerrado detrás de un token de portador — ver Tokens de portador en el endpoint MCP. El endpoint de salud permanece abierto.
Descubrimiento OAuth
Claude tiene que encontrar el servidor de autorización antes de poder iniciar un flujo. src/oauth-router.ts monta el mcpAuthRouter del SDK en la raíz de la aplicación — construye sus rutas a partir de la URL del emisor en lugar de un punto de montaje, por lo que no puede ir detrás de un prefijo — y sirve cinco rutas, todas ellas sin autenticación por necesidad:
Ruta | Qué es |
| Metadatos del servidor de autorización RFC 8414 |
| Metadatos del recurso protegido RFC 9728 |
| Endpoint de autorización |
| Donde el formulario de consentimiento publica; el router |
| Endpoint de token |
curl -s localhost:8080/.well-known/oauth-authorization-server
curl -s localhost:8080/.well-known/oauth-protected-resource/mcpTodo en ambos documentos se deriva de MCP_PUBLIC_URL: es el emisor, y el identificador resource es esa URL más /mcp. MCP_PUBLIC_URL se rechaza al inicio si lleva una barra final, para que cada URL construida concatenando una ruta sea bien formada; el campo issuer entonces informa la forma normalizada de URL, que para un valor solo de origen es la misma cadena con una barra final añadida. El documento de recurso protegido se sirve solo en la URL con sufijo de ruta — la URL desnuda /.well-known/oauth-protected-resource es un 404, y también lo es /.well-known/openid-configuration. Claude sondea primero la ruta con sufijo.
scopes_supported lista offline_access, que es lo que hace que Claude pida un token de refresco en lugar de volver a consentir cada vez que un token de acceso caduca. No hay registration_endpoint: el id de cliente y el secreto están configurados, por lo que el Registro Dinámico de Clientes no tiene nada que hacer. Un cliente está registrado, con tres URIs de redirección — https://claude.ai/api/mcp/auth_callback para las superficies de Claude alojadas, y http://localhost/callback más http://127.0.0.1/callback para Claude Code, cuyo puerto se ignora según RFC 8252.
GET /authorize renderiza una página de consentimiento en lugar de redirigir: un botón Aprobar que nombra lo que se concede y el host al que se enviará el código de autorización. Aprobar publica de vuelta a POST /consent, que acuña un código de un solo uso de 60 segundos y redirige al callback del cliente. Toda la solicitud de autorización cruza esa página en un campo oculto firmado con MCP_TOKEN_SIGNING_KEY, por lo que un reemplazo de instancia a mitad de consentimiento no rompe el flujo y el formulario no puede editarse; un campo que falla la verificación es un 400 sin redirección y sin código acuñado.
Ambas respuestas de consentimiento llevan Cache-Control: no-store, Referrer-Policy: no-referrer — el formulario publica desde la URL /authorize, que tiene state y el desafío PKCE en su cadena de consulta — X-Frame-Options: DENY, y un CSP de default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'. Deliberadamente no hay form-action: los navegadores han discrepado sobre si se comprueba contra un destino de redirección, y el POST de consentimiento responde con una redirección a claude.ai.
POST /consent tiene su propio límite de tasa — 200 en 15 minutos — porque está montado deliberadamente antes del limitador /authorize del SDK y un formulario renderizado permanece publicable durante diez minutos, por lo que un viaje a través de /authorize produce un campo que puede reproducirse. El límite se sitúa por encima del tope de 100 códigos pendientes, de modo que la propia expulsión del almacén, cuyo comportamiento está especificado, es lo que una ráfaga encuentra primero.
POST /token emite un token de acceso válido por una hora y un token de refresco válido por 30 días. Ambos son un HMAC-SHA256 sobre un payload compacto bajo MCP_TOKEN_SIGNING_KEY y nada más — no se consulta ningún almacén para verificar uno, que es lo que evita que un reemplazo de revisión de Cloud Run fuerce una reconexión. El payload lleva la audiencia, que es MCP_PUBLIC_URL más /mcp, por lo que un token es válido para este endpoint MCP y para ningún otro. Cuánto viven esos tokens, y cómo hacer que un humano apruebe más a menudo, está dos secciones más abajo.
Tokens de portador en el endpoint MCP
Cada solicitud a /mcp necesita Authorization: Bearer <access token>. El requireBearerAuth del SDK se sitúa delante del router MCP en createApp, y verifyAccessToken en src/oauth-provider.ts es lo que llama: la firma HMAC bajo MCP_TOKEN_SIGNING_KEY, el tipo de token, la expiración y la audiencia. Un token que está correctamente firmado y no expirado pero lleva el identificador de recurso de otro servidor es rechazado — el SDK no comprueba ninguna audiencia propia, por lo que sin esa comprobación un token acuñado para un servidor MCP diferente por un servidor que comparte esta clave de firma sería aceptado.
Una solicitud sin token, con token caducado o con un token que falla cualquiera de esas comprobaciones es 401 con un encabezado de desafío:
WWW-Authenticate: Bearer error="invalid_token", error_description="…",
resource_metadata="https://<MCP_PUBLIC_URL>/.well-known/oauth-protected-resource/mcp"El parámetro resource_metadata es la parte que importa: es cómo Claude encuentra el servidor de autorización e inicia el flujo, por lo que un 401 sin él es un callejón sin salida en lugar de un aviso de inicio de sesión. Claude refresca reactivamente ante un 401 y proactivamente unos minutos antes de la expiración almacenada, por lo que un 401 aquí es un evento ordinario.
El token se lee del encabezado Authorization y de ningún otro lugar. ?access_token= en la cadena de consulta no se honra — la especificación de autorización MCP lo prohíbe, y src/logging.ts deja la cadena de consulta fuera de la línea de registro en virtud de eso.
Qué rutas están abiertas es la lista de exenciones, y es más larga que "todo excepto /mcp" porque todo el flujo de autorización tiene que responder a llamadores que aún no tienen token: /healthz y /health, ambos documentos .well-known, /authorize, /consent y /token. Una prueba en test/server.test.ts enumera las rutas que createApp realmente registra y afirma que cada una que no esté en esa lista responde 401 sin token, por lo que una ruta añadida más tarde queda cerrada a menos que alguien la abra deliberadamente.
No se requieren scopes. offline_access, el único scope que este servidor emite, trata sobre si se concede un token de refresco en lugar de sobre lo que un llamador puede hacer, y exigirlo respondería 403 para tokens que por lo demás son buenos. Si alguna vez se añade una comprobación de scope, el 403 tiene que llevar WWW-Authenticate: Bearer error="insufficient_scope" — que este middleware hace — porque Claude trata cualquier otro 403 como terminal y no solicita nada.
Duración del token y forzar la revalidación
Este servidor está diseñado para funcionar sin supervisión. La configuración predeterminada lo refleja, y sacrifica cierta capacidad de cortar una credencial filtrada. Léelo antes de desplegarlo en un lugar que importe, y cambia los números si el intercambio no te conviene.
Qué hacen los valores predeterminados
Token | Duración | Qué lo renueva |
Token de acceso | 1 hora | El token de refresco, automáticamente |
Token de refresco | 30 días | Cada refresco genera uno nuevo con 30 días renovados |
Formulario de consentimiento | 10 minutos | Nada; un formulario caducado se rechaza y el flujo se reinicia |
Claude se refresca por su cuenta — de forma proactiva antes de que termine la hora, y de forma reactiva ante un 401. Así que una persona hace clic en Aprobar cuando se añade el conector por primera vez, y luego solo si el conector permanece sin uso durante 30 días. Esa es la ventana deslizante: los 30 días limitan cuánto tiempo puede permanecer la conexión inactiva, no cuánto tiempo puede vivir.
Por qué deslizante, y qué cuesta
Cada token que emite este servidor no tiene estado. Es una carga útil firmada y nada más — sin fila en la base de datos, sin registro de sesión, nada que consultar cuando vuelve. Eso es lo que hace invisible la sustitución de una revisión de Cloud Run: la nueva instancia verifica un token que emitió la instancia anterior, sin estado compartido entre ambas. Un almacén de tokens significaría reconectar en cada despliegue.
El coste es que nada puede revocarse individualmente. No hay endpoint de revocación porque no hay nada que eliminar. Concretamente:
Un token de refresco filtrado concede acceso durante hasta 30 días, y cada uso amplía el acceso de su titular otros 30. No hay registro en el servidor que invalidar, ni forma de distinguir un token de refresco robado del legítimo — ambos son los mismos bytes firmados con la misma clave.
Deslizar la ventana no es rotación. Cuando un refresco genera un nuevo token de refresco, el que sustituye sigue funcionando hasta que expire la marca temporal que lleva dentro. La rotación real significa marcar el token antiguo como gastado, lo que requiere el almacén que este diseño no tiene.
Un token de acceso no puede cortarse dentro de su hora, por la misma razón.
Lo que queda es una única palanca contundente, y funciona de inmediato: cambia MCP_TOKEN_SIGNING_KEY y vuelve a desplegar. Todos los tokens de acceso, todos los tokens de refresco y todas las páginas de consentimiento abiertas quedan invalidados a la vez, porque todos se verifican contra esa clave. La siguiente petición de Claude recibe un 401 y el operador hace clic en Aprobar una vez. Rotar la clave con una programación es una política razonable por sí misma.
La pantalla de consentimiento, por si sirve de algo, no autentica a nadie — tiene un botón y ninguna contraseña. Lo que se interpone entre un desconocido y tus cuadernos es MCP_OAUTH_CLIENT_SECRET, que POST /token exige, la lista de permitidos de URI de redirección que envía cada código de autorización a claude.ai o a loopback, y PKCE que vincula el código al cliente que inició el flujo.
Hacer que una persona apruebe con más frecuencia
Cada uno de estos cambios es un cambio de código fuente, no un valor de configuración. Es deliberado: un operador que acorta la ventana está cambiando la postura de seguridad del despliegue, y eso pertenece a un commit que alguien pueda leer, no a una variable de entorno que alguien pueda olvidar.
Acortar la ventana de inactividad. En 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 weekSin uso durante ese tiempo, el conector necesita un clic. Con uso regular, nunca lo pide — la ventana sigue deslizándose hacia delante. Esto limita cuánto tiempo sobrevive un token de refresco filtrado después de que la fuga deje de usarse, y nada más.
Detener el deslizamiento de la ventana. Esto es lo que especificaba originalmente el issue #22, y limita la vida total de una conexión en lugar de su tiempo de inactividad: una persona aprueba cada 30 días sin importar lo ocupado que esté el conector. Una línea en exchangeRefreshToken, en 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);Negarse a emitir tokens de refresco por completo. La configuración más estricta: una persona aprueba cada hora, porque un token de acceso caducado no tiene nada que lo renueve. Dos ediciones, ambas necesarias — el cambio de metadatos por sí solo no impide que se emita el token.
En
src/oauth-router.ts, vacíaSCOPES_SUPPORTED. Claude añadeoffline_accessa una solicitud de autorización solo cuando los metadatos lo anuncian, y ese es el interruptor que decide si pide un token de refresco.En
src/oauth-provider.ts, elimina el camporefresh_tokende lo que devuelveissueTokens. Hoy se emite independientemente de los ámbitos solicitados.
Espera que este sea visible en uso: Claude envía el navegador de vuelta a la pantalla de consentimiento a mitad de sesión cuando se agota la hora.
Acortar el token de acceso. ACCESS_TOKEN_TTL_S en src/oauth-provider.ts reduce la ventana en la que funciona un token de acceso filtrado. Cuesta una petición de token por caducidad y ninguna intervención humana, así que es barato — pero no hace nada contra un token de refresco filtrado, que es la credencial que merece preocupación.
Keepalive
Los tokens de refresco delegados de Microsoft caducan tras aproximadamente 90 días sin uso. El token solo se desliza hacia delante cuando se intercambia de verdad, y solo se intercambia cuando llega una llamada de herramienta después de que el token de acceso retenido haya caducado — así que un conector que nadie usa durante tres meses es un conector que necesita a una persona en un navegador ejecutando npm run bootstrap. Nada en el servidor puede evitarlo por sí solo, porque nada en el servidor se ejecuta cuando nadie lo llama.
POST /keepalive es la solución. Llama a acquireTokenSilent con forceRefresh: true, que omite el token de acceso retenido e intercambia el token de refresco, de modo que Entra emite un reemplazo con una ventana renovada y src/token-cache.ts lo escribe en Firestore. forceRefresh es la parte que soporta la carga: sin él, MSAL responde desde su propia caché, ninguna petición llega a Entra y la ventana no se mueve.
Establece MCP_KEEPALIVE_SECRET con al menos 32 caracteres aleatorios y la ruta se monta; déjala sin establecer y la ruta devuelve 404. Un programador presenta el secreto en la cabecera X-Keepalive-Secret, que se compara en tiempo constante antes de hacer ningún trabajo. Es un secreto compartido en lugar de un token de portador porque un programador no puede ejecutar el flujo OAuth — no tiene navegador ni dónde guardar un token de refresco — y es su propia variable en lugar del secreto de cliente de la Capa 1, para que una credencial que puede alcanzar toda la superficie MCP no esté también en un trabajo de programador.
Configurarlo en tu propia cuenta
Cuatro pasos: generar un secreto, llevarlo al servicio, crear el trabajo y comprobar que el trabajo realmente llega al servicio. Todo es opcional — omítelo y el servidor funciona, hasta el día en que nadie lo haya llamado en 90 días.
# 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 es obligatorio y es la región propia del programador, que no tiene nada que ver con dónde está el objetivo del trabajo; usar la región de Cloud Run mantiene un valor menos en tu cabeza. scripts/gcp-bootstrap.sh también habilita cloudscheduler.googleapis.com, así que la habilitación del paso 3 solo es necesaria en un proyecto inicializado antes de que existiera esa línea.
Luego demuéstralo, porque todos los modos de fallo aquí son silenciosos — un secreto incorrecto, un trabajo apuntando a la URL antigua y un servicio desplegado sin la variable parecen todos un trabajo de programador sentado ahí habilitado:
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)'Un 200 significa que el token se intercambió. Lo que demuestra que se almacenó es el campo updatedAt del documento de Firestore moviéndose al momento de la ejecución, que es la única evidencia de que el token de refresco de reemplazo sobrevivió a la llamada. Medido en este despliegue el 2026-08-19: la ejecución forzada respondió 200 y tokencache/msal avanzó de 14:15:53Z a 14:30:54Z.
Semanal es más que suficiente contra una ventana de 90 días y deja margen para varias ejecuciones fallidas. El trabajo cuesta un viaje de ida y vuelta al endpoint de token y una escritura en Firestore.
Para rotar el secreto: establece el nuevo secreto de GitHub, despliega, y luego gcloud scheduler jobs update http onenote-mcp-keepalive --update-headers=…. Cualquier ejecución entre el despliegue y la actualización del trabajo responde 401 y no hace ningún trabajo, lo que en una programación semanal es una ventana en la que nada cae.
La cabecera está en la definición del trabajo, legible por cualquiera con roles/cloudscheduler.viewer en el proyecto, exactamente igual que las variables de entorno son legibles en la especificación de la revisión de Cloud Run. Es el mismo intercambio que se hace en Despliegue por no usar Secret Manager.
Estado | Significado | Qué debe hacer el programador |
200 | El token de refresco se intercambió y el nuevo se almacenó | Nada |
401 | El secreto falta o es incorrecto | Arregla el trabajo; la ruta no hizo nada |
404 |
| Establécelo y vuelve a desplegar |
503 con | Firestore no estaba accesible | Reintenta |
503 con | La concesión está muerta |
|
Contra lo que esto no protege: una política de frecuencia de inicio de sesión de acceso condicional, un cambio de contraseña, un restablecimiento de MFA o un administrador que revoque la concesión. Cualquiera de esos mata el token de refresco diga lo que diga la programación, y ningún cambio de código lo evita. Si el inquilino de Entra es tuyo, exime este registro de aplicación de las políticas de frecuencia de inicio de sesión; si no lo es, trata los 90 días como un límite superior que otra persona puede acortar sin avisarte.
La ruta de keepalive tampoco está relacionada con la ventana de 30 días de la Capa 1 en Duración de tokens más abajo. Ese token de refresco vive en el almacén de conectores de Claude, y solo Claude puede presentarlo o recibir su reemplazo, así que nada de lo que se ejecuta aquí puede mantenerlo vivo. Perderlo cuesta un clic en el botón Aprobar; perder el de Microsoft cuesta un inicio de sesión con código de dispositivo.
Sincronización del espejo de páginas
El espejo mantiene copias en Firestore de las páginas de un conjunto seleccionado a mano de cuadernos, para que las herramientas de lectura puedan responder sin gastar el presupuesto de peticiones de OneNote. POST /sync es cómo se llena. Nada de esto es automático: la ruta no se monta sin un secreto, y las herramientas de lectura no consultan el espejo sin MIRROR_READ_ENABLED.
Qué lo mantiene actualizado
Tres mecanismos. Solo dos tienen programación, y uno de ellos es temporal.
Qué lo ejecuta | Qué cubre | |
Relleno inicial | Un trabajo de programador | Cada cuaderno reflejado, activo o no, rellenado una vez |
Actualización continua | Cada lectura cubierta, en línea, antes de responder | Páginas creadas y editadas en cuadernos activos |
Barrido de eliminación | Un trabajo de programador nocturno | Páginas eliminadas de cuadernos activos |
No hay sincronización incremental programada en estado estable, porque las lecturas la hacen. Cada lectura cubierta ejecuta runIncremental por sí misma antes de responder, con un máximo de 12 peticiones y 15 segundos y no más de una vez cada 30 segundos — ver Lectura desde él más abajo. El espejo se refresca por tanto cuando alguien está a punto de mirarlo y no de otra forma. Una ejecución incremental programada además de eso gastaría las mismas 400 peticiones por hora refrescando datos que nadie pidió, y la siguiente lectura se refrescaría igualmente.
Una lectura no puede notar una eliminación, que es toda la razón por la que existe el barrido nocturno.
La pasada incremental se basa en lastModifiedDateTime de la sección padre — una creación, una
edición y una eliminación lo mueven — y luego pide a esa sección sus páginas cambiadas. Una página
que ha sido eliminada no aparece en ningún listado, así que nada en esa respuesta dice que ya no está.
Descubrirlo significa enumerar los ids de página de la sección y reconciliarlos contra el
espejo, porque Graph no tiene /delta en ningún recurso de OneNote ni una lápida para una
página eliminada. Eso es un barrido, es demasiado caro para ponerlo delante de una lectura, y hasta que
uno se ejecuta, el espejo sigue sirviendo una página que ya no existe. Una noche es el desfase
que esto acepta.
La carga inicial no es ni lo uno ni lo otro, así que tiene su propio horario hasta que termina. Es un
listado por sección desde la época más un fetch de contenido por página — miles de solicitudes
en esta cuenta — y el presupuesto de 12 solicitudes de la actualización en línea tardaría semanas en cubrirlo.
Elimina el trabajo una vez que sync/state.backfillComplete sea true, que es cuando cada
sección reflejada tiene un pagesSyncedThrough no nulo. Nada más llama a POST /sync.
La primera escritura de estructura después de actualizar reescribe cada documento una vez. Los documentos
de estructura llevan una cadena identity que dice lo que el árbol decía sobre ellos, y un
documento escrito antes de que ese campo existiera no tiene ninguna — así que no coincide y se escribe.
Es una pasada de escrituras de Firestore y ninguna solicitud a Graph, y ocurre una vez.
Un cuaderno inactivo se rellena una vez y luego se congela. activeNotebookIds en el
documento de selección es lo que traza esa línea — ver Eligiendo qué reflejar. Un cuaderno
seleccionado que no está activo se rellena con la carga inicial y se sirve desde el espejo para siempre;
ninguna actualización en línea ni barrido lo visita de nuevo. Eso es lo que hace que una cuenta de 55 cuadernos
sea asequible de reflejar: un archivo se lista y se obtiene una vez y nunca más.
Las cuatro rutas
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 notDos de estas están en un horario: /sync mientras la carga inicial se ejecuta, y /sync/sweep/full
nocturno. /sync/sweep es la variante más barata y acotada, útil a mano. /sync/sweep/all es la
única modalidad sin filtro de actividad y la única forma de notar una eliminación dentro de un
cuaderno inactivo; cuesta una solicitud por sección reflejada, así que ejecútala deliberadamente en lugar de
en un temporizador.
Lo que cuesta
OneNote permite a una aplicación delegada 400 solicitudes por hora, y cada llamada de herramienta interactiva sale del mismo total de 400. La cuenta contra la que esto se ejecuta tiene 55 cuadernos y 568 secciones; 4 cuadernos y 202 secciones están reflejados, y 2 cuadernos y 70 secciones están activos.
Ejecución | Solicitudes |
Actualización en línea, nada cambió | 1 — el árbol expandido, y nada más |
Actualización en línea, algunas páginas editadas | 2–12; un fetch solo para una página que realmente cambió, y 12 es un presupuesto duro |
| ~70 aquí, más una por página cambiada |
| una por sección visitada |
| ~202 aquí — la mitad del presupuesto por hora |
Una ejecución de carga inicial | 49–120 medido; se detiene en el presupuesto que se agote primero |
Una página que no ha cambiado no cuesta ninguna solicitud. El listado de páginas cambiadas lleva el
sello y el título de cada página, y una copia almacenada que coincide con ambos no se vuelve a obtener. Así que el
costo de una ejecución sigue a las páginas que realmente cambiaron en lugar de a las páginas editadas en la última
hora, y pagesSkipped en el informe de ejecución es cuántas rechazó de esa manera.
Las dos ventanas de solapamiento tienen anchos diferentes, y eso es deliberado.
WATERMARK_OVERLAP_MS es una hora y decide qué páginas devuelve el listado de una sección;
nada de lo que muestra cuesta una solicitud, así que mantiene el margen. SECTION_SCAN_OVERLAP_MS es
quince minutos y decide cuánto tiempo una sección que fue editada una vez sigue siendo visitada,
a un costo de una solicitud de listado por ejecución — una hora de eso fue el último término que valió la pena recortar. Ambos
son márgenes contra el retraso de propagación de Graph y la brecha entre su reloj y el de este servicio;
las dos consultas de registro bajo Demostrando que funciona son lo que dice si quince minutos es
lo correcto.
MIRROR_SYNC_REQUEST_BUDGET (por defecto 120) es un tope duro. Una ejecución que lo alcanza confirma
lo que hizo, responde 200 con "done": false, y se reanuda en el siguiente horario. Eso es
lo que hace posible la primera carga inicial.
Cuál de los dos topes se agota varía, así que dimensiona la cadencia contra el presupuesto de solicitudes en lugar de contra un promedio observado. Medido en seis ejecuciones el 2026-08-20: 49, 57, 60, 63, 120 y 120 solicitudes. Las primeras cuatro terminaron en el reloj de 240 segundos con presupuesto de sobra — una página cuesta un fetch de Graph, un render de tinta de resvg y dos escrituras de blob, y los renders son lo que llena el reloj en una instancia de 1 CPU. Las últimas dos gastaron el presupuesto completo dentro del reloj, en secciones cuyas páginas eran más baratas de renderizar.
Cadencia | Solicitudes/hora con presupuesto 120 | |
| hasta 360 | la cadencia más rápida que no puede exceder el límite |
| hasta 480 | por debajo mientras las ejecuciones estén limitadas por el reloj, por encima cuando no lo estén |
| hasta 720 | por encima del límite siempre que las ejecuciones estén limitadas por solicitudes |
| hasta 1440 | medido contra esta cuenta el 2026-08-20: 429s en 45 minutos |
Pasadas 400 por hora, Graph responde 429 con el código OData 10007. Una solicitud rechazada no hace
trabajo y aún así gasta el intento, la marca de agua de la sección no avanza, y la penalización
dura más que un backoff corto — cinco reintentos que abarcaron tres minutos fueron todos rechazados después de una
ráfaga, registrado en docs/graph.md. Exceder el límite
por lo tanto no termina la carga inicial antes. Reducir a la mitad MIRROR_SYNC_REQUEST_BUDGET compra un
intervalo reducido a la mitad con el mismo gasto por hora, al costo de una lectura de árbol extra por ejecución.
El tamaño de la carga inicial en esta cuenta es aproximadamente una solicitud por sección más una por página. 202 secciones con unas 24 páginas cada una — estimado de las primeras cuatro secciones cuyo listado se completó, no medido — es aproximadamente 4,800 páginas y 5,000 solicitudes, así que el mínimo es alrededor de 12.5 horas de segmentos sea cual sea la cadencia.
Con MIRROR_READ_ENABLED=false no hay ninguna puesta al día continua en absoluto. Esa es la
configuración desplegada hoy, así que el trabajo de carga inicial y el barrido nocturno son las únicas cosas
que tocan el espejo. Activar las lecturas es lo que hace real el diseño impulsado por lecturas anterior,
y añade hasta 12 solicitudes por actualización a no más de una por 30 segundos — aproximadamente 120
solicitudes por hora desde una conversación continuamente ocupada, y hasta 144 mientras el espejo está
atrasado. Ese término cabe bajo 400 junto al barrido nocturno una vez que el trabajo de carga inicial se ha ido.
Configurándolo
# 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"El trabajo de carga inicial en la cuenta desplegada está en */15. Eso está bajo el límite mientras las ejecuciones
estén limitadas por el reloj — medido en 61–81 solicitudes por ejecución el 2026-08-20 — y por encima
si empiezan a gastar el presupuesto completo de 120 solicitudes de nuevo. */20 es la cadencia que no puede excederlo
de ninguna manera; bajar MIRROR_SYNC_REQUEST_BUDGET a 100 hace que */15 también sea seguro.
Los reintentos son seguros en ambos: cada modalidad es idempotente, y un reintento después de una ejecución con presupuesto agotado es activamente útil durante la carga inicial. Mientras ambos trabajos existan, el arrendamiento de ejecución hace que un solapamiento sea un 409 en lugar de un doble gasto del presupuesto de Graph.
/sync/sweep/all no recibe ningún trabajo. Ejecútalo a mano después de editar activeNotebookIds, o cuando un
cuaderno haya sido reorganizado fuera de este servidor y el espejo necesite reconciliarse contra
lo que OneNote realmente contiene.
Eligiendo qué reflejar
La selección es un documento de Firestore editado a mano, MIRROR_ROOT_DOC, por defecto
onenoteMirror/default. Es el único documento que el servicio nunca escribe.
Campo | Tipo | Significado |
| array de string | Ids de cuaderno cuyas páginas están reflejadas |
| array de string, opcional | Cuáles de esos todavía se están editando. Ausente o |
| string | Texto libre para ti. Nunca se lee. |
Obtén los ids de list_notebooks. La estructura — cada nombre de cuaderno y sección en la
cuenta — se refleja de todos modos, porque la lectura del árbol lo devuelve todo por la misma
solicitud y una estructura parcial haría que list_notebooks y una search_pages sin ámbito
respondieran con confianza y de forma parcial. Solo el contenido de las páginas sigue la selección.
activeNotebookIds reduce el trabajo en curso y nada más. Las páginas, el contenido y la tinta de un cuaderno
inactivo se almacenan exactamente como las de uno activo, las escrituras a través de este
servidor todavía lo alcanzan y todavía actualizan el espejo, y una lectura todavía responde desde él. Lo que
cambia es que ninguna pasada incremental ni ningún barrido lo vuelve a comprobar una vez que su carga inicial está hecha, así que
una edición hecha en el cliente de OneNote a un cuaderno inactivo no llega al espejo hasta
que el cuaderno se vuelve a hacer activo o /sync/sweep/all se ejecuta a mano.
Mover un cuaderno de vuelta a activeNotebookIds es seguro, y cuesta solo ese cuaderno:
la siguiente ejecución compara las listas almacenadas, encuentra el único id que se volvió activo, y vuelve a listar
las secciones de ese cuaderno contra sus propias marcas de agua por sección. Cada otro cuaderno mantiene su
marca de agua y no se lista. Solo se obtienen las páginas que realmente cambiaron. Añadir un cuaderno
a notebookIds se comporta de la misma manera.
Un id en notebookIds que no coincide con ningún cuaderno se informa como unknownNotebookIds en cada informe
de ejecución. Un id en activeNotebookIds que nombra un cuaderno no reflejado — un error tipográfico, o un
cuaderno real nunca añadido a notebookIds — se informa como unknownActiveNotebookIds y se registra
como mirror-selection-unknown-active. Revisa ambos después de editar: los ids son cadenas opacas,
y un error tipográfico es de otro modo un cuaderno que silenciosamente nunca se sincroniza.
Demostrando que funciona
# 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)'Un 200 no es prueba por sí solo. Las tres cosas que lo son:
sections/{id}.pagesSyncedThroughse movió en Firestore. Una ejecución que respondió 200 y no avanzó ninguna marca de agua no hizo nada.El bucket contiene un objeto por página con tinta, y su recuento de bytes coincide con el
ink.bytesdel documento de página.sectionsSkippedInactivees el número que esperas. Un recuento que cubre cada sección reflejada significa que el conjunto activo está vacío o mal escrito;unknownActiveNotebookIdsen el mismo informe dice cuál.
Dos números basados en registros dicen si las ventanas de solapamiento tienen el ancho correcto. Ninguno es urgente; léelos después de que el servicio haya estado funcionando durante una semana.
# 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)'Observa la carga inicial volviendo a ejecutar el trabajo y leyendo done en informes sucesivos: false
con un pagesUpdated en aumento, luego true. sync/state.backfillComplete dice lo mismo,
y es la señal para eliminar el trabajo onenote-mcp-sync.
Vigila un 429 en el registro de solicitudes durante una ejecución. Mientras el trabajo de relleno se ejecuta por encima de 400 solicitudes por hora, un 429 es la consecuencia esperada de esa elección y la cura es una cadencia más larga. En cualquier otro momento significa que algo está evitando la puerta de solicitudes compartida, que es el fallo que hace que se limite toda la cuenta.
Estado | Significado | Qué hacer |
200, | La ejecución terminó su trabajo | Nada |
200, | Se detuvo por el presupuesto de solicitudes o de tiempo | Nada; la siguiente ejecución reanuda |
401 | El secreto falta o es incorrecto | Arregla el trabajo; la ruta no hizo nada |
404 |
| Configúralo y vuelve a desplegar |
409 | Otra ejecución tiene el arrendamiento | Nada; expira después de 15 minutos |
504 | La ejecución superó el tiempo de espera de 300s de Cloud Run | Nada; ver más abajo |
503, | El token de actualización de Microsoft ha desaparecido | Vuelve a ejecutar |
503, | Firestore no respondió | Reintenta; la credencial está bien |
Lectura desde él
MIRROR_READ_ENABLED es el interruptor. Con él en false — el valor predeterminado — cada herramienta responde desde Microsoft Graph exactamente como lo hacía antes de que existiera el espejo. Con él en true, siete herramientas pasan por la copia local: list_notebooks, list_sections, list_pages, list_pages_by_name, find_page_by_name, get_page_content y search_pages.
Cada una de esas lecturas son tres pasos, en este orden.
Actualización. La herramienta ejecuta una sincronización incremental por sí misma — el mismo
runIncrementalque ejecutaPOST /sync, con presupuestos más pequeños: 12 solicitudes de OneNote y 15 segundos. Una vez que se elimina el trabajo de relleno, esto es lo único que ejecuta un pase incremental.Leer la copia local. Un acierto no cuesta ninguna solicitud de OneNote. Un fallo pasa a Microsoft Graph, exactamente como antes.
Informar la fuente.
sourceesonenotecuando la respuesta es igual a lo que tiene OneNote,best-availablecuando parte proviene de un cuaderno que el operador congeló, ymirrorcuando puede estar desactualizada.
La regla de source completa. "Confirmado" significa que la actualización del paso 1 terminó, o que la respuesta es una sola página que ninguna escritura ha marcado como obsoleta:
Situación |
|
La lectura pasó a Microsoft Graph |
|
Un acierto local de un cuaderno activo, confirmado |
|
Un acierto local de un cuaderno activo, no confirmado |
|
Un acierto local de un cuaderno inactivo, confirmado o no |
|
Una búsqueda sin ámbito que cubre ambos, confirmada |
|
Una búsqueda sin ámbito que cubre ambos, no confirmada |
|
Una actualización "terminó" significa que leyó el árbol de cuadernos, visitó cada sección que necesitaba, obtuvo cada página que necesitaba y no agotó ninguno de los presupuestos. Cualquier otra cosa — el presupuesto de solicitudes o de tiempo se agotó, el árbol devolvió un 500, la obtención de una página falló, el programador tenía el arrendamiento de sincronización, Firestore no respondió — es mirror, y la respuesta lleva mirroredAt indicando cuándo se tomó la copia. get_page_content es la excepción en las primeras tres filas: un documento de página lleva su propio indicador de obsolescencia y cada escritura marca su página como obsoleta antes de tocar OneNote, por lo que un acierto allí es una página que nada ha superado.
best-available no es una respuesta degradada y no se convierte en mirror cuando una actualización falla: esa actualización nunca iba a comprobar un cuaderno inactivo, por lo que informarlo describiría un fallo que no podría haber cambiado la respuesta. Lo que sí dice es que la copia solo se vuelve a comprobar mediante una escritura a través de este servidor o mediante /sync/sweep/all. list_notebooks informa pagesActive por cuaderno y una search_pages sin ámbito informa inactiveNotebooks junto a notebooksSearched, para que un modelo pueda ver cuán grande fue la omisión. list_notebooks y list_sections nunca informan best-available — la estructura se almacena para toda la cuenta en cada ejecución, sea cual sea el conjunto activo.
Una actualización no se ejecuta en cada llamada. Una que terminó se mantiene durante 30 segundos y una que no terminó durante 5 minutos, por lo que una ráfaga de llamadas de herramientas paga una actualización en lugar de treinta. El peor caso es aproximadamente 120 solicitudes adicionales de OneNote por hora desde una conversación continuamente ocupada en una cuenta tranquila, contra el límite de 400 — y una actualización en una cuenta tranquila es una sola solicitud, el árbol de cuadernos.
No hay un argumento por llamada para forzar una lectura en vivo. Solía haberlo (useLiveData); ya no existe, porque una lectura que primero actualiza y luego dice si la actualización terminó responde la misma pregunta sin un campo de esquema que el modelo que llama tenga que razonar.
Apagarlo es una reversión completa: establece la variable en false, vuelve a desplegar, y las herramientas son byte-idénticas a su comportamiento anterior al espejo. No se ejecuta ninguna actualización, ninguna llamada de herramienta gasta una solicitud en una sincronización, y cada respuesta dice source: "onenote". Sin migración de datos, y el espejo sigue llenándose en segundo plano.
Las escrituras resincronizan su página inmediatamente. Las cinco herramientas de escritura vuelven a leer la página de OneNote después de una escritura exitosa y la almacenan, por lo que un get_page_content justo después de un append_to_page responde desde la copia local con el texto que se acaba de añadir. Eso cuesta una solicitud adicional de OneNote por escritura. Si la resincronización falla, la página se marca como obsoleta en su lugar, lo que envía la siguiente lectura a OneNote — más lento, pero nunca incorrecto — y la actualización delante de una lectura posterior la repara de cualquier manera. Una resincronización fallida nunca hace fallar la escritura.
Dos cosas que comprobar antes de activarlo:
Los cuatro índices compuestos leen
READY. Una consulta contra uno que aún estáCREATINGfalla conFAILED_PRECONDITION.sync/state.backfillCompleteestrue. Activarlo antes es seguro — una página no reflejada es un fallo y va a Graph — pero la mayoría de las lecturas pasarían, lo que es más lento que no tener el espejo en absoluto y gasta presupuesto que el relleno necesita.
El único cambio de comportamiento que vale la pena conocer es search_pages. Respondida desde Graph, recorre secciones y se detiene en 60 de ellas o 25 segundos, por lo que informa sectionsSearched, sectionsFound y stoppedEarly. Respondida desde el espejo no hay recorrido ni límite — pero el contenido de las páginas se guarda solo para los cuadernos de tu selección, por lo que informa notebooksSearched contra notebooksInAccount en su lugar, y lo dice en su nota cuando difieren. Un modelo que lee "sin coincidencias" necesita saber cuál de esos obtuvo. Para buscar un cuaderno fuera de la selección, pasa sectionId: eso falla en la copia local y recorre la única sección en OneNote.
Cuando una ejecución se excede
MIRROR_SYNC_REQUEST_BUDGET y el presupuesto de tiempo de pared de 240 segundos detienen una ejecución muy dentro del tiempo de espera de 300 segundos de Cloud Run, por lo que la respuesta ordinaria es un 200 con "done": false. Un 504 significa que algo tomó más tiempo de lo esperado — casi siempre un 429 de OneNote con un Retry-After largo.
Lo que sucede entonces vale la pena saberlo. El servicio está desplegado con limitación de CPU y sin recuento mínimo de instancias, por lo que una solicitud cortada encuentra uno de dos destinos y Cloud Run no promete ninguno: si la instancia aún está caliente, el proceso se suspende y se reanuda cuando llega la siguiente solicitud, y si se ha reducido a cero, el trabajo simplemente se pierde. En cualquier caso, la ejecución no termina, su arrendamiento no se libera, y los reintentos responden 409 hasta que el arrendamiento expira 15 minutos después. La siguiente ejecución — el trabajo de relleno, el barrido nocturno, o la actualización delante de la siguiente lectura cubierta — procede normalmente.
Nada se pierde en ninguno de los casos. Los marcadores de agua avanzan por sección y solo después de que se almacenan las páginas de esa sección, por lo que una ejecución cortada conserva todo lo que confirmó y la siguiente ejecución reanuda desde allí. Lo mismo ocurre con una instancia que simplemente desaparece entre ejecuciones: el espejo, el token de actualización de Microsoft y cada token que este servidor emite se almacenan o firman en lugar de mantenerse en memoria, por lo que una instancia puede desaparecer en cualquier momento sin costar más que un viaje de ida y vuelta de token.
Lo único que debes comprobar si ves 504 repetidos es el registro de solicitudes en busca de 429. Una limitación sostenida es la única forma realista de alcanzar el tiempo de espera, y la cura es un MIRROR_SYNC_REQUEST_BUDGET más pequeño o un intervalo más largo, no un tiempo de espera de Cloud Run más largo.
Recuperación de un espejo malo
Elimina las subcolecciones notebooks, sectionGroups, sections, pages, pageContent y tombstones de MIRROR_ROOT_DOC, y limpia structureHash y sectionsScannedThrough en sync/state. Luego vuelve a crear el trabajo onenote-mcp-sync desde Configuración y déjalo hasta que backfillComplete sea true de nuevo: la actualización en línea delante de una lectura tiene un presupuesto de 12 solicitudes y no reconstruirá un espejo desde cero. El documento de selección y la caché de tokens no se ven afectados por esto, y no hay ninguna escritura de Graph involucrada — el espejo es una copia, y Microsoft Graph sigue siendo la fuente de verdad.
Alertas
Dos fallos son invisibles sin una métrica basada en registros, porque ambos aparecen solo como un mensaje dentro de una conversación de Claude o como una línea que nadie está leyendo:
Evento | Significado |
| La concesión de Microsoft está muerta. Alguien tiene que ejecutar |
| MSAL entregó una caché sin credenciales. La copia almacenada sobrevivió; algo está mal. |
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"'Luego una política de alertas sobre esa métrica por encima de cero. También vale la pena vigilar una aprobación de consentimiento: POST /consent respondiendo 302 debería ocurrir solo cuando agregas el conector, y el registro de solicitudes ya lo lleva.
jsonPayload.event="request" jsonPayload.path="/consent" jsonPayload.status=302Bootstrap
npm run bootstrap es el único inicio de sesión interactivo de Microsoft en el proyecto, y se ejecuta en tu máquina, no en Cloud Run. Inicia sesión con el flujo de código de dispositivo y escribe la caché de MSAL resultante en el documento de Firestore que el servidor lee.
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 bootstrapImprime el mensaje de código de dispositivo de Microsoft, espera a que apruebes en un navegador, luego lista tus cuadernos una vez e imprime el recuento como prueba de que el token funciona. Las líneas finales nombran el proyecto de Firestore y el documento escrito y el inquilino principal de la cuenta, para que puedas ver que iniciaste sesión en el directorio correcto. Esa salida lleva el id del inquilino; mantenlo fuera de issues, pull requests y registros de flujo de trabajo.
GOOGLE_CLOUD_PROJECT y FIRESTORE_CACHE_DOC son obligatorios aquí, a diferencia del servidor donde el primero se infiere y el segundo tiene un valor predeterminado. La CLI escribe con tus propias Credenciales de Aplicación Predeterminadas, por lo que un valor no establecido sembraría un documento real en el proyecto al que apunta tu inicio de sesión de gcloud, y aún imprimiría una línea de éxito. Los valores MCP_OAUTH_* no se leen, por lo que ejecutar esto nunca pone el secreto de cliente de la Capa 1 en tu máquina.
Ejecútalo de nuevo cada vez que aparezca un evento graph-auth-failure con retryable: "false" en los registros del servidor. El token de actualización se rota en cada uso y muere si el servicio permanece inactivo más de aproximadamente 90 días; no hay recuperación automática. Configurar el trabajo de keepalive anterior es lo que evita que la inactividad sea una de las formas de llegar allí.
Contenedor
El servicio se despliega en Cloud Run, que ejecuta linux/amd64. La imagen se compila explícitamente para esa plataforma para que el binario nativo de @resvg/resvg-js coincida. La base del runtime es Debian node:24-slim y no debe convertirse en Alpine: el prebuild de resvg es solo para 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 responde lo mismo y es lo que usan las propias sondas de Cloud Run. No lo llames desde fuera: el frontend de Google responde a https://<service>.run.app/healthz con su propia página 404 y la solicitud nunca llega al contenedor, por lo que una comprobación de disponibilidad externa debe usar /health. Medido contra el servicio desplegado el 2026-08-19: /health, /healthz2 e incluso /Healthz llegan todos, y solo la variante exacta en minúsculas /healthz es absorbida.
Esos valores son marcadores de posición que solo están lo suficientemente bien formados para pasar la validación de arranque; no autentican contra nada. En Cloud Run, PORT lo proporciona la plataforma y el resto proviene del flujo de trabajo de despliegue. Si el puerto 8080 del host ya está ocupado, mapea otro: -p 8081:8080 con -e PORT=8080.
Para probar la imagen:
RUN_DOCKER_TESTS=1 bash scripts/test/run.shEso construye la imagen, comprueba que el binario glibc de resvg sobrevivió a la instalación de solo producción y que no vino ninguna dependencia de desarrollo, renderiza un SVG a PNG dentro del contenedor y verifica que /healthz responde 200 en el puerto indicado en PORT. Sin RUN_DOCKER_TESTS=1, la suite de Docker se omite y el resto sigue ejecutándose.
Despliegue
.github/workflows/deploy.yml se ejecuta en cada push a main y en workflow_dispatch. Comprueba tipos, ejecuta npm test, compila, construye y sube la imagen del contenedor a Artifact Registry etiquetada con el SHA del commit, y despliega esa imagen en Cloud Run. Una comprobación de tipos o una prueba fallida detiene la ejecución antes de que se construya una imagen.
No hay credenciales de larga duración en GitHub. El trabajo se autentica mediante Workload Identity Federation: permissions: id-token: write le permite solicitar un token OIDC de GitHub, y google-github-actions/auth@v2 lo intercambia por credenciales de Google de corta duración. scripts/gcp-bootstrap.sh no crea ninguna clave JSON de cuenta de servicio ni se necesita en ningún lugar. El proveedor solo acepta tokens cuya reclamación repository sea este repositorio.
La imagen se construye en ubuntu-latest, que es linux/amd64 — la plataforma en la que se ejecuta Cloud Run y para la que está compilado el prebuild de @resvg/resvg-js. Por eso el flujo de trabajo construye la imagen él mismo en lugar de usar gcloud run deploy --source, lo que también implicaría habilitar Cloud Build y otorgar los roles asociados.
El despliegue se ejecuta con --max-instances=1 y --allow-unauthenticated, como la cuenta de servicio de runtime, que tiene roles/datastore.user para la caché de tokens de Firestore. --allow-unauthenticated es lo que permite que Claude llegue al servicio en absoluto; el endpoint MCP está cerrado mediante el token de portador. Ver Tokens de portador en el endpoint MCP.
Lo que el repositorio debe contener
scripts/gcp-bootstrap.sh aprovisiona el lado de GCP e imprime los comandos gh variable set para los primeros seis. El flujo de trabajo falla en su primer paso, indicando qué falta, en lugar de desplegar una configuración a medias.
Nombre | Tipo | Valor |
| variable | ID del proyecto |
| variable | Región de Cloud Run |
| variable | Región de Artifact Registry |
| variable | Nombre completo del recurso del proveedor de identidad de carga de trabajo |
| variable | Correo de la cuenta de servicio de despliegue |
| variable | Correo de la cuenta de servicio de runtime |
| variable | ID de cliente del registro de aplicación de Azure |
| variable | URL de autoridad de Entra |
| variable | ID de cliente OAuth de capa 1 |
| variable | URL pública del servicio; ver más abajo |
| variable | Opcional; por defecto |
| secreto | Secreto de cliente OAuth de capa 1 |
| secreto | Clave de firma de tokens de acceso, de al menos 32 caracteres |
| secreto | Opcional; si no se define, |
| variable | Opcional; el bucket de Cloud Storage del espejo de páginas. |
| variable | Opcional; por defecto |
| variable | Opcional; por defecto |
| variable | Opcional; por defecto |
| secreto | Opcional; si no se define, |
Solo cuatro de estos son credenciales. El nombre del proveedor WIF y los correos de las cuentas de servicio son identificadores, inútiles para cualquiera que no pueda presentar la identidad OIDC de este repositorio, por lo que son variables en lugar de secretos.
El despliegue pasa env_vars_update_strategy: overwrite, por lo que la lista en el flujo de trabajo es todo el entorno del servicio en cada revisión. El valor predeterminado de la acción es merge, bajo el cual una variable eliminada del flujo de trabajo sobreviviría silenciosamente de la revisión anterior. PORT y GOOGLE_CLOUD_PROJECT están deliberadamente fuera de la lista: Cloud Run los proporciona ambos y rechaza PORT como entrada.
El primer despliegue y MCP_PUBLIC_URL
MCP_PUBLIC_URL es el emisor OAuth y la audiencia de cada token de acceso que emite este servidor, y no existe ningún servicio que tenga una URL hasta que se haya realizado el primer despliegue. El flujo de trabajo lo resuelve en tres pasos: la variable de repositorio MCP_PUBLIC_URL, luego la URL que Cloud Run ya ha asignado al servicio y, solo cuando ninguna existe, https://placeholder.invalid, que reemplaza con la URL real inmediatamente después del despliegue. Así, una primera ejecución funciona sin supervisión y termina con el valor correcto en su lugar. Deja una advertencia que nombra la URL; establece la variable de repositorio con ella, porque es la única de las tres fuentes que sobrevive al poner un dominio personalizado delante del servicio.
Cambiar MCP_PUBLIC_URL no invalida nada por sí mismo, pero cada token de acceso ya emitido está vinculado a la audiencia anterior y será rechazado. Claude vuelve a ejecutar el flujo de autorización cuando eso ocurre.
Revertir
La etiqueta de la imagen es el SHA del commit, por lo que una imagen anterior sigue en Artifact Registry:
gcloud run services update-traffic onenote-mcp --region "$GCP_REGION" --to-revisions <revision>=100Volver a ejecutar el flujo de trabajo desde un commit anterior con workflow_dispatch también funciona, y es el que mantiene el entorno desplegado en sintonía con el archivo de flujo de trabajo de ese commit.
Configuración
Cada valor proviene de una variable de entorno, validada al arrancar. Una variable faltante o mal formada produce un ConfigError que enumera todo lo que está mal a la vez, y el proceso sale con código 1 sin un rastro de pila.
Variable | Obligatoria | Predeterminado | Propósito |
| sí | — | ID de cliente del registro de aplicación de Azure (cliente público) |
| sí | — | URL de autoridad de Entra ID para el inquilino |
| sí | — | ID de cliente OAuth de capa 1 que presenta Claude |
| sí | — | Secreto de cliente OAuth de capa 1 |
| sí | — | Clave usada para firmar los tokens de acceso emitidos (mín. 32 caracteres) |
| sí | — | La URL pública propia del servicio: |
| servidor: no · bootstrap: sí |
| Ruta del documento de Firestore que contiene la caché de tokens de MSAL |
| servidor: no · bootstrap: sí | — | Proyecto de GCP; se infiere automáticamente en Cloud Run |
| no |
| Puerto de enlace. Cloud Run lo establece; el servidor nunca lo fija en el código. |
| no | — | Al menos 32 caracteres. Si se establece, se monta |
| no |
| Documento de Firestore que contiene las listas editadas manualmente |
| no | — | Al menos 32 caracteres. Si se establece, se monta |
| condicionalmente | — | Bucket de Cloud Storage para la tinta renderizada y el HTML de páginas sobredimensionadas. Obligatorio una vez que se establece |
| no |
|
|
| no |
| Solicitudes a Graph que una ejecución de sincronización puede gastar antes de detenerse e informar de más pendientes. 10–350, frente a un límite horario de 400. |
Las cinco variables MIRROR_* configuran el espejo de páginas de Firestore. Todas son opcionales, por lo que un servicio desplegado sin ninguna de ellas establecida se comporta exactamente igual que antes de que existiera el espejo — que es lo que convierte a MIRROR_READ_ENABLED en un interruptor de reversión completo en lugar de un cambio de código.
MIRROR_BUCKET es la única regla entre campos en loadConfig. Un VarSpec indica si cada variable es obligatoria o no y no puede decir «obligatoria cuando otra está presente», pero una sincronización no tiene dónde poner un PNG de tinta renderizada sin un bucket, y una lectura del espejo no tiene de dónde obtenerlo. Así que establecer MIRROR_SYNC_SECRET, o establecer MIRROR_READ_ENABLED a true, sin un bucket falla al arrancar el contenedor con MIRROR_BUCKET en la lista de faltantes — en lugar de horas después de iniciado un backfill, en la primera escritura de objeto.
MIRROR_READ_ENABLED solo acepta true y false, sin distinguir mayúsculas. Deliberadamente no es permisivo: 1, yes y on todos se leen como true para un humano, y un analizador que los aceptara también tendría que decidir qué significan off y 0, momento en el que un error tipográfico como ture se convierte en false y apaga el espejo sin decir nada.
FIRESTORE_CACHE_DOC nombra el documento que el plugin de caché de MSAL en src/token-cache.ts lee y escribe. Su valor debe ser una ruta de documento, es decir, un número par de segmentos separados por barras; loadConfig rechaza una ruta de colección al arrancar.
ONENOTE_CLIENT_ID y ONENOTE_AUTHORITY identifican el registro de aplicación de Azure que src/graph-auth.ts presenta a Entra ID. Es un cliente público, por lo que deliberadamente no hay secreto de cliente de capa 2; los valores MCP_OAUTH_* que aparecen debajo pertenecen a la capa 1, entre Claude y este servidor, y no están relacionados.
MCP_PUBLIC_URL es la URL en la que Claude alcanza este servicio. El emisor de OAuth, el identificador resource al que está vinculado un token y la URL del documento de metadatos del recurso protegido se construyen todos a partir de ella. Nada en Cloud Run le dice al proceso en qué URL se le alcanza, y un valor tomado de la cabecera Host sería lo que enviara el llamador, por lo que se configura. Solo se puede rellenar después de que el primer despliegue haya producido la URL.
npm run bootstrap solo lee ONENOTE_CLIENT_ID, ONENOTE_AUTHORITY, FIRESTORE_CACHE_DOC y GOOGLE_CLOUD_PROJECT — no los valores MCP_OAUTH_* — y exige los dos últimos en lugar de darles un valor predeterminado. Ver Bootstrap.
Higiene del repositorio
Este repositorio es público. No se deben confirmar contenidos reales de páginas, tinta renderizada, nombres o IDs de inquilinos de Entra, ni contenidos de documentos de Firestore. .gitignore excluye output/ y los patrones de archivo de la caché de tokens; ver la sección «Higiene del repositorio» de 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