onenote-mcp
onenote-mcp
一个通过 Microsoft Graph 暴露 Microsoft OneNote 的 MCP 服务器——笔记本和分区结构、页面内容,以及渲染为图像供调用模型阅读的手写内容。
[project-spec.md`](./project-spec.md) 是权威设计文档。 它涵盖了墨迹重建流水线、两个独立的 OAuth 层、Cloud Run 部署模型,以及基于 Firestore 的令牌缓存。在改动这里的任何内容之前,请先阅读它。
要求
Node >= 24。@google-cloud/firestore 要求 Node >= 22,而 Node 24 是当前的 Active LTS。
Related MCP server: OneNoteMCP
快速开始
npm ci
npm run build
npm test脚本
脚本 | 作用 |
| 将 |
| 仅做类型检查,不输出 |
| 使用 |
| 从 |
| 本地设备码登录,用于填充 Firestore 令牌缓存 |
| 对 |
测试位于 test/ 中,与 src/ 一一对应。它们使用 Node 的原生类型剥离功能直接针对 TypeScript 源码运行,因此 npm test 不需要构建。这给源码带来了一些约束:不能使用 enum、namespace、构造函数参数属性,且仅类型导入必须写成 import type。erasableSyntaxOnly 和 verbatimModuleSyntax 编译器选项强制执行这些约束。
目录布局及其配套约定请参阅 CLAUDE.md。
令牌缓存
src/token-cache.ts 针对单个 Firestore 文档实现了 MSAL 的 ICachePlugin,该文档的路径来自 FIRESTORE_CACHE_DOC。beforeCacheAccess 读取文档的 cache 字段并将字符串交给 MSAL。afterCacheAccess 在 Firestore 事务中将序列化后的缓存写回,并且仅在 MSAL 报告缓存已更改时才写入。不存在的文档会被读取为空缓存,也就是运行 npm run bootstrap 之前的状态。两个入口都使用同一个插件:bootstrap CLI 通过它写入缓存,服务器通过它读取缓存,因此只有一个序列化器,不需要维护第二种格式。
该 blob 是刷新令牌的唯一副本,因此有两重保护。
会清空文档的写入会被拒绝。 MSAL 在某些失败情况下会从其内存缓存中移除凭据,而 afterCacheAccess 在 MSAL 的 finally 块内运行,因此丢失了账户的序列化结果可能到达此代码,而此时存储的版本仍然有效。overwriteWouldEmptyCache 会阻止这种情况并记录 {"event":"token-cache-write-refused"}。空检查不读取任何 MSAL 键名——当缓存解析为对象且每个值都是空容器时即为空——因此即使 MSAL 改变其格式也不会失效,而且任何无法识别的内容都会被放行而不是被阻止。
每次写入所替换的 blob 会保存在 previousCache 字段中。 只保留一代,而非完整历史:缓存每次刷新时都会被重写,而有用的副本始终是最近一次完好的那个。从一次错误写入中恢复的方法是在 Firestore 控制台中将该字段复制到 cache 上,这值得做,因为替代方案是重新进行设备码登录。如需第二层保护,请开启时间点恢复:
gcloud firestore databases update --enable-pitr后端故障不是凭据故障。 Firestore 不可达,或 roles/datastore.user 绑定被撤销,会抛出 TokenCacheUnavailableError,而不会表现为死刷新令牌所产生的错误。在此之前,写入会重试三次。为什么这个区别值得写这些代码,请参见下表中的 cache-unavailable 行。
npm test 只覆盖 readCache,即解码文档快照的函数。两个回调、事务和 createFirestoreTokenCachePlugin 没有自动化测试——它们需要 Firestore 后端。要测试它们就需要模拟器,这要求 PATH 上有 java 并且需要单独安装:
sudo apt-get install google-cloud-cli-firestore-emulatorgcloud components install cloud-firestore-emulator 不会安装在基于 Debian 打包的 Google Cloud CLI 上。该构建中组件管理器被禁用,gcloud 会打印上面那条 apt-get 命令作为替代。
Graph 认证
src/graph-auth.ts 将已填充的令牌缓存转换为 Microsoft Graph 访问令牌。createGraphAuth 根据 ONENOTE_CLIENT_ID、ONENOTE_AUTHORITY 和 Firestore 缓存插件构建一个 PublicClientApplication,并在进程生命周期内持有它。getAccessToken() 读取缓存的账户,调用 acquireTokenSilent,然后返回令牌。请求的作用域是 Notes.Read 和 Notes.ReadWrite,使用完全限定名。
部署的服务器从不进行交互式登录。它没有任何方式提示用户,而 Graph 的 OneNote 端点不支持仅应用认证,因此当存储的刷新令牌失效时没有回退方案——需要人工重新运行 npm run bootstrap。因此,每次失败都会抛出说明此情况的 GraphAuthError,而不是原始的 MSAL 错误(后者会以来自 Graph 的裸 401 到达调用方):
| 发生了什么 | 该怎么做 |
| Firestore 文档不存在,或其 |
|
| Firestore 未响应,或运行时服务账户失去了 | 重试。不是登录。 |
| 缓存已读取但不包含已登录的账户 |
|
| 存储的刷新令牌已过期或被撤销,或令牌端点未返回任何可用内容 |
|
cache-unavailable 是值得保留的那一行。Firestore 的读写发生在 acquireTokenSilent 内部,通过缓存插件进行,因此后端中断过去会以与死刷新令牌相同的拒绝形式出现——而那条消息会告诉操作员去浏览器替换一个仍然有效的凭据。GraphAuthError.retryable 承载了这一区别,并且只有该原因会设置它。
以上每一种情况还会向 stderr 写入一行:
{"event":"graph-auth-failure","reason":"silent-failed","documentPath":"tokencache/msal","retryable":"false"}这一行是关键。否则工具故障只会出现在 Claude 对话内部,没有它就没有任何东西告诉操作员连接器已停止工作。请参阅下面的告警。
消息中会指明文档路径和底层 MSAL 错误,并且刻意不携带任何账户标识符:username 是用户的 UPN,homeAccountId 嵌入了租户 ID,两者都不应出现在日志中。
npm test 通过一个假客户端覆盖了获取逻辑。createGraphAuth 本身没有自动化测试:它需要一个由真实设备码登录填充的缓存,而任何能填充缓存的凭据都不得提交。运行 npm run bootstrap,然后让服务器针对同一文档运行以测试它。它的消费者是下面的 Graph 结构客户端;目前两者都还没有接入 createApp。
Graph 结构
src/graph-structure.ts 读取 OneNote 树:笔记本、分区组、分区,以及某个分区内的页面列表。new GraphStructure(auth) 接受任何具有 getAccessToken() 的对象,因此服务器会将上面的 GraphAuth 传给它。
方法 | 返回 |
| 所有笔记本,按显示名称列出 |
| 笔记本或分区组下的直接子分区 |
| 笔记本或分区组下的直接子分区组 |
| 上述两者,一起获取 |
| 某个分区中的页面,最近修改的在前,最多 |
| 一个笔记本,所有嵌套的分区组均已解析 |
| 所有笔记本,每个都解析出其完整树 |
| 所有笔记本及其分区和一层分区组,通过单个请求获取 |
| 账户中名称包含该文本的分区,每个都带有其父笔记本和分区组 |
| 某个分区中标题匹配的页面,由 Graph 进行不区分大小写的比较 |
containerKind 是 notebooks 或 sectionGroups——Graph 的两个关系名称。两种容器类型暴露相同的子关系,这就是列表方法接受类型参数而不是各写一份的原因。
getExpandedTree() 是廉价的那个。它让 Graph 展开关系而不是自行遍历:
GET /me/onenote/notebooks?$select=id,displayName
&$expand=sections($select=id,displayName),
sectionGroups($select=id,displayName;$expand=sections($select=id,displayName))针对一个包含 54 个笔记本的账户实测:1 个请求和 78 KB,而 getFullTree() 需要 195 个请求,这很重要,因为 OneNote 允许每小时 400 个请求和 5 个并发。每个展开子句中的 $select 将响应从 441 KB 降到 78 KB,而同时携带 $select 和 $expand 的子句内部使用分号作为分隔符。它无法触及的是嵌套在分区组内的分区组——Graph 将 $expand 的嵌套限制为两层——因此 findSectionsByName 改为通过单个请求覆盖这种情况:过滤账户级分区列表并展开每个分区的父级。
api-overview.md 记录了这些端点接受的内容,包括服务与其自身文档相矛盾的地方。
遍历处理了单个 Graph 调用无法处理的三种情况:
嵌套。 分区组是 UI 中的"选项卡组",它们还包含更深层的分区组。
getNotebookTree会递归。分页。 每个列表调用都会跟随
@odata.nextLink,直到它不再出现。Graph 自行选择页面大小并忽略更大的$top,因此单个响应永远不能证明集合是完整的。listPagesInSection一旦拿到top个项目就停止,因此top是结果数量而不是页面大小。绝不调用账户级页面列表。 在按年建笔记本的结构下,
GET /me/onenote/pages会以错误 20266"超出最大分区数"失败。页面列表始终限定在/me/onenote/sections/{id}/pages范围内,并且有一个测试会扫描src/以检查账户级路径。
失败是 GraphRequestError,对应非 2xx 响应——它携带 status、statusText 和响应 body,因为错误 20266 只能通过该文本与其他 400 区分——以及 GraphResponseError,对应 2xx 但 body 不是预期形状、列表不会终止、或节组嵌套超过 20 层的情况。没有任何消息包含笔记本、节或页面名称。
npm test 通过一个按精确 URL 键控的假 fetch 驱动所有内容。它无法检查的是 Graph 是否接受这些 URL;查询字符串来自 project-spec.md 附录 A 中经过验证的 recon 脚本,并且只能通过针对真实租户运行来确认。
墨迹
Graph 的常规页面内容端点会丢弃手写内容,并留下 <!-- InkNode is not supported -->,而 Graph 无法将页面导出为图像或 PDF。因此,手写内容从原始笔划数据重建:
GET /me/onenote/pages/{id}/content?includeInkML=true 返回 multipart/mixed,一部分是相同的 HTML,另一部分是 InkML。笔划变成 SVG,然后变成 PNG,作为图像发送给调用模型,供其自己的视觉读取。不涉及 OCR 服务。
模块 | 功能 |
|
|
|
|
|
|
四个细节决定了这是否能正常工作,而所有四个都来自 project-spec.md 附录 A 中经过验证的 recon 脚本:
命名空间被剥离。 Graph 发出
inkml:ink、inkml:trace、inkml:traceFormat。fast-xml-parser配置了removeNSPrefix: true,每次查找都使用裸名称。通道顺序来自
<traceFormat>。 此账户的点是 X、Y、F,其中 F 是笔压。读取每个点的前两个数字会将压力绘制为坐标。坐标是 himetric。
px = himetric * 96 / 2540。这与页面 HTML 定位键入内容所使用的坐标空间相同,因此墨迹和键入内容以后可以通过算术相互配准。轨迹在树中的任何位置。 页面可以携带多个
<ink>根,并且<traceGroup>元素可以嵌套。所有都会被收集。
没有墨迹的页面渲染为 null。这是键入页面的正常答案,而不是错误。确实会引发的失败是 InkParseError(用于嵌套超过 50 层的轨迹组)和 InkRenderError(用于 resvg 拒绝的文档);两者都不会重现任何文档内容,因为笔划坐标是用户的手写内容。
test/fixtures/*.inkml 是手工编写的——几个笔划、X/Y/F 通道顺序、himetric 单位、一个文件包含两个 <ink> 根和嵌套的 <traceGroup> 元素。不得提交捕获的页面转储:渲染的墨迹是完全可读的个人笔记。
MCP 端点
服务器在 POST /mcp 上通过无状态 Streamable HTTP 提供 MCP。每个请求构建自己的 MCP 服务器,应答,然后拆除;没有任何内容保留到下一个请求。没有会话 id,也没有 SSE——GET /mcp 和 DELETE /mcp 返回 405,而 POST 返回 JSON body 而不是打开流。打开的流会保持 Cloud Run 实例存活并产生空闲费用。
curl -s -X POST localhost:8080/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# {"result":{"tools":[]},"jsonrpc":"2.0","id":1}Streamable HTTP 规范要求两种 Accept 类型,即使此服务器从不流式传输。src/tools.ts 中的 createTools 是注册表——六个浏览工具(list_notebooks、list_sections、list_pages、search_pages、find_page_by_name、list_pages_by_name)、一个读取工具(get_page_content)和五个写入工具(append_to_page、append_to_page_by_name、create_page、create_page_by_name、update_page_title)——而 src/mcp-server.ts 是围绕它们的 JSON-RPC 表面。
抛出的工具会作为 isError: true 的工具结果返回,并带有可读消息——过期的刷新令牌、已消失的页面以及 resvg 拒绝的文档都是正常结果,而不是协议故障。只有调用从未注册的工具才是 JSON-RPC 错误。
每个请求写入一行 JSON 日志:HTTP 动词、路径、状态、持续时间、JSON-RPC 方法,以及 tools/call 上的工具名称。绝不包含查询字符串、标头、参数或结果——参见 src/logging.ts。
/mcp 受 bearer 令牌保护——参见 MCP 端点上的 Bearer 令牌。健康端点保持开放。
OAuth 发现
Claude 必须先找到授权服务器,然后才能开始流程。src/oauth-router.ts 将 SDK 的 mcpAuthRouter 挂载在应用程序根目录——它根据 issuer URL 而不是挂载点构建路径,因此不能放在前缀后面——并提供五个路由,所有这些路由都必然未经过身份验证:
路径 | 是什么 |
| RFC 8414 授权服务器元数据 |
| RFC 9728 受保护资源元数据 |
| 授权端点 |
| 同意表单回发的位置;SDK 的 |
| 令牌端点 |
curl -s localhost:8080/.well-known/oauth-authorization-server
curl -s localhost:8080/.well-known/oauth-protected-resource/mcp两个文档中的所有内容都源自 MCP_PUBLIC_URL:它是 issuer,而 resource 标识符是它加上 /mcp。如果 MCP_PUBLIC_URL 带有尾部斜杠,则在启动时被拒绝,这样通过将路径连接到它而构建的每个 URL 都是格式良好的;issuer 字段随后报告 URL 规范化形式,对于仅 origin 的值,该形式是添加了尾部斜杠的相同字符串。受保护资源文档仅在带路径后缀的 URL 处提供——裸的 /.well-known/oauth-protected-resource 是 404,/.well-known/openid-configuration 也是。Claude 首先探测带后缀的路径。
scopes_supported 列出 offline_access,这使 Claude 在访问令牌过期时请求刷新令牌,而不是重新同意。没有 registration_endpoint:客户端 id 和 secret 已配置,因此动态客户端注册无事可做。注册了一个客户端,具有三个重定向 URI——https://claude.ai/api/mcp/auth_callback 用于托管的 Claude 界面,以及 http://localhost/callback 和 http://127.0.0.1/callback 用于 Claude Code,其端口根据 RFC 8252 被忽略。
GET /authorize 渲染同意页面而不是重定向:一个批准按钮,说明授予的内容以及授权代码将发送到的主机。批准回发到 POST /consent,后者生成一个 60 秒的一次性代码并重定向到客户端的回调。整个授权请求通过一个隐藏字段跨越该页面,该字段使用 MCP_TOKEN_SIGNING_KEY 签名,因此同意期间的实例替换不会破坏流程,并且表单无法被编辑;验证失败的字段是 400,没有重定向,也不会生成代码。
两个同意响应都携带 Cache-Control: no-store、Referrer-Policy: no-referrer——表单从 /authorize URL 回发,该 URL 的查询字符串中有 state 和 PKCE 挑战——X-Frame-Options: DENY,以及 CSP default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'。故意没有 form-action:浏览器对于是否针对重定向目标检查它存在分歧,而同意 POST 以重定向到 claude.ai 作为回答。
POST /consent 有自己的速率限制——15 分钟内 200 次——因为它有意挂载在 SDK 的 /authorize 限制器之前,并且渲染的表单在十分钟内保持可回发,因此一次通过 /authorize 会产生一个可以重放的字段。该限制位于 100 条待处理代码上限之上,因此存储自身的驱逐(其行为已指定)是突发流量首先遇到的东西。
POST /token 颁发有效期为一小时的访问令牌和有效期为 30 天的刷新令牌。两者都是 MCP_TOKEN_SIGNING_KEY 下紧凑负载的 HMAC-SHA256,没有其他内容——不查询存储来验证,这正是 Cloud Run 修订版替换不会强制重新连接的原因。负载携带受众,即 MCP_PUBLIC_URL 加上 /mcp,因此令牌仅对此 MCP 端点有效,对其他端点无效。这些令牌的寿命以及如何让人类更频繁地批准,在下面两节中说明。
MCP 端点上的 Bearer 令牌
对 /mcp 的每个请求都需要 Authorization: Bearer <access token>。SDK 的 requireBearerAuth 位于 createApp 中 MCP 路由器之前,而 src/oauth-provider.ts 中的 verifyAccessToken 是它调用的:MCP_TOKEN_SIGNING_KEY 下的 HMAC 签名、令牌类型、过期时间和受众。签名正确且未过期但携带另一个服务器资源标识符的令牌会被拒绝——SDK 本身不检查受众,因此如果没有该检查,由共享此签名密钥的服务器为另一个 MCP 服务器签发的令牌将被接受。
没有令牌、令牌过期或任何检查失败的请求都会返回 401,并带有挑战标头:
WWW-Authenticate: Bearer error="invalid_token", error_description="…",
resource_metadata="https://<MCP_PUBLIC_URL>/.well-known/oauth-protected-resource/mcp"resource_metadata 参数是重要的部分:它是 Claude 找到授权服务器并启动流程的方式,因此没有它的 401 是死胡同,而不是登录提示。Claude 在 401 时被动刷新,并在存储的过期时间前几分钟主动刷新,因此这里的 401 是普通事件。
令牌从 Authorization 标头读取,不从其他任何地方读取。查询字符串中的 ?access_token= 不被接受——MCP 授权规范禁止它,而 src/logging.ts 正是基于此将查询字符串排除在日志行之外。
哪些路由是开放的是豁免列表,它比“除 /mcp 之外的所有内容”更长,因为整个授权流程必须回答尚未持有令牌的调用者:/healthz 和 /health、两个 .well-known 文档、/authorize、/consent 和 /token。test/server.test.ts 中的一个测试枚举了 createApp 实际注册的路由,并断言不在该列表中的每个路由在没有令牌时都返回 401,因此以后添加的路由默认是关闭的,除非有人有意打开它。
不需要作用域。offline_access 是此服务器颁发的唯一作用域,它关乎是否授予刷新令牌,而不是调用者可以做什么,要求它会为其他方面有效的令牌返回 403。如果将来添加作用域检查,403 必须携带 WWW-Authenticate: Bearer error="insufficient_scope"——此中间件确实如此——因为 Claude 将任何其他 403 视为终止性的,并且不会提示任何内容。
令牌生命周期和强制重新验证
此服务器设计为无人值守运行。默认设置反映了这一点,它们牺牲了切断已泄露凭据的部分能力。在将其部署到重要位置之前,请阅读本文,如果这种取舍对您不合适,请更改这些数值。
默认设置的作用
令牌 | 有效期 | 续期方式 |
访问令牌 | 1 小时 | 刷新令牌,自动续期 |
刷新令牌 | 30 天 | 每次刷新都会签发一个新的,并重新获得 30 天有效期 |
同意表单 | 10 分钟 | 无;过期的表单会被拒绝,流程重新开始 |
Claude 会自动刷新——既会在到期前一小时主动刷新,也会在收到 401 时被动刷新。因此,用户在首次添加连接器时点击一次"批准",之后仅当连接器连续 30 天未被使用时才需要再次点击。这就是滑动窗口:30 天限制的是连接可能处于空闲状态的时间,而非其总存活时间。
为什么采用滑动窗口,以及其代价
此服务器签发的每个令牌都是无状态的。它只是一个签名负载,仅此而已——没有数据库行、没有会话记录、没有回传时需要查询的任何内容。这正是 Cloud Run 修订版本替换时对用户不可见的原因:新实例可以验证旧实例签发的令牌,而两者之间无需共享状态。如果引入令牌存储,则每次部署都需要重新连接。
代价是没有任何令牌可以被单独撤销。没有撤销端点,因为没有任何记录可供删除。具体来说:
泄露的刷新令牌可在最多 30 天内授予访问权限,且每次使用都会将持有者的访问权限再延长 30 天。服务器端没有任何记录可供作废,也无法区分被盗的刷新令牌与合法令牌——两者是由同一密钥签名的相同字节。
滑动窗口不是轮换。当刷新操作签发新的刷新令牌时,被替换的旧令牌在其内部盖印的到期时间之前仍然有效。真正的轮换意味着将旧令牌标记为已使用,这需要本设计所没有的存储。
访问令牌在其一小时内无法被切断,原因相同。
剩下的只有一个直接有效的杠杆:更换 MCP_TOKEN_SIGNING_KEY 并重新部署。 所有访问令牌、所有刷新令牌以及所有未关闭的同意页面会立即失效,因为它们都通过该密钥进行验证。Claude 的下一个请求会收到 401,操作员点击一次"批准"即可。按计划轮换密钥本身就是一项合理的策略。
同意页面本身并不认证任何人——它只有一个按钮,没有密码。陌生人访问您的笔记本之间隔着的是 MCP_OAUTH_CLIENT_SECRET(POST /token 要求提供)、将每个授权码发送到 claude.ai 或回环地址的重定向 URI 白名单,以及将授权码绑定到发起流程的客户端的 PKCE。
让用户更频繁地批准
以下每一项都是源代码修改,而非配置值。这是有意为之:缩短窗口的操作员正在改变部署的安全态势,这应该放在一个有人能阅读的提交中,而不是放在一个可能被人遗忘的环境变量里。
缩短空闲窗口。 在 src/oauth-provider.ts 中:
const REFRESH_TOKEN_TTL_S = 30 * 24 * 60 * 60; // 30 days
const REFRESH_TOKEN_TTL_S = 7 * 24 * 60 * 60; // a week闲置超过该时长后,连接器需要一次点击。如果定期使用,它仍然不会要求批准——窗口会不断向前滑动。这限制了泄露的刷新令牌在泄露停止被使用后还能存活多久,仅此而已。
停止窗口滑动。 这正是 issue #22 最初的要求,它限制的是连接的总存活时间而非空闲时间:无论连接器多繁忙,用户每 30 天批准一次。在 src/oauth-provider.ts 的 exchangeRefreshToken 中修改一行:
// 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);完全拒绝签发刷新令牌。 最严格的设置:用户每小时批准一次,因为过期的访问令牌没有任何东西可以续期。需要两处修改,缺一不可——仅修改元数据开关并不能阻止令牌被签发。
在
src/oauth-router.ts中,清空SCOPES_SUPPORTED。只有当元数据声明支持offline_access时,Claude 才会在授权请求中附加该作用域,而这正是决定它是否请求刷新令牌的开关。在
src/oauth-provider.ts中,从issueTokens的返回值中删除refresh_token字段。目前无论请求了哪些作用域,该字段都会被签发。
可以预期这一设置在运行中可见:当一小时到期时,Claude 会在会话中途将浏览器送回同意页面。
缩短访问令牌有效期。 src/oauth-provider.ts 中的 ACCESS_TOKEN_TTL_S 缩小了泄露的访问令牌可被使用的时间窗口。其代价是每次到期产生一次令牌请求,且完全不需要人工参与,因此成本很低——但它对泄露的刷新令牌毫无作用,而刷新令牌才是真正值得担心的凭据。
保活
Microsoft 的委派刷新令牌在大约 90 天未使用后失效。令牌只有在实际交换时才会向前滑动,而交换只发生在工具调用到达且持有的访问令牌已过期之后——因此,一个三个月无人使用的连接器就需要有人在浏览器中运行 npm run bootstrap。服务器本身无法阻止这种情况,因为当没有人调用它时,服务器中没有任何东西在运行。
POST /keepalive 就是解决方案。它调用 acquireTokenSilent 并传入 forceRefresh: true,这会跳过持有的访问令牌并直接交换刷新令牌,因此 Entra 会签发一个具有全新窗口的替换令牌,src/token-cache.ts 将其写入 Firestore。forceRefresh 是关键部分:没有它,MSAL 会从自己的缓存中应答,不会有请求到达 Entra,窗口也不会移动。
将 MCP_KEEPALIVE_SECRET 设置为至少 32 个随机字符后,该路由即被挂载;保持未设置则路径返回 404。调度器在 X-Keepalive-Secret 头中提供该密钥,在任何工作开始之前以恒定时间进行比较。它使用共享密钥而非不记名令牌,因为调度器无法运行 OAuth 流程——它没有浏览器,也没有地方保存刷新令牌——并且它使用独立的变量而非 Layer-1 客户端密钥,这样能够触达整个 MCP 表面的凭据不会同时存放在调度器作业中。
在自己的账户上设置
四个步骤:生成密钥、将其部署到服务上、创建作业、确认作业确实能到达服务。整个过程是可选的——跳过它服务器也能正常工作,直到某一天它已经 90 天无人调用。
# 1. Generate the secret. umask 077 so the file is not world-readable, and it is never
# printed: the two commands below read it from disk.
umask 077
openssl rand -hex 32 > keepalive.secret
# 2. Get it onto the service. The GitHub secret alone changes nothing — the value
# reaches the container as an env var, which happens on a deploy and only then.
gh secret set MCP_KEEPALIVE_SECRET < keepalive.secret
gh workflow run deploy.yml --ref main
# 3. Create the job. The API enable takes a minute or two to propagate; a create run
# immediately after it fails with SERVICE_DISABLED, which is a retry rather than a
# misconfiguration.
gcloud services enable cloudscheduler.googleapis.com --project="$GCP_PROJECT"
gcloud scheduler jobs create http onenote-mcp-keepalive \
--project="$GCP_PROJECT" \
--location="$GCP_REGION" \
--schedule="0 4 * * 1" \
--time-zone=UTC \
--uri="$MCP_PUBLIC_URL/keepalive" \
--http-method=POST \
--headers="X-Keepalive-Secret=$(cat keepalive.secret)" \
--attempt-deadline=60s \
--max-retry-attempts=3
rm keepalive.secret--location 是必需的,它是调度器自身的区域,与作业目标的位置无关;使用 Cloud Run 区域可以少记一个值。scripts/gcp-bootstrap.sh 也会启用 cloudscheduler.googleapis.com,因此第 3 步的启用操作仅对在该行代码存在之前完成引导的项目才需要。
然后验证它,因为这里的每种失败模式都是静默的——密钥错误、作业指向旧 URL、服务部署时未包含该变量,这些看起来都像一个已启用但闲置的调度器作业:
gcloud scheduler jobs run onenote-mcp-keepalive --project="$GCP_PROJECT" --location="$GCP_REGION"
# An empty status code is success. A code is a gRPC status; the request log below says why.
gcloud scheduler jobs describe onenote-mcp-keepalive \
--project="$GCP_PROJECT" --location="$GCP_REGION" \
--format='value(status.code,lastAttemptTime,scheduleTime)'
# The request as the service saw it. 200 is the answer; 401 is a secret mismatch and 404
# means the deploy in step 2 did not happen.
gcloud logging read \
'resource.type="cloud_run_revision" AND httpRequest.requestUrl:"keepalive"' \
--project="$GCP_PROJECT" --limit=3 --freshness=10m \
--format='value(timestamp,httpRequest.requestMethod,httpRequest.status,httpRequest.userAgent)'返回 200 表示令牌已交换。证明令牌已存储的依据是 Firestore 文档的 updatedAt 字段移动到运行时间,这是替换刷新令牌在调用后存活的唯一证据。在此部署上于 2026-08-19 测量:强制运行返回 200,tokencache/msal 从 14:15:53Z 推进到 14:30:54Z。
每周一次对于 90 天的窗口来说绰绰有余,并且为多次错过运行留有余地。该作业的成本是一次令牌端点往返和一次 Firestore 写入。
轮换密钥:设置新的 GitHub 密钥,部署,然后执行 gcloud scheduler jobs update http onenote-mcp-keepalive --update-headers=…。从部署到作业更新之间的任何运行都会返回 401 且不执行任何工作,在每周一次的调度下,这个窗口内不会有任何运行落入其中。
该头信息位于作业定义中,任何对项目拥有 roles/cloudscheduler.viewer 权限的人都可以读取,正如环境变量在 Cloud Run 修订版本规范中可读一样。这与部署部分中不运行 Secret Manager 所采取的取舍相同。
状态 | 含义 | 调度器应执行的操作 |
200 | 刷新令牌已交换且新令牌已存储 | 无需操作 |
401 | 密钥缺失或错误 | 修复作业;路由未执行任何工作 |
404 | 服务上未设置 | 设置并重新部署 |
503 且 | Firestore 不可达 | 重试 |
503 且 | 授权已失效 | 运行 |
这不能防护的情况:条件访问登录频率策略、密码更改、MFA 重置或管理员撤销授权。其中任何一种都会使刷新令牌失效,无论调度计划如何,也没有任何代码修改可以避免。如果 Entra 租户是您自己的,请将此应用注册从登录频率策略中豁免;如果不是,请将 90 天视为一个上限,其他人可以在不通知您的情况下缩短它。
保活路由也与下方令牌有效期中的 Layer-1 30 天窗口无关。那个刷新令牌存在于 Claude 的连接器存储中,只有 Claude 才能出示它或接收其替换令牌,因此此处运行的任何内容都无法使其保持存活。丢失它只需在"批准"按钮上点击一次;丢失 Microsoft 的那个则需要设备代码登录。
页面镜像同步
镜像在 Firestore 中保存一组精选笔记本中页面的副本,以便读取工具无需消耗 OneNote 的请求预算即可应答。POST /sync 是填充镜像的方式。其中没有任何自动化成分:没有密钥就不会挂载该路由,没有 MIRROR_READ_ENABLED 读取工具也不会查询镜像。
什么保持其最新
三种机制。只有两种按计划运行,其中一种是临时的。
运行方式 | 覆盖范围 | |
初始回填 | 一个 | 每个被镜像的笔记本,无论是否活跃,填充一次 |
持续追赶 | 每次被覆盖的读取,内联执行,在应答之前 | 活跃笔记本中创建和编辑的页面 |
删除清理 | 一个每晚运行的调度器作业 | 活跃笔记本中被删除的页面 |
稳态下没有计划性的增量同步,因为读取操作本身完成了这项工作。 每次被覆盖的读取在应答之前都会自行运行 runIncremental,最多 12 个请求和 15 秒,且每 30 秒最多一次——参见下方从中读取。因此,镜像在有人即将查看它时被刷新,否则不会刷新。如果在此基础上再增加一个计划性的增量运行,那将花费同样的每小时 400 个请求来刷新无人请求的数据,而下一次读取无论如何都会再次刷新。
读取操作无法察觉删除,这正是夜间清扫存在的全部原因。
增量遍历以父分区的 lastModifiedDateTime 为键——创建、编辑和删除都会使其变动——然后向该分区请求其已更改的页面。被删除的页面不会出现在任何列表中,因此该响应中没有任何信息表明它已消失。要发现这一点,需要枚举分区的页面 ID 并与镜像进行核对,因为 Graph 对任何 OneNote 资源都没有 /delta,也没有已删除页面的墓碑标记。这就是一次清扫,将其放在读取之前成本过高,而且在清扫运行之前,镜像会持续提供已不存在的页面。一夜的陈旧性正是此设计所接受的。
回填两者皆非,因此它有自己的调度,直到完成。 它是对每个分区从纪元开始的一次列表,加上每个页面的一次内容获取——在此账户上相当于数千个请求——而内联刷新的 12 个请求预算需要数周才能覆盖。一旦 sync/state.backfillComplete 为 true(即每个镜像分区都有非空的 pagesSyncedThrough),就删除该任务。没有其他调用 POST /sync。
升级后的第一次结构写入会重写每个文档一次。 结构文档携带一个 identity 字符串,说明树对它们的描述,而在该字段存在之前写入的文档没有该字符串——因此不匹配,会被重写。这是一次 Firestore 写入,没有 Graph 请求,且只发生一次。
非活动笔记本会被回填一次,然后冻结。 选择文档中的 activeNotebookIds 划定了这条线——参见选择要镜像的内容。一个被选中但非活动的笔记本由回填填充,此后一直从镜像提供;没有内联刷新,也没有清扫会再次访问它。这正是让 55 个笔记本的账户可负担镜像的原因:归档被列出并获取一次,之后不再触碰。
四条路由
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其中两条按计划运行:回填期间的 /sync,以及每晚的 /sync/sweep/full。/sync/sweep 是更便宜的范围变体,适合手动使用。/sync/sweep/all 是唯一没有活动过滤器的模式,也是唯一能注意到非活动笔记本内删除的方式;它每个镜像分区花费一个请求,因此应谨慎运行,而不是按定时器运行。
成本
OneNote 允许委派应用每小时 400 个请求,而每次交互式工具调用都来自这 400 个。此服务运行的账户持有 55 个笔记本和 568 个分区;其中 4 个笔记本和 202 个分区被镜像,2 个笔记本和 70 个分区处于活动状态。
运行方式 | 请求数 |
内联刷新,无变化 | 1 — 展开的树,仅此而已 |
内联刷新,少量编辑页面 | 2–12;仅对真正更改的页面进行获取,12 是硬性预算 |
活动笔记本上的夜间 | 此处约 70,加上每个更改页面一个 |
对时间戳移动的分区执行 | 每个访问的分区一个 |
对所有镜像笔记本执行 | 此处约 202 — 占每小时预算的一半 |
一次回填运行 | 实测 49–120;以先触达的预算为准停止 |
未更改的页面不花费任何请求。 更改页面列表携带每个页面的戳记和标题,如果存储副本与两者都匹配,则不会再次获取。因此,运行成本跟踪的是真正更改的页面,而不是过去一小时内编辑的页面,运行报告中的 pagesSkipped 表示以这种方式跳过的数量。
两个重叠窗口宽度不同,这是有意为之。WATERMARK_OVERLAP_MS 为一小时,决定分区列表返回哪些页面;它呈现的内容不花费请求,因此保持余量。SECTION_SCAN_OVERLAP_MS 为十五分钟,决定一个被编辑一次的分区持续被访问多久,每次运行一个列表请求——一小时的该值曾是值得削减的最后一项。两者都是针对 Graph 传播延迟及其时钟与此服务时钟之间差距的余量;证明其有效下的两个日志查询正是判断十五分钟是否合适的依据。
MIRROR_SYNC_REQUEST_BUDGET(默认 120)是硬性停止。达到该值的运行会提交已完成的工作,以 "done": false 返回 200,并在下一个计划时恢复。这正是首次回填得以实现的原因。
两个停止条件哪个先触发会变化,因此应根据请求预算而非观察到的平均值来调整节奏。 在 2026-08-20 上对六次运行的测量:49、57、60、63、120 和 120 个请求。前四次在 240 秒时钟内结束,预算有剩余——一个页面需要一次 Graph 获取、一次 resvg 墨水渲染和两次 blob 写入,而渲染在 1-CPU 实例上占满了时钟。后两次在时钟内花光了全部预算,用于渲染成本更低的页面。
节奏 | 预算 120 时的请求/小时 | |
| 最多 360 | 无法超过限制的最快节奏 |
| 最多 480 | 在运行受时钟限制时低于限制,否则超过 |
| 最多 720 | 只要运行受请求限制就会超过限制 |
| 最多 1440 | 在 2026-08-20 上对此账户的测量:45 分钟内 429 秒 |
超过每小时 400 后,Graph 以 OData 代码 10007 返回 429。被拒绝的请求不执行任何工作,但仍消耗尝试,分区的 watermark 不会前进,惩罚比短暂退避更持久——一次突发后,跨越三分钟的五次重试全部被拒绝,记录在 docs/graph.md 中。因此,超过限制并不会更快完成回填。将 MIRROR_SYNC_REQUEST_BUDGET 减半,可以在相同每小时消耗下获得减半的间隔,代价是每次运行多一次树读取。
此账户上的回填大小大约为每个分区一个请求加上每个页面一个请求。202 个分区,每个约 24 页——根据前四个完成列表的分区估算,而非实测——大约 4,800 页和 5,000 个请求,因此无论节奏如何,下限约为 12.5 小时的切片。
当 MIRROR_READ_ENABLED=false 时,完全没有持续的追赶。 这是当前部署的设置,因此回填任务和夜间清扫是唯一接触镜像的东西。开启读取才使上述读取驱动设计成为现实,并且每次刷新最多增加 12 个请求,每 30 秒最多一个——在持续忙碌的对话中,大约每小时 120 个请求,在镜像落后时最多 144 个。在回填任务消失后,该部分与夜间清扫一起可容纳在 400 以下。
设置
# 1. The secret. Never printed; the commands below read it from disk.
umask 077
openssl rand -hex 32 > sync.secret
gh secret set MIRROR_SYNC_SECRET < sync.secret
gh workflow run deploy.yml --ref main
# 2. The backfill job. Temporary — delete it when sync/state.backfillComplete is true.
# --attempt-deadline sits just above Cloud Run's 300s request timeout, so the
# platform's cut is the one that happens and the scheduler records it.
gcloud scheduler jobs create http onenote-mcp-sync \
--project="$GCP_PROJECT" --location="$GCP_REGION" \
--schedule="*/20 * * * *" --time-zone=UTC \
--uri="$MCP_PUBLIC_URL/sync" --http-method=POST \
--headers="X-Sync-Secret=$(cat sync.secret)" \
--attempt-deadline=330s --max-retry-attempts=2
# 3. The nightly deletion sweep. This one is permanent: it is the only thing that
# notices a page deleted out of an active notebook.
gcloud scheduler jobs create http onenote-mcp-sync-sweep-full \
--project="$GCP_PROJECT" --location="$GCP_REGION" \
--schedule="0 0 * * *" --time-zone=America/New_York \
--uri="$MCP_PUBLIC_URL/sync/sweep/full" --http-method=POST \
--headers="X-Sync-Secret=$(cat sync.secret)" \
--attempt-deadline=330s --max-retry-attempts=1
rm sync.secret
# 4. When the backfill finishes.
gcloud scheduler jobs delete onenote-mcp-sync \
--project="$GCP_PROJECT" --location="$GCP_REGION"部署账户上的回填任务位于 */15。在运行受时钟限制时,它低于限制——2026-08-20 上实测每次运行 61–81 个请求——但如果开始再次花费完整的 120 个请求预算,则会超过。*/20 是无论哪种情况都不会超过的节奏;将 MIRROR_SYNC_REQUEST_BUDGET 降至 100 也使 */15 安全。
重试在两者上都是安全的:每种模式都是幂等的,而在回填期间,预算耗尽后的重试实际上是有用的。在两个任务都存在时,运行租约使重叠变成 409,而不是 Graph 预算的双重消耗。
/sync/sweep/all 没有任务。在编辑 activeNotebookIds 后,或当笔记本在此服务器之外被重组且镜像需要与 OneNote 实际持有的内容核对时,手动运行它。
选择要镜像的内容
选择是一个手动编辑的 Firestore 文档,MIRROR_ROOT_DOC,默认 onenoteMirror/default。它是服务从不写入的唯一文档。
字段 | 类型 | 含义 |
| 字符串数组 | 其页面被镜像的笔记本 ID |
| 字符串数组,可选 | 其中哪些仍在编辑。缺失或 |
| 字符串 | 供你使用的自由文本。从不读取。 |
从 list_notebooks 获取 ID。结构——账户中每个笔记本和分区的名称——无论如何都会被镜像,因为树读取在一次请求中返回全部内容,而部分结构会使 list_notebooks 和无范围的 search_pages 自信且部分地回答。只有页面内容遵循选择。
activeNotebookIds 仅缩小持续工作的范围,别无其他。非活动笔记本的页面、内容和墨水与活动笔记本的存储方式完全相同,通过此服务器的写入仍会到达并更新镜像,读取仍会从中回答。变化的是,一旦其回填完成,没有增量遍历和清扫会重新检查它,因此在 OneNote 客户端中对非活动笔记本所做的编辑不会到达镜像,直到笔记本重新变为活动或手动运行 /sync/sweep/all。
将笔记本移回 activeNotebookIds 是安全的,且仅花费该笔记本:下一次运行会对比存储的列表,找到变为活动的一个 ID,并根据各自的分区 watermark 重新列出该笔记本的分区。其他每个笔记本保留其 watermark,不会被列出。只有真正更改的页面会被获取。向 notebookIds 添加笔记本的行为相同。
notebookIds 中不匹配任何笔记本的 ID 会在每次运行报告中报告为 unknownNotebookIds。activeNotebookIds 中命名了未镜像笔记本的 ID——拼写错误,或从未添加到 notebookIds 的真实笔记本——会报告为 unknownActiveNotebookIds,并记录为 mirror-selection-unknown-active。编辑后检查两者:ID 是不透明的字符串,拼写错误否则会导致笔记本静默地永不同步。
证明其有效
# While the backfill job exists. Once it is deleted, the sweep job is the one to force,
# and a covered read is what exercises the incremental path.
gcloud scheduler jobs run onenote-mcp-sync --project="$GCP_PROJECT" --location="$GCP_REGION"
# The report the run returned, in the service log.
gcloud logging read \
'resource.type="cloud_run_revision" AND jsonPayload.event="sync-completed"' \
--project="$GCP_PROJECT" --limit=3 --freshness=30m --format='value(jsonPayload)'200 本身不是证明。以下三件事才是:
Firestore 中的
sections/{id}.pagesSyncedThrough已移动。返回 200 但未推进任何 watermark 的运行没有做任何事。存储桶中每个有墨水的页面都有一个对象,其字节数与页面文档的
ink.bytes匹配。sectionsSkippedInactive是你期望的数字。覆盖所有镜像分区的计数意味着活动集为空或拼写错误;同一报告中的unknownActiveNotebookIds会说明是哪个。
两个基于日志的数字说明重叠窗口的宽度是否合适。两者都不紧急;在服务运行一周后阅读它们。
# How far back the page-listing overlap actually reached to catch a page that had really
# changed. The largest ageMs over a day is the narrowest WATERMARK_OVERLAP_MS that would
# still have caught every one of them; approaching 3600000 means that hour is too narrow.
# Tens of minutes is ordinary. Nothing logged at all means the overlap has never caught
# anything.
#
# It says nothing about SECTION_SCAN_OVERLAP_MS. A section that window declines is never
# listed, so it produces no line here at all -- that failure is silence, not a large
# number, and nothing observes it. The nightly /sync/sweep/full is the backstop for it.
gcloud logging read \
'resource.type="cloud_run_revision" AND jsonPayload.event="sync-overlap-save"' \
--project="$GCP_PROJECT" --limit=50 --freshness=1d --format='value(jsonPayload.ageMs)'
# How far this service's clock is from Graph's, in milliseconds, signed. Both overlap
# windows and the settle guard are sized against this being seconds rather than minutes.
# Nothing logged means it stayed under the threshold, which is the expected reading.
gcloud logging read \
'resource.type="cloud_run_revision" AND jsonPayload.event="graph-clock-skew"' \
--project="$GCP_PROJECT" --limit=50 --freshness=1d --format='value(jsonPayload.skewMs)'通过重新运行任务并阅读连续报告中的 done 来观察回填:false 且 pagesUpdated 上升,然后 true。sync/state.backfillComplete 表示相同情况,并且是删除 onenote-mcp-sync 任务的信号。
在运行期间留意请求日志中的 429。当回填任务以每小时超过 400 个请求的速度运行时,429 是该选择带来的预期后果,解决办法是放慢节奏。在其他任何时间出现 429,都意味着有东西绕过了共享请求闸门,而正是这种故障会导致整个账户被限流。
状态 | 含义 | 该做什么 |
200, | 运行完成了它的工作 | 什么都不用做 |
200, | 它在请求或时间预算上停了下来 | 什么都不用做;下一次运行会继续 |
401 | 密钥缺失或错误 | 修复任务;该路由没有做任何工作 |
404 | 服务上未设置 | 设置它并重新部署 |
409 | 另一次运行持有租约 | 什么都不用做;它会在 15 分钟后过期 |
504 | 运行超过了 Cloud Run 的 300 秒请求超时 | 什么都不用做;见下文 |
503, | Microsoft 刷新令牌已失效 | 重新运行 |
503, | Firestore 没有响应 | 重试;凭据没有问题 |
从中读取
MIRROR_READ_ENABLED 是开关。当它为 false 时——这是默认值——每个工具都像镜像存在之前一样,直接从 Microsoft Graph 应答。当它为 true 时,七个工具走本地副本:list_notebooks、list_sections、list_pages、list_pages_by_name、find_page_by_name、get_page_content 和 search_pages。
这些读取中的每一次都是三个步骤,按此顺序。
刷新。 工具自己运行一次增量同步——与
POST /sync运行的同一个runIncremental,但预算更小:12 个 OneNote 请求和 15 秒。一旦回填任务被删除,这就是唯一会运行增量遍历的东西。读取本地副本。 命中不消耗任何 OneNote 请求。未命中则回退到 Microsoft Graph,与之前完全一样。
报告来源。 当答案与 OneNote 持有的内容一致时,
source为onenote;当部分内容来自操作员冻结的笔记本时,为best-available;当内容可能滞后时,为mirror。
source 规则的完整说明。"已确认" 意味着第 1 步中的刷新已完成,或者答案是一个没有任何写入标记为过期的单页:
情况 |
|
读取回退到了 Microsoft Graph |
|
来自活动笔记本的本地命中,已确认 |
|
来自活动笔记本的本地命中,未确认 |
|
来自非活动笔记本的本地命中,已确认或未确认 |
|
覆盖两者的无范围搜索,已确认 |
|
覆盖两者的无范围搜索,未确认 |
|
刷新"完成"意味着它读取了笔记本树,访问了它需要的每个分区,获取了它需要的每个页面,并且没有耗尽任何一个预算。其他任何情况——请求或时间预算耗尽、树读取返回 500、页面获取失败、调度器持有同步租约、Firestore 没有响应——都是 mirror,并且答案带有 mirroredAt,说明副本是何时获取的。get_page_content 是前三行中的例外:页面文档自带过期标志,每次写入在触碰 OneNote 之前都会将其页面标记为过期,所以那里的命中是一个没有被任何东西取代的页面。
best-available 不是降级答案,也不会在刷新失败时变成 mirror:那次刷新本来就不会检查非活动笔记本,所以报告它是在描述一个不可能改变答案的失败。它实际要说的是,该副本只会被通过此服务器的写入或 /sync/sweep/all 重新检查。list_notebooks 按笔记本报告 pagesActive,无范围的 search_pages 在 notebooksSearched 旁边报告 inactiveNotebooks,这样模型就能看到跳过了多少。list_notebooks 和 list_sections 从不报告 best-available——无论活动集合怎么说,每次运行都会为整个账户存储结构。
刷新不会在每次调用时都运行。 一次完成的刷新持续 30 秒,未完成的持续 5 分钟,所以一次工具调用突发只支付一次刷新,而不是三十次。最坏情况是,在一个安静的账户上,持续繁忙的对话每小时产生约 120 个额外的 OneNote 请求,而限制是 400——并且安静账户上的刷新只是一个请求,即笔记本树。
没有按调用提供的参数来强制实时读取。以前有过(useLiveData);它已经没了,因为先刷新然后说明刷新是否完成的读取,回答了同样的问题,却不需要调用模型去推理的 schema 字段。
关闭它是一个完整的回滚:将变量设为 false,重新部署,工具就与镜像前的行为逐字节相同。不运行刷新,没有工具调用在同步上花费请求,每个答案都说 source: "onenote"。无需数据迁移,镜像继续在后台填充。
写入会立即重新同步其页面。 所有五个写入工具在成功写入后从 OneNote 重新读取页面并存储它,所以紧接着 append_to_page 之后的 get_page_content 从本地副本应答,包含刚刚添加的文本。这每次写入多花费一个 OneNote 请求。如果重新同步失败,页面会被标记为过期,这会把下一次读取送到 OneNote——更慢,但绝不会错——而后续读取前面的刷新无论如何都会修复它。失败的重新同步绝不会使写入失败。
在打开它之前要检查两件事:
所有四个复合索引都读取为
READY。 对仍处于CREATING状态的索引进行查询会以FAILED_PRECONDITION失败。sync/state.backfillComplete为 true。 更早打开它是安全的——未镜像的页面是未命中,会去 Graph——但大多数读取会回退,这比根本没有镜像更慢,并且会消耗回填需要的预算。
一个值得了解的行为变化是 search_pages。从 Graph 应答时,它会遍历分区并在 60 个分区或 25 秒处停止,所以它报告 sectionsSearched、sectionsFound 和 stoppedEarly。从镜像应答时没有遍历也没有边界——但页面内容只为你的选择中的笔记本保留,所以它改为报告 notebooksSearched 对比 notebooksInAccount,并在它们不同时在备注中说明。读取"无匹配"的模型需要知道它得到的是哪一个。要搜索选择之外的笔记本,传入 sectionId:那会在本地副本中未命中,并在 OneNote 中遍历那一个分区。
当运行超时时
MIRROR_SYNC_REQUEST_BUDGET 和 240 秒的墙钟预算都会在 Cloud Run 的 300 秒请求超时之前很久停止一次运行,所以通常的答案是带有 "done": false 的 200。504 意味着某些东西花费的时间比预期长——几乎总是来自 OneNote 的带有长 Retry-After 的 429。
那时发生什么值得了解。服务以 CPU 节流和零最小实例数部署,所以被切断的请求会遇到两种命运之一,而 Cloud Run 两者都不保证:如果实例仍然热,进程会被挂起,并在下一个请求到达时恢复;如果它已缩放到零,工作就简单地消失了。无论哪种方式,运行都不会完成,其租约不会被释放,重试会得到 409,直到租约在 15 分钟后过期。下一次运行——回填任务、夜间清扫、或下一次覆盖读取前面的刷新——然后正常进行。
两种情况下都不会丢失任何东西。水位线按分区推进,并且只在该分区的页面存储之后推进,所以被切断的运行保留它已提交的一切,下一次运行从那里继续。实例在两次运行之间直接消失也是如此:镜像、Microsoft 刷新令牌以及此服务器签发的每个令牌都是存储或签名的,而不是保存在内存中,所以实例可以在任何时刻消失,而代价不超过一次令牌往返。
如果你看到重复的 504,唯一要检查的是请求日志中的 429。持续限流是达到超时的唯一现实途径,解决办法是更小的 MIRROR_SYNC_REQUEST_BUDGET 或更长的间隔,而不是更长的 Cloud Run 超时。
从损坏的镜像恢复
删除 MIRROR_ROOT_DOC 的 notebooks、sectionGroups、sections、pages、pageContent 和 tombstones 子集合,并清除 sync/state 中的 structureHash 和 sectionsScannedThrough。然后从设置中重新创建 onenote-mcp-sync 任务,并让它一直运行到 backfillComplete 再次为 true:读取前面的内联刷新只有 12 个请求的预算,无法从零重建镜像。选择文档和令牌缓存不受此影响,也不涉及任何 Graph 写入——镜像是一个副本,Microsoft Graph 始终是事实来源。
告警
有两种故障在没有基于日志的指标时是不可见的,因为两者都只表现为 Claude 对话中的一条消息或一行没人读的日志:
事件 | 含义 |
| Microsoft 授权已失效。必须有人运行 |
| MSAL 交出了一个没有凭据的缓存。存储的副本幸存了;有什么东西出了问题。 |
gcloud logging metrics create onenote_mcp_auth_failure \
--description="Microsoft Graph credential failures needing an operator" \
--log-filter='resource.type="cloud_run_revision"
resource.labels.service_name="onenote-mcp"
jsonPayload.event=("graph-auth-failure" OR "token-cache-write-refused")
jsonPayload.retryable!="true"'然后在该指标大于零时设置告警策略。同意批准也值得关注:POST /consent 返回 302 应该只在你添加连接器时发生,而请求日志已经携带了它。
jsonPayload.event="request" jsonPayload.path="/consent" jsonPayload.status=302引导
npm run bootstrap 是项目中唯一的交互式 Microsoft 登录,它运行在你的机器上,而不是 Cloud Run 上。它使用设备代码流程登录,并将生成的 MSAL 缓存写入服务器读取的 Firestore 文档。
gcloud auth application-default login
ONENOTE_CLIENT_ID=00000000-0000-0000-0000-000000000000 \
ONENOTE_AUTHORITY=https://login.microsoftonline.com/common \
GOOGLE_CLOUD_PROJECT=your-project \
FIRESTORE_CACHE_DOC=tokencache/msal \
npm run bootstrap它打印 Microsoft 的设备代码消息,等待你在浏览器中批准,然后列出你的笔记本一次,并打印数量作为令牌有效的证明。结尾几行指明写入的 Firestore 项目和文档以及账户的主租户,这样你可以看到你登录到了正确的目录。该输出携带租户 ID;不要把它放到 issue、pull request 和工作流日志中。
GOOGLE_CLOUD_PROJECT 和 FIRESTORE_CACHE_DOC 在这里是必需的,不像在服务器上,前者是推断的,后者有默认值。CLI 使用你自己的 Application Default Credentials 写入,所以未设置的值会在你的 gcloud 登录指向的任何项目中播种一个真实文档,并且仍然打印成功行。MCP_OAUTH_* 值不会被读取,所以运行这个永远不会把 Layer-1 客户端密钥放到你的机器上。
每当服务器的日志中出现带有 retryable: "false" 的 graph-auth-failure 事件时,再次运行它。刷新令牌在每次使用时轮换,如果服务闲置超过大约 90 天就会失效;没有自动恢复。配置上面的 keepalive 任务就是防止闲置成为达到那种状态的方式之一。
容器
该服务部署到 Cloud Run,其运行环境为 linux/amd64。镜像明确针对该平台构建,以确保 @resvg/resvg-js 原生二进制文件匹配。运行时基础镜像为 Debian node:24-slim,绝不能换成 Alpine——resvg 预编译包仅支持 glibc。
docker build --platform linux/amd64 -t onenote-mcp .
docker run --rm -p 8080:8080 \
-e PORT=8080 \
-e ONENOTE_CLIENT_ID=00000000-0000-0000-0000-000000000000 \
-e ONENOTE_AUTHORITY=https://login.microsoftonline.com/common \
-e MCP_OAUTH_CLIENT_ID=test-client \
-e MCP_OAUTH_CLIENT_SECRET=test-secret \
-e MCP_TOKEN_SIGNING_KEY=0123456789abcdef0123456789abcdef \
-e MCP_PUBLIC_URL=https://onenote-mcp.example.run.app \
onenote-mcp
curl -i localhost:8080/health # 200, {"status":"ok",...}/healthz 返回相同内容,供 Cloud Run 自身的探针使用。请勿从外部调用它:Google 前端会以自身的 404 页面响应 https://<service>.run.app/healthz,请求根本不会到达容器,因此外部可用性检查必须使用 /health。2026-08-19 针对已部署服务实测——/health、/healthz2 甚至 /Healthz 均能到达,只有完全小写的 /healthz 被吞掉。
这些值只是占位符,其格式仅足以通过启动校验;它们不用于任何身份验证。在 Cloud Run 上,PORT 由平台提供,其余变量来自部署工作流。如果主机端口 8080 已被占用,可映射其他端口:-p 8081:8080 并配合 -e PORT=8080。
测试镜像:
RUN_DOCKER_TESTS=1 bash scripts/test/run.sh该命令构建镜像,检查 resvg glibc 二进制文件在仅生产环境的安装中得以保留且未带入任何开发依赖,在容器内将 SVG 渲染为 PNG,并断言 /healthz 在 PORT 指定的端口上返回 200。未设置 RUN_DOCKER_TESTS=1 时,docker 测试套件会被跳过,其余测试仍会运行。
部署
.github/workflows/deploy.yml 在每次推送到 main 分支以及 workflow_dispatch 时运行。它会执行类型检查、运行 npm test、构建、构建并推送容器镜像到 Artifact Registry(以提交 SHA 作为标签),然后将该镜像部署到 Cloud Run。类型检查或测试失败会在构建镜像之前停止整个流程。
GitHub 中不存在长期有效的凭据。该任务通过 Workload Identity Federation 进行身份验证:permissions: id-token: write 使其能够请求 GitHub OIDC 令牌,google-github-actions/auth@v2 将该令牌兑换为短期有效的 Google 凭据。scripts/gcp-bootstrap.sh 不会创建任何服务账号 JSON 密钥,任何地方也不需要。该提供方仅接受 repository 声明为本仓库的令牌。
镜像在 ubuntu-latest 上构建,该平台为 linux/amd64——即 Cloud Run 运行的平台,也是 @resvg/resvg-js 预编译包所针对的平台。这就是工作流自行构建镜像而非使用 gcloud run deploy --source 的原因,后者还需要启用 Cloud Build 并授予相应角色。
部署以 --max-instances=1 和 --allow-unauthenticated 运行,使用运行时服务账号,该账号持有 Firestore 令牌缓存的 roles/datastore.user 权限。--allow-unauthenticated 是 Claude 能够访问该服务的唯一前提;MCP 端点本身通过 bearer 令牌关闭。参见 MCP 端点上的 Bearer 令牌。
仓库需要持有的配置
scripts/gcp-bootstrap.sh 负责配置 GCP 侧资源,并打印前六项配置的 gh variable set 命令。工作流会在第一步失败并指出缺失项,而不是部署一个半配置的服务。
名称 | 类型 | 值 |
| variable | 项目 ID |
| variable | Cloud Run 区域 |
| variable | Artifact Registry 区域 |
| variable | 完整的工作负载身份提供方资源名称 |
| variable | 部署服务账号邮箱 |
| variable | 运行时服务账号邮箱 |
| variable | Azure 应用注册客户端 ID |
| variable | Entra 颁发机构 URL |
| variable | 第 1 层 OAuth 客户端 ID |
| variable | 服务的公共 URL;见下文 |
| variable | 可选;默认为 |
| secret | 第 1 层 OAuth 客户端密钥 |
| secret | 访问令牌签名密钥,至少 32 个字符 |
| secret | 可选;未设置表示不挂载 |
| variable | 可选;页面镜像的 Cloud Storage 存储桶。 |
| variable | 可选;默认为 |
| variable | 可选;默认为 |
| variable | 可选;默认为 |
| secret | 可选;未设置表示不挂载 |
其中只有四项是凭据。WIF 提供方名称和服务账号邮箱是标识符,对无法出示本仓库 OIDC 身份的人毫无用处,因此作为变量而非密钥。
部署时使用 env_vars_update_strategy: overwrite,因此工作流中的列表就是服务每个修订版本的全部环境变量。该操作的默认值为 merge,在 merge 模式下,从工作流中移除的变量会静默保留在上一个修订版本中。PORT 和 GOOGLE_CLOUD_PROJECT 有意不在列表中:Cloud Run 会提供这两者,并且会拒绝将 PORT 作为输入。
首次部署与 MCP_PUBLIC_URL
MCP_PUBLIC_URL 是 OAuth 颁发者,也是本服务器签发的每个访问令牌的受众,而在首次部署完成之前,不存在任何服务来拥有 URL。工作流分三步解析它:先使用 MCP_PUBLIC_URL 仓库变量,然后使用 Cloud Run 已分配给服务的 URL,最后——仅当两者都不存在时——使用 https://placeholder.invalid,并在部署完成后立即将其替换为真实 URL。因此首次运行可以无人值守地完成,并以正确的值收尾。它会留下一条警告,指明该 URL;请将其设置为仓库变量,因为这是三个来源中唯一能在服务前面配置自定义域名后仍然有效的来源。
更改 MCP_PUBLIC_URL 本身不会使任何内容失效,但所有已签发的访问令牌都绑定到旧的受众,将被拒绝。发生这种情况时,Claude 会重新执行授权流程。
回滚
镜像标签是提交 SHA,因此较早的镜像仍保留在 Artifact Registry 中:
gcloud run services update-traffic onenote-mcp --region "$GCP_REGION" --to-revisions <revision>=100使用 workflow_dispatch 从较早的提交重新运行工作流同样可行,这也是让已部署环境与该提交的工作流文件保持同步的方式。
配置
所有值均来自环境变量,并在启动时进行校验。缺失或格式错误的变量会产生一个 ConfigError,一次性列出所有问题,进程以退出码 1 结束,不打印堆栈跟踪。
变量 | 必需 | 默认值 | 用途 |
| 是 | — | Azure 应用注册客户端 ID(公共客户端) |
| 是 | — | 租户的 Entra ID 颁发机构 URL |
| 是 | — | Claude 呈现的第 1 层 OAuth 客户端 ID |
| 是 | — | 第 1 层 OAuth 客户端机密 |
| 是 | — | 用于签署所签发访问令牌的密钥(至少 32 个字符) |
| 是 | — | 服务自身的公共 URL: |
| 服务器:否 · 引导:是 |
| 保存 MSAL 令牌缓存的 Firestore 文档路径 |
| 服务器:否 · 引导:是 | — | GCP 项目;在 Cloud Run 上自动推断 |
| 否 |
| 绑定端口。Cloud Run 会设置此值;服务器从不硬编码端口。 |
| 否 | — | 至少 32 个字符。设置后 |
| 否 |
| 保存手工编辑的 |
| 否 | — | 至少 32 个字符。设置后 |
| 有条件 | — | 用于渲染墨迹和超大页面 HTML 的 Cloud Storage 存储桶。一旦设置了 |
| 否 |
| 只能是 |
| 否 |
| 一次同步运行在停止并报告更多未完成请求之前可花费的 Graph 请求数。10–350,每小时限额为 400。 |
五个 MIRROR_* 变量配置 Firestore 页面镜像。它们都是可选的,因此一个未设置其中任何一个变量的服务,其行为与镜像存在之前完全一致——这正是 MIRROR_READ_ENABLED 成为完整回滚开关而非代码更改的原因。
MIRROR_BUCKET 是 loadConfig 中唯一的跨字段规则。VarSpec 按变量说明是否必需,无法表达“当另一个变量存在时必需”,但如果没有存储桶,同步就没有地方存放渲染的墨迹 PNG,镜像读取也没有地方获取它。因此,在未设置存储桶的情况下设置 MIRROR_SYNC_SECRET,或将 MIRROR_READ_ENABLED 设为 true,会在容器启动时失败,MIRROR_BUCKET 会出现在缺失列表中——而不是在回填进行数小时后、第一次写入对象时才失败。
MIRROR_READ_ENABLED 只接受 true 和 false,不区分大小写。它刻意不宽容:1、yes 和 on 对人类来说都读作 true,而一个接受它们的解析器还必须决定 off 和 0 的含义,到那时,像 ture 这样的拼写错误就会变成 false,从而关闭镜像,且没有任何提示。
FIRESTORE_CACHE_DOC 指定 src/token-cache.ts 中 MSAL 缓存插件读写文档的名称。其值必须是文档路径,即由斜杠分隔的偶数个段;loadConfig 会在启动时拒绝集合路径。
ONENOTE_CLIENT_ID 和 ONENOTE_AUTHORITY 标识 src/graph-auth.ts 向 Entra ID 呈现的 Azure 应用注册。它是一个公共客户端,因此刻意没有第 2 层客户端机密;其下方的 MCP_OAUTH_* 值属于第 1 层,即 Claude 与此服务器之间,两者无关。
MCP_PUBLIC_URL 是 Claude 访问此服务的 URL。OAuth 颁发者、令牌所绑定的 resource 标识符,以及受保护资源元数据文档的 URL,都由它构建。Cloud Run 上没有任何机制告诉进程它被访问的 URL 是什么,而从 Host 头获取的值将是调用方发送的任何内容,因此需要配置。它只能在首次部署产生 URL 之后才能填写。
npm run bootstrap 只读取 ONENOTE_CLIENT_ID、ONENOTE_AUTHORITY、FIRESTORE_CACHE_DOC 和 GOOGLE_CLOUD_PROJECT——不读取 MCP_OAUTH_* 值——并且它要求后两个必须提供,而不是使用默认值。参见 Bootstrap。
仓库卫生
此仓库是公开的。请勿提交真实的页面内容、渲染的墨迹、Entra 租户名称或 ID,或 Firestore 文档内容。.gitignore 排除了 output/ 和令牌缓存文件模式;参见 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