Skip to main content
Glama

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 test

Scripts

Script

Qué hace

npm run build

Compila src/ a dist/

npm run typecheck

Comprueba tipos sin emitir

npm run dev

Ejecuta el servidor desde el código fuente con --watch

npm start

Ejecuta el servidor compilado desde dist/ (ejecuta build primero)

npm run bootstrap

Inicio de sesión local con código de dispositivo que siembra la caché de tokens de Firestore

npm test

node --test sobre test/**/*.test.ts

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-pitr

Un 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-emulator

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

reason

Qué sucedió

Qué hacer

cache-unreadable

El documento de Firestore está ausente, o su campo cache no es algo que MSAL pueda deserializar

npm run bootstrap

cache-unavailable

Firestore no respondió, o la cuenta de servicio en tiempo de ejecución perdió roles/datastore.user

Reintentar. No es un inicio de sesión.

no-account

La caché se leyó pero no contiene una cuenta con sesión iniciada

npm run bootstrap

silent-failed

El token de actualización almacenado está caducado o revocado, o el endpoint de token no devolvió nada utilizable

npm run bootstrap

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

listNotebooks()

Cada bloc de notas, por nombre para mostrar

listSections(containerKind, containerId)

Secciones directamente bajo un bloc de notas o grupo de secciones

listSectionGroups(containerKind, containerId)

Grupos de secciones directamente bajo un bloc de notas o grupo de secciones

listContainerChildren(containerKind, containerId)

Ambos de los anteriores, obtenidos juntos

listPagesInSection(sectionId, top?)

Páginas en una sección, más recientemente modificadas primero, como máximo top (por defecto 50)

getNotebookTree(notebook)

Un bloc de notas con cada grupo de secciones anidado resuelto

getFullTree()

Cada bloc de notas, cada uno con su árbol resuelto

getExpandedTree()

Cada bloc de notas con sus secciones y un nivel de grupo de secciones, en una sola solicitud

findSectionsByName(displayName)

Secciones en cualquier parte de la cuenta cuyo nombre contenga ese texto, cada una con su bloc de notas y grupo de secciones padre

findPagesByTitle(sectionId, title)

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. getNotebookTree recurre.

  • Paginación. Cada llamada de lista sigue @odata.nextLink hasta que deja de aparecer. Graph elige su propio tamaño de página e ignora un $top mayor, por lo que una respuesta nunca es prueba de que una colección esté completa. listPagesInSection se detiene tan pronto como tiene top elementos en mano, por lo que top es 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/pages falla 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 escanea src/ 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

src/multipart.ts

splitMultipart(body, contentType) → las partes, o null cuando la respuesta no es multipart

src/ink.ts

parseInkStrokes(text) → trazos; strokesToSvg; rasterizeSvg; renderInk(text, width?) → un PNG o null

src/page-content.ts

GraphPageContent.fetchRaw(pageId) → la respuesta dividida; .fetchInk(pageId) → el PNG o null

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-parser está configurado con removeNSPrefix: true y 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

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

Metadatos del servidor de autorización RFC 8414

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

Metadatos del recurso protegido RFC 9728

GET,POST /authorize

Endpoint de autorización

POST /consent

Donde el formulario de consentimiento publica; el router /authorize del SDK no posee ruta de reanudación

POST /token

Endpoint de token

curl -s localhost:8080/.well-known/oauth-authorization-server
curl -s localhost:8080/.well-known/oauth-protected-resource/mcp

Todo 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 week

Sin 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.

  1. En src/oauth-router.ts, vacía SCOPES_SUPPORTED. Claude añade offline_access a una solicitud de autorización solo cuando los metadatos lo anuncian, y ese es el interruptor que decide si pide un token de refresco.

  2. En src/oauth-provider.ts, elimina el campo refresh_token de lo que devuelve issueTokens. 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

MCP_KEEPALIVE_SECRET no está establecido en el servicio

Establécelo y vuelve a desplegar

503 con "retryable": true

Firestore no estaba accesible

Reintenta

503 con "retryable": false

La concesión está muerta

npm run bootstrap

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 POST /sync, eliminado al terminar

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 not

Dos 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

/sync/sweep/full nocturno sobre los cuadernos activos

~70 aquí, más una por página cambiada

/sync/sweep sobre secciones cuyo timestamp se movió

una por sección visitada

/sync/sweep/all sobre cada cuaderno reflejado

~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

*/20

hasta 360

la cadencia más rápida que no puede exceder el límite

*/15

hasta 480

por debajo mientras las ejecuciones estén limitadas por el reloj, por encima cuando no lo estén

*/10

hasta 720

por encima del límite siempre que las ejecuciones estén limitadas por solicitudes

*/5

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

notebookIds

array de string

Ids de cuaderno cuyas páginas están reflejadas

activeNotebookIds

array de string, opcional

Cuáles de esos todavía se están editando. Ausente o null significa todos.

note

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}.pagesSyncedThrough se 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.bytes del documento de página.

  • sectionsSkippedInactive es el número que esperas. Un recuento que cubre cada sección reflejada significa que el conjunto activo está vacío o mal escrito; unknownActiveNotebookIds en 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, outcome: complete

La ejecución terminó su trabajo

Nada

200, outcome: budget-exhausted

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

MIRROR_SYNC_SECRET no está configurado en el servicio

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, reason: silent-failed

El token de actualización de Microsoft ha desaparecido

Vuelve a ejecutar npm run bootstrap

503, reason: cache-unavailable

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.

  1. Actualización. La herramienta ejecuta una sincronización incremental por sí misma — el mismo runIncremental que ejecuta POST /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.

  2. Leer la copia local. Un acierto no cuesta ninguna solicitud de OneNote. Un fallo pasa a Microsoft Graph, exactamente como antes.

  3. Informar la fuente. source es onenote cuando la respuesta es igual a lo que tiene OneNote, best-available cuando parte proviene de un cuaderno que el operador congeló, y mirror cuando 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

source

La lectura pasó a Microsoft Graph

onenote

Un acierto local de un cuaderno activo, confirmado

onenote

Un acierto local de un cuaderno activo, no confirmado

mirror

Un acierto local de un cuaderno inactivo, confirmado o no

best-available

Una búsqueda sin ámbito que cubre ambos, confirmada

best-available

Una búsqueda sin ámbito que cubre ambos, no confirmada

mirror

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á CREATING falla con FAILED_PRECONDITION.

  • sync/state.backfillComplete es true. 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

graph-auth-failure con retryable: "false"

La concesión de Microsoft está muerta. Alguien tiene que ejecutar npm run bootstrap.

token-cache-write-refused

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=302

Bootstrap

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 bootstrap

Imprime 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.sh

Eso 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

GCP_PROJECT

variable

ID del proyecto

GCP_REGION

variable

Región de Cloud Run

GAR_REGION

variable

Región de Artifact Registry

WIF_PROVIDER

variable

Nombre completo del recurso del proveedor de identidad de carga de trabajo

DEPLOY_SA

variable

Correo de la cuenta de servicio de despliegue

RUNTIME_SA

variable

Correo de la cuenta de servicio de runtime

ONENOTE_CLIENT_ID

variable

ID de cliente del registro de aplicación de Azure

ONENOTE_AUTHORITY

variable

URL de autoridad de Entra

MCP_OAUTH_CLIENT_ID

variable

ID de cliente OAuth de capa 1

MCP_PUBLIC_URL

variable

URL pública del servicio; ver más abajo

FIRESTORE_CACHE_DOC

variable

Opcional; por defecto tokencache/msal

MCP_OAUTH_CLIENT_SECRET

secreto

Secreto de cliente OAuth de capa 1

MCP_TOKEN_SIGNING_KEY

secreto

Clave de firma de tokens de acceso, de al menos 32 caracteres

MCP_KEEPALIVE_SECRET

secreto

Opcional; si no se define, POST /keepalive no se monta

MIRROR_BUCKET

variable

Opcional; el bucket de Cloud Storage del espejo de páginas. scripts/gcp-bootstrap.sh imprime el nombre que creó

MIRROR_READ_ENABLED

variable

Opcional; por defecto false. El interruptor que hace que las herramientas de lectura actualicen y usen el espejo

MIRROR_ROOT_DOC

variable

Opcional; por defecto onenoteMirror/default

MIRROR_SYNC_REQUEST_BUDGET

variable

Opcional; por defecto 120

MIRROR_SYNC_SECRET

secreto

Opcional; si no se define, POST /sync no se monta

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>=100

Volver 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

ONENOTE_CLIENT_ID

sí

—

ID de cliente del registro de aplicación de Azure (cliente público)

ONENOTE_AUTHORITY

sí

—

URL de autoridad de Entra ID para el inquilino

MCP_OAUTH_CLIENT_ID

sí

—

ID de cliente OAuth de capa 1 que presenta Claude

MCP_OAUTH_CLIENT_SECRET

sí

—

Secreto de cliente OAuth de capa 1

MCP_TOKEN_SIGNING_KEY

sí

—

Clave usada para firmar los tokens de acceso emitidos (mín. 32 caracteres)

MCP_PUBLIC_URL

sí

—

La URL pública propia del servicio: https, sin consulta, sin fragmento, sin barra final

FIRESTORE_CACHE_DOC

servidor: no · bootstrap: sí

tokencache/msal

Ruta del documento de Firestore que contiene la caché de tokens de MSAL

GOOGLE_CLOUD_PROJECT

servidor: no · bootstrap: sí

—

Proyecto de GCP; se infiere automáticamente en Cloud Run

PORT

no

8080

Puerto de enlace. Cloud Run lo establece; el servidor nunca lo fija en el código.

MCP_KEEPALIVE_SECRET

no

—

Al menos 32 caracteres. Si se establece, se monta POST /keepalive; si se deja sin establecer, la ruta devuelve 404. Ver Keepalive.

MIRROR_ROOT_DOC

no

onenoteMirror/default

Documento de Firestore que contiene las listas editadas manualmente notebookIds y activeNotebookIds. Sus subcolecciones son el espejo. Ruta de documento, número par de segmentos, misma regla que FIRESTORE_CACHE_DOC.

MIRROR_SYNC_SECRET

no

—

Al menos 32 caracteres. Si se establece, se monta POST /sync; si se deja sin establecer, la ruta devuelve 404.

MIRROR_BUCKET

condicionalmente

—

Bucket de Cloud Storage para la tinta renderizada y el HTML de páginas sobredimensionadas. Obligatorio una vez que se establece MIRROR_SYNC_SECRET o MIRROR_READ_ENABLED; ver más abajo.

MIRROR_READ_ENABLED

no

false

true o false, nada más. Cuando es true, las herramientas de lectura actualizan el espejo, responden desde él y recurren a Graph si no hay coincidencia.

MIRROR_SYNC_REQUEST_BUDGET

no

120

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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables interaction with Microsoft OneNote via the Microsoft Graph API, allowing users to list notebooks and retrieve page content. It supports both personal and organization notebooks with credential caching for efficient authentication.
    21 npm
    3
    MIT
  • A
    license
    D
    quality
    C
    maintenance
    Enables AI assistants to securely interact with Microsoft OneNote data through the Microsoft Graph API. It supports comprehensive management tasks including searching page content, creating and editing notes, and automating productivity workflows like daily note creation.
    20
    3
    MIT