Skip to main content
Glama

onenote-mcp

一个通过 Microsoft Graph 暴露 Microsoft OneNote 的 MCP 服务器——笔记本和分区结构、页面内容,以及渲染成图像供调用模型阅读的手写内容。

project-spec.md 是权威设计文档。 它涵盖墨水重建流水线、两个独立的 OAuth 层、Cloud Run 部署模型,以及基于 Firestore 的令牌缓存。在修改此处任何内容之前,请先阅读它。

Requirements

Node >= 24。@google-cloud/firestore 需要 Node >= 22,而 Node 24 是当前的 Active LTS。

Related MCP server: OneNoteMCP

Quick start

npm ci
npm run build
npm test

Scripts

脚本

作用

npm run build

src/ 编译到 dist/

npm run typecheck

仅做类型检查,不产生输出

npm run dev

使用 --watch 从源代码运行服务器

npm start

dist/ 运行编译后的服务器(先运行 build

npm run bootstrap

本地设备代码登录,为 Firestore 令牌缓存填充数据

npm test

test/**/*.test.ts 上运行 node --test

测试位于 test/ 中,并与 src/ 镜像对应。它们使用 Node 的原生类型剥离直接针对 TypeScript 源码运行,因此 npm test 不需要先构建。这对源码有约束:不允许 enumnamespace、构造函数参数属性,且仅类型导入必须写成 import type。编译器选项 erasableSyntaxOnlyverbatimModuleSyntax 强制实施这一点。

目录结构及相应约定请参阅 CLAUDE.md

Token cache

src/token-cache.ts 针对单个 Firestore 文档实现 MSAL 的 ICachePlugin,该文档的路径来自 FIRESTORE_CACHE_DOCbeforeCacheAccess 读取文档的 cache 字段,并将字符串交给 MSAL。afterCacheAccess 在 Firestore 事务中将序列化后的缓存写回,并且仅在 MSAL 报告缓存已更改时执行。不存在的文档会被读取为空缓存,也就是运行 npm run bootstrap 之前的状态。两个入口都使用同一个插件:bootstrap CLI 通过它写入缓存,服务器通过它读取缓存,因此只有一个序列化器,也没有第二种格式需要保持同步。

该数据块是刷新令牌的唯一副本,因此有两重保护。

会使文档变空的写入会被拒绝。 MSAL 在某些失败情况下会从其内存缓存中移除凭据,而 afterCacheAccess 在 MSAL 的 finally 块内运行,因此一个已丢失账户的序列化结果可能到达此代码,而存储的缓存仍然有效。overwriteWouldEmptyCache 会阻止这种情况并记录 {"event":"token-cache-write-refused"}。空缓存检查不读取任何 MSAL 键名——当缓存解析为一个对象且其每个值都是空容器时,即为空——因此当 MSAL 更改其格式时,该检查不会失效;任何它无法识别的内容都会被放行,而不是被阻止。

每次写入所替换的数据块都会保存在 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-emulator

gcloud components install cloud-firestore-emulator 不会在 Debian 打包的 Google Cloud CLI 上安装它。该构建版本禁用了组件管理器,gcloud 会在其位置打印上面的 apt-get 命令。

Graph auth

src/graph-auth.ts 将已填充的令牌缓存转换为 Microsoft Graph 访问令牌。createGraphAuth 根据 ONENOTE_CLIENT_IDONENOTE_AUTHORITY 和 Firestore 缓存插件构建一个 PublicClientApplication,并在进程生命周期内持有它。getAccessToken() 读取缓存的账户,调用 acquireTokenSilent,然后返回令牌。请求的作用域是完全限定的 Notes.ReadNotes.ReadWrite

部署后的服务器绝不会以交互方式登录。它没有办法提示任何人,而且 Graph 的 OneNote 端点不支持仅应用(app-only)认证,因此当存储的刷新令牌失效时没有任何后备方案——需要人工重新运行 npm run bootstrap。因此,每个失败都被表示为 GraphAuthError,而不是以 Graph 返回的裸 401 形式到达调用方的原始 MSAL 错误:

reason

发生了什么

该怎么做

cache-unreadable

Firestore 文档不存在,或者其 cache 字段不是 MSAL 可以反序列化的内容

npm run bootstrap

cache-unavailable

Firestore 没有响应,或运行时服务账户失去了 roles/datastore.user

重试。不是重新登录。

no-account

缓存已读取,但不包含已登录的账户

npm run bootstrap

silent-failed

存储的刷新令牌已过期或被吊销,或者令牌端点没有返回可用内容

npm run bootstrap

cache-unavailable 就是值得单独列出的那一行。Firestore 的读写发生在 acquireTokenSilent 内部,通过缓存插件进行,因此后端中断过去会表现为与失效刷新令牌相同的拒绝——而该错误消息会告诉运维人员去浏览器更换一个仍在正常工作的凭据。GraphAuthError.retryable 承载了这一区分,并且只有该 reason 会设置它。

这些情况中的每一种还会向 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 structure client;目前还没有任何代码将两者接入 createApp

Graph structure

src/graph-structure.ts 读取 OneNote 树:笔记本、分区组、分区,以及某个分区内的页面列表。new GraphStructure(auth) 接受任何具有 getAccessToken() 的对象,因此服务器会将上面的 GraphAuth 传递给它。

方法

返回值

listNotebooks()

按显示名称返回每个笔记本

listSections(containerKind, containerId)

直接位于笔记本或分区组下的分区

listSectionGroups(containerKind, containerId)

直接位于笔记本或分区组下的分区组

listContainerChildren(containerKind, containerId)

上述两者,一起获取

listPagesInSection(sectionId, top?)

某个分区中的页面,按最近修改时间排序,最多 top 个(默认 50)

getNotebookTree(notebook)

一个笔记本,以及所有已解析的嵌套分区组

getFullTree()

每个笔记本,各自的树均已解析

getExpandedTree()

每个笔记本及其分区和一层分区组,通过单个请求获取

findSectionsByName(displayName)

账户中名称包含该文本的所有分区,每个分区附带其父笔记本和父分区组

findPagesByTitle(sectionId, title)

某个分区中标题匹配的页面,由 Graph 进行不区分大小写的比较

containerKindnotebookssectionGroups——这是两个 Graph 关系名称。两种容器类型都暴露相同的子关系,这就是列表方法接收 containerKind 而不是各写一份的原因。

getExpandedTree() 是开销低的那一个。它请求 Graph 展开关系,而不是逐个遍历:

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

针对一个有 54 个笔记本的账户进行测量:getFullTree() 需要 195 个请求,而 getExpandedTree() 只需 1 个请求和 78 KB 数据,这之所以重要,是因为 OneNote 每小时允许 400 个请求,且最多 5 个并发。每个 expand 子句中的 $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“maximum sections exceeded”失败。页面列表始终限定在 /me/onenote/sections/{id}/pages,并且有一个测试会扫描 src/,以确保不出现账户级路径。

失败是 GraphRequestError,对应非 2xx 响应——它携带 statusstatusText 和响应 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 服务。

模块

作用

src/multipart.ts

splitMultipart(body, contentType) → 各部分,或当响应不是 multipart 时返回 null

src/ink.ts

parseInkStrokes(text) → 笔画;strokesToSvgrasterizeSvgrenderInk(text, width?) → PNG 或 null

src/page-content.ts

GraphPageContent.fetchRaw(pageId) → 拆分后的响应;.fetchInk(pageId) → PNG 或 null

四个细节决定了这一切是否有效,而所有四个都来自 project-spec.md 附录 A 中经过验证的 recon 脚本:

  • 命名空间被剥离。 Graph 输出 inkml:inkinkml:traceinkml:traceFormatfast-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 端点

服务器通过无状态 Streamable HTTPPOST /mcp 上提供 MCP 服务。每个请求构建自己的 MCP 服务器,应答,然后拆除;没有任何内容保留到下一个请求。没有会话 ID,也没有 SSE——GET /mcpDELETE /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_notebookslist_sectionslist_pagessearch_pagesfind_page_by_namelist_pages_by_name)、一个读取工具(get_page_content)和三个写入工具(append_to_pagecreate_pageupdate_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 构建路径,而不是从挂载点构建,因此不能放在前缀后面——并提供五个路由,所有这些路由都必然未认证:

路径

作用

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

RFC 8414 授权服务器元数据

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

RFC 9728 受保护资源元数据

GET,POST /authorize

授权端点

POST /consent

同意表单回发的位置;SDK 的 /authorize 路由器不拥有恢复路径

POST /token

令牌端点

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

两个文档中的所有内容都源自 MCP_PUBLIC_URL:它是 issuer,resource 标识符是它加上 /mcpMCP_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 和密钥已配置,因此动态客户端注册无事可做。注册了一个客户端,具有三个重定向 URI——https://claude.ai/api/mcp/auth_callback 用于托管的 Claude 界面,以及 http://localhost/callbackhttp://127.0.0.1/callback 用于 Claude Code,其端口根据 RFC 8252 被忽略。

GET /authorize 渲染同意页面而不是重定向:一个批准按钮,说明授予的内容以及授权代码将发送到的主机。批准回发到 POST /consent,后者生成一个 60 秒的一次性代码并重定向到客户端的回调。整个授权请求通过一个隐藏字段跨越该页面,该字段使用 MCP_TOKEN_SIGNING_KEY 签名,因此同意过程中的实例替换不会破坏流程,并且表单无法被编辑;验证失败的字段是 400,没有重定向,也没有生成代码。

两个同意响应都携带 Cache-Control: no-storeReferrer-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/tokentest/server.test.ts 中的一个测试枚举了 createApp 实际注册的路由,并断言不在该列表上的每个路由在没有令牌时都返回 401,因此后来添加的路由默认关闭,除非有人故意打开它。

不需要作用域。offline_access 是此服务器颁发的唯一作用域,它关乎是否授予刷新令牌,而不是调用者可以做什么,要求它会为本来有效的令牌返回 403。如果将来添加作用域检查,403 必须携带 WWW-Authenticate: Bearer error="insufficient_scope"——此中间件确实如此——因为 Claude 将任何其他 403 视为终止性的,并且不会提示任何内容。

令牌生命周期和强制重新验证

此服务器设计为无人值守运行。默认设置反映了这一点,并牺牲了一些切断泄露凭据的能力。在部署到重要位置之前请阅读此内容,如果权衡对您不合适,请更改数字。

默认设置的作用

Token

生命周期

什么会续期它

Access token

1 小时

Refresh token,自动续期

Refresh token

30 天

每次刷新都会生成一个新的,并重新获得 30 天

Consent form

10 分钟

没有;过期的表单会被拒绝,流程重新开始

Claude 会自行刷新——既会在到期前一小时主动刷新,也会在收到 401 时被动刷新。 因此,人类只需在首次添加连接器时点击 Approve,之后只有当连接器闲置超过 30 天时才需要再次点击。这就是滑动窗口的含义:30 天限制的是连接可以保持空闲的时间,而不是它可以存续的总时长。

为什么是滑动窗口,以及它的代价

该服务器签发的每个 token 都是无状态的。它只是一个签名负载,仅此而已——没有数据库行,没有会话记录,token 回来时无需查找任何东西。这正是 Cloud Run 修订版替换不可见的原因:新实例验证旧实例签发的 token,两者之间无需共享状态。如果引入 token 存储,则每次部署都需要重新连接。

代价是无法单独撤销任何 token。没有撤销端点,因为没有任何东西可供它删除。具体来说:

  • 泄露的 refresh token 可在最多 30 天内授予访问权限,并且每次使用都会将持有者的访问权限再延长 30 天。没有服务端记录可供失效,也没有办法区分被盗的 refresh token 和合法的 refresh token——两者都是用同一把密钥签名的相同字节。

  • 滑动窗口不是轮换。当刷新生成新的 refresh token 时,被替换的旧 token 在其内部标记的到期时间之前仍然有效。真正的轮换意味着将旧 token 标记为已使用,这需要本设计没有的存储。

  • Access token 在其一小时内无法被切断,原因相同。

剩下的只有一个直接生效的粗粒度杠杆:更改 MCP_TOKEN_SIGNING_KEY 并重新部署。 所有 access token、所有 refresh token 以及所有打开的 consent 页面都会立即失效,因为它们都使用该密钥进行验证。Claude 的下一个请求会收到 401,操作员点击一次 Approve 即可。按计划轮换密钥本身就是一个合理的策略。

Consent 屏幕本身并不验证任何人的身份——它只有一个按钮,没有密码。陌生人无法访问你的笔记本,靠的是 MCP_OAUTH_CLIENT_SECRETPOST /token 要求提供)、重定向 URI 允许列表(将每个授权码发送到 claude.ai 或回环地址),以及 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

闲置这么久,连接器需要一次点击。如果定期使用,它仍然不会要求——窗口会不断向前滑动。这限制了泄露的 refresh token 在泄露停止使用后还能存活多久,仅此而已。

停止窗口滑动。 这正是 issue #22 最初指定的内容,它限制的是连接的总生命周期,而不是空闲时间:无论连接器有多繁忙,人类每 30 天批准一次。在 src/oauth-provider.tsexchangeRefreshToken 中,一行代码:

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

完全拒绝签发 refresh token。 最严格的设置:人类每小时批准一次,因为过期的 access token 没有东西可以续期。需要两处修改,且两者都需要——仅修改元数据开关并不能阻止 token 被签发。

  1. src/oauth-router.ts 中,清空 SCOPES_SUPPORTED。只有当元数据声明支持 offline_access 时,Claude 才会在授权请求中附加它,而这个开关决定了它是否会请求 refresh token。

  2. src/oauth-provider.ts 中,从 issueTokens 的返回值中删除 refresh_token 字段。目前无论请求的 scopes 是什么,它都会被签发。

这个设置在使用中会很明显:当小时到期时,Claude 会将浏览器送回 consent 屏幕。

缩短 access token。 src/oauth-provider.ts 中的 ACCESS_TOKEN_TTL_S 缩小了泄露的 access token 可用的窗口。它每次到期都会产生一次 token 请求,且不需要任何人工参与,因此成本很低——但它对泄露的 refresh token 毫无作用,而后者才是值得担心的凭据。

Keepalive

Microsoft 的委托 refresh token 在约 90 天未使用后会失效。token 只有在实际交换时才会向前滑动,而它只有在持有的 access token 过期后收到工具调用时才会被交换——因此,一个三个月无人使用的连接器,就需要有人在浏览器中运行 npm run bootstrap。服务器本身无法阻止这种情况,因为当没有人调用它时,服务器中没有任何东西在运行。

POST /keepalive 就是解决方案。它调用 acquireTokenSilent 并设置 forceRefresh: true,这会跳过持有的 access token 并交换 refresh token,因此 Entra 会签发一个具有新窗口的替换 token,src/token-cache.ts 会将其写入 Firestore。forceRefresh 是关键部分:没有它,MSAL 会从自己的缓存中回答,请求不会到达 Entra,窗口也不会移动。

MCP_KEEPALIVE_SECRET 设置为至少 32 个随机字符,路由就会挂载;不设置则路径返回 404。调度器在 X-Keepalive-Secret 头中提供该密钥,在任何工作完成之前会进行常数时间比较。它是一个共享密钥而不是 bearer token,因为调度器无法运行 OAuth 流程——它没有浏览器,也没有地方保存 refresh token——并且它是自己的变量,而不是 Layer-1 客户端密钥,这样能够访问整个 MCP 表面的凭据不会同时出现在调度器作业中。

gcloud scheduler jobs create http onenote-mcp-keepalive \
  --schedule="0 4 * * 1" \
  --time-zone=UTC \
  --uri="https://YOUR-SERVICE-URL/keepalive" \
  --http-method=POST \
  --headers="X-Keepalive-Secret=YOUR-SECRET" \
  --attempt-deadline=60s \
  --max-retry-attempts=3

每周一次对于 90 天的窗口来说绰绰有余,并且为几次错过的运行留有余地。该作业的成本是一次 token 端点往返和一次 Firestore 写入。

状态

含义

调度器应该做什么

200

Refresh token 已交换,新 token 已存储

什么都不做

401

密钥缺失或错误

修复作业;路由未做任何工作

404

服务上未设置 MCP_KEEPALIVE_SECRET

设置它并重新部署

503 且 "retryable": true

Firestore 不可达

重试

503 且 "retryable": false

授权已失效

npm run bootstrap

不能防止的:条件访问的登录频率策略、密码更改、MFA 重置或管理员撤销授权。其中任何一个都会杀死 refresh token,无论调度器怎么说,而且没有代码更改可以避免。如果 Entra 租户是你的,请将此应用注册豁免于登录频率策略;如果不是,请将 90 天视为一个上限,其他人可以在不通知你的情况下缩短它。

Keepalive 路由也与下面 Token lifetime 中的 Layer-1 30 天窗口无关。那个 refresh token 存在于 Claude 的连接器存储中,只有 Claude 才能呈现它或接收它的替换 token,因此这里运行的任何东西都无法保持它存活。失去它只需点击一次 Approve 按钮;失去 Microsoft 的那个则需要设备代码登录。

Alerting

有两种失败在没有基于日志的指标的情况下是不可见的,因为两者都只出现在 Claude 对话中的消息或没有人阅读的行中:

事件

含义

graph-auth-failureretryable: "false"

Microsoft 授权已失效。必须有人运行 npm run bootstrap

token-cache-write-refused

MSAL 交出了一个没有凭据的缓存。存储的副本幸存了;但有问题。

gcloud logging metrics create onenote_mcp_auth_failure \
  --description="Microsoft Graph credential failures needing an operator" \
  --log-filter='resource.type="cloud_run_revision"
    resource.labels.service_name="onenote-mcp"
    jsonPayload.event=("graph-auth-failure" OR "token-cache-write-refused")
    jsonPayload.retryable!="true"'

然后,对该指标大于零设置警报策略。Consent 批准也值得关注:POST /consent 返回 302 应该只在你添加连接器时发生,而请求日志已经携带了它。

jsonPayload.event="request" jsonPayload.path="/consent" jsonPayload.status=302

Bootstrap

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 的设备代码消息,等待你在浏览器中批准,然后列出你的笔记本一次,并打印计数作为 token 有效的证明。结尾几行会指明 Firestore 项目和写入的文档以及账户的主租户,以便你确认登录到了正确的目录。该输出携带租户 ID;请将其从 issues、pull requests 和工作流日志中移除。

GOOGLE_CLOUD_PROJECTFIRESTORE_CACHE_DOC 在这里是必需的,这与服务器不同,服务器中第一个是推断的,第二个有默认值。CLI 使用你自己的 Application Default Credentials 写入,因此未设置的值会在你的 gcloud 登录指向的项目中播种一个真实文档,并且仍然打印成功行。MCP_OAUTH_* 值不会被读取,因此运行它永远不会将 Layer-1 客户端密钥放在你的机器上。

每当服务器日志中出现 graph-auth-failureretryable: "false" 的事件时,再次运行它。Refresh token 在每次使用时都会轮换,如果服务闲置超过约 90 天就会失效;没有自动恢复。配置上面的 keepalive 作业是防止闲置成为达到该状态的方式之一。

Container

该服务部署到 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,并断言 /healthzPORT 给定的端口上返回 200。如果没有 RUN_DOCKER_TESTS=1,docker 套件会被跳过,其余部分仍然运行。

Deploy

部署

Wait, I need to check the original text. The last section is "## Deploy" - I should translate that as "## 部署". Let me also check the table headers - I need to preserve the table structure exactly.

Let me also check the GXP placeholders - they should remain as GXP9, GXP10, etc.

Let me redo this more carefully, preserving the exact| Token | 生命周期 | 什么会续期它 | | ------------- | ---------- | ------------------------------------------------------ | | Access token | 1 小时 | Refresh token,自动续期 | | Refresh token | 30 天 | 每次刷新都会生成一个新的,并重新获得 30 天 | | Consent form | 10 分钟 | 没有;过期的表单会被拒绝,流程重新开始 |

Claude 会自行刷新——在到期前一小时主动刷新,并在收到 401 时被动刷新。 因此,人类只需在首次添加连接器时点击 Approve,之后,仅当 连接器闲置超过 30 天时才需要再次点击。这就是滑动窗口:30 天限制的是连接可以保持空闲的时间,而不是它可以存活的时长。

为什么是滑动窗口,以及它的代价

该服务器签发的每个 token 都是无状态的。它只是一个签名负载,仅此而已——没有 数据库行,没有会话记录,token 回来时无需查找任何东西。这就是为什么 Cloud Run 修订版替换是不可见的:新实例验证旧实例签发的 token, 两者之间无需共享状态。如果引入 token 存储,则每次部署都需要重新连接。

代价是无法单独撤销任何 token。没有撤销端点, 因为没有任何东西可以删除。具体来说:

  • 泄露的 refresh token 可在最多 30 天内授予访问权限,并且每次使用都会将其 持有者的访问权限再延长 30 天。没有服务端记录可以使其失效,也没有办法 区分被盗的 refresh token 和合法的 refresh token——两者都是同一把密钥签名的相同字节。

  • 滑动窗口不是轮换。当刷新生成新的 refresh token 时, 被替换的旧 token 在其内部标记的到期时间之前仍然有效。真正的轮换意味着 将旧 token 标记为已使用,这需要本设计没有的存储。

  • Access token 在其一小时内无法被切断,原因相同。

剩下的只有一个直接生效的粗粒度杠杆:更改 MCP_TOKEN_SIGNING_KEY 并重新部署。 所有 access token、所有 refresh token 以及所有 打开的 consent 页面都会立即失效,因为它们都使用该密钥进行验证。 Claude 的下一个请求会收到 401,操作者点击一次 Approve 即可。按计划轮换 密钥本身就是一个合理的策略。

Consent 屏幕本身并不认证任何人的身份——它只有一个按钮,没有 密码。陌生人无法访问你的笔记本,靠的是 MCP_OAUTH_CLIENT_SECRETPOST /token 要求提供)、 重定向 URI 允许列表(将每个授权码发送到 claude.ai 或回环),以及 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

闲置那么久,连接器需要一次点击。定期使用,它仍然不会要求——窗口会不断向前滑动。这限制了泄露的 refresh token 在泄露停止使用后还能存活多久,仅此而已。

停止窗口滑动。 这正是 issue #22 最初指定的方案,它限制的是连接的总寿命,而不是空闲时间:无论连接器有多繁忙,人类每 30 天批准一次。在 src/oauth-provider.tsexchangeRefreshToken 中,一行代码:

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

完全拒绝签发 refresh token。 最严格的设置:人类每小时批准一次,因为过期的 access token 没有东西可以续期它。两处修改,两者都需要——仅修改元数据开关并不能阻止 token 被签发。

  1. src/oauth-router.ts 中,清空 SCOPES_SUPPORTED。只有当元数据声明支持 offline_access 时,Claude 才会在授权请求中追加 offline_access,而这个开关决定了它是否会请求 refresh token。

  2. src/oauth-provider.ts 中,从 issueTokens 的返回值中删除 refresh_token 字段。目前无论请求的 scopes 是什么,它都会被签发。

缩短 access token。 src/oauth-provider.ts 中的 ACCESS_TOKEN_TTL_S 缩小了泄露的 access token 可用的窗口。它每次到期都会产生一次 token 请求,且不需要任何人类参与,因此成本很低——但它对泄露的 refresh token 毫无作用,而后者才是值得担心的凭据。

Keepalive

Microsoft 的委托 refresh token 在约 90 天未使用后会失效。token 只有在实际交换时才会滑动,而它只有在持有的 access token 过期后收到工具调用时才会被交换——因此,一个三个月无人使用的连接器,就是需要有人在浏览器中运行 npm run bootstrap 的连接器。服务器中没有任何东西可以自行阻止这种情况,因为当没有人调用它时,服务器中没有任何东西在运行。

POST /keepalive 就是修复方案。它调用 acquireTokenSilent 并设置 forceRefresh: true,这会跳过持有的 access token 并交换 refresh token,因此 Entra 会签发一个带新窗口的替换 token,src/token-cache.ts 会将其写入 Firestore。forceRefresh 是关键部分:没有它,MSAL 会从自己的缓存应答,请求不会到达 Entra,窗口也不会移动。

MCP_KEEPALIVE_SECRET 设置为至少 32 个随机字符,路由就会挂载;不设置则路径返回 404。调度器在 X-Keepalive-Secret 头中提供该密钥,在任何工作完成之前会以常数时间进行比较。它是一个共享密钥,而不是 bearer token,因为调度器无法运行 OAuth 流程——它没有浏览器,也没有地方存放 refresh token——并且它是自己的变量,而不是 Layer-1 客户端密钥,这样能够访问整个 MCP 表面的凭据不会同时存在于调度器作业中。

gcloud scheduler jobs create http onenote-mcp-keepalive \
  --schedule="0 4 * * 1" \
  --time-zone=UTC \
  --uri="https://YOUR-SERVICE-URL/keepalive" \
  --http-method=POST \
  --headers="X-Keepalive-Secret=YOUR-SECRET" \
  --attempt-deadline=60s \
  --max-retry-attempts=3

每周一次对于 90 天的窗口来说绰绰有余,并为几次错过的运行留有余地。该作业的成本是一次 token 端点往返和一次 Firestore 写入。

状态

含义

调度器应做什么

200

Refresh token 已交换,新 token 已存储

什么都不做

401

密钥缺失或错误

修复作业;路由未做任何工作

404

服务上未设置 MCP_KEEPALIVE_SECRET

设置它并重新部署

503 且 "retryable": true

Firestore 不可达

重试

503 且 "retryable": false

授权已失效

npm run bootstrap

没有防止的:条件访问的登录频率策略、密码更改、MFA 重置或管理员撤销授权。任何这些都会杀死 refresh token,无论计划怎么说,并且没有代码更改能避免。如果 Entra 租户是你的,请将此应用注册豁免于登录频率策略;如果不是,请将 90 天视为一个上限,其他人可以在不通知你的情况下缩短它。

Keepalive 路由也与下面 Token lifetime 中的 Layer-1 30 天窗口无关。该 refresh token 存储在 Claude 的连接器存储中,只有 Claude 才能提供它或接收它的替换 token,因此这里运行的任何东西都无法保持它。失去它只需点击一次 Approve 按钮;失去 Microsoft 的那个则需要设备代码登录。

Alerting

有两种失败在没有日志指标的情况下是不可见的,因为两者都只出现在 Claude 对话中的消息或无人阅读的行中:

事件

含义

graph-auth-failureretryable: "false"

Microsoft 授权已失效。必须有人运行 npm run bootstrap

token-cache-write-refused

MSAL 交出了一个没有凭据的缓存。存储的副本仍然存在;有什么东西出错了。

gcloud logging metrics create onenote_mcp_auth_failure \
  --description="Microsoft Graph credential failures needing an operator" \
  --log-filter='resource.type="cloud_run_revision"
    resource.labels.service_name="onenote-mcp"
    jsonPayload.event=("graph-auth-failure" OR "token-cache-write-refused")
    jsonPayload.retryable!="true"'

然后,对该指标设置警报策略。Consent 批准也值得关注:POST /consent 返回 302 应该只在你添加连接器时发生,而请求日志已经携带了它。

jsonPayload.event="request" jsonPayload.path="/consent" jsonPayload.status=302

Bootstrap

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 的设备代码消息,等待你在浏览器中批准,然后列出你的笔记本一次,并打印数量作为 token 生效的证明。它运行的最后几行会标明 Firestore 项目和写入的文档以及账户的主租户,以便你确认你登录到了正确的目录。该输出携带租户 ID;请将其从 issues、pull requests 和 workflow 日志中保持。

GOOGLE_CLOUD_PROJECTFIRESTORE_CACHE_DOC 在这里是必需的,这与服务器上不同,服务器上第一个是推断的,第二个有默认值。CLI 使用你自己的 Application Default Credentials 写入,因此未设置的值会在你 gcloud 登录指向的项目中播种一个真实文档,并且仍然打印成功行。MCP_OAUTH_* 值不会被读取,因此运行此操作永远不会将 Layer-1 客户端密钥放在你的机器上。

每当服务器日志中出现 graph-auth-failureretryable: "false" 的事件时,重新运行它。Refresh token 在每次使用时都会轮换,如果服务闲置超过约 90 天就会失效;没有自动恢复。配置上面的 keepalive 作业是防止闲置成为到达该状态的方式之一。

Container

该服务部署到 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,请求永远不会到达容器,因此外部 uptime 检查必须使用 /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,并断言 /healthzPORT 给定的端口上返回 200。如果没有设置 RUN_DOCKER_TESTS=1,docker 套件会被跳过,其余部分仍然运行。

Deploy

部署

Wait, I need to check the original text. The last section is "## Deploy" - I should translate it as "## 部署". Let me also check the GXP placeholders - they should be GXP9, GXP10, etc. I see I wrote GXP012 in one place - that should be GXP12. Let me fix that.

Also, I need to make sure the table structure is preserved exactly. Let me redo the tables.

Let me redo the entire translation more carefully.| Token | 生命周期 | 什么会续期它 | | ------------- | ---------- | ------------------------------------------------------ | | Access token | 1 小时 | Refresh token,由它自动续期 | | Refresh token | 30 天 | 每次刷新都会生成一个新的,并重新获得 30 天 | | Consent form | 10 分钟 | 没有;过期的表单会被拒绝,流程重新开始 |

Claude 会自行刷新——在到期前一小时主动刷新,并在收到 401 时被动刷新。 因此,人类只需在首次添加连接器时点击 Approve,之后,只有在 连接器闲置超过 30 天时才需要再次点击。这就是滑动窗口:30 天限制的是连接可以保持 空闲的时间,而不是它可以存活的时长。

为什么是滑动窗口,以及它的代价是什么

该服务器签发的每个 token 都是无状态的。它只是一个签名负载,仅此而已——没有 数据库行,没有会话记录,token 回来时无需查找任何东西。这就是为什么 Cloud Run 修订版替换是不可见的:新实例验证旧实例签发的 token, 两者之间无需共享状态。如果引入 token 存储,则每次部署都需要重新连接。

代价是无法单独撤销任何 token。没有撤销端点, 因为没有任何东西可以删除。具体来说:

  • 泄露的 refresh token 可在最多 30 天内授予访问权限,并且每次使用都会 将其持有者的访问权限再延长 30 天。没有服务端记录可以使其失效,也没有办法 区分被盗的 refresh token 和合法的 refresh token——两者都是同一把密钥签名的相同字节。

  • 滑动窗口不是轮换。当刷新生成新的 refresh token 时, 被替换的旧 token 在其内部标记的到期时间之前仍然有效。真正的轮换意味着 将旧 token 标记为已使用,这需要本设计没有的存储。

  • Access token 在其一小时内无法被切断,原因相同。

剩下的只有一个直接生效的粗粒度杠杆:更改 MCP_TOKEN_SIGNING_KEY 并重新部署。 所有 access token、所有 refresh token 以及所有 打开的 consent 页面都会立即失效,因为它们都使用该密钥进行验证。 Claude 的下一个请求会收到 401,操作者点击一次 Approve 即可。按计划轮换 密钥本身就是一个合理的策略。

Consent 屏幕本身并不认证任何人的身份——它只有一个按钮,没有 密码。它保护你的笔记本不被陌生人访问,靠的是 MCP_OAUTH_CLIENT_SECRETPOST /token 要求提供)、 重定向 URI 允许列表(将每个授权码发送到 claude.ai 或回环),以及 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

闲置那么久,连接器需要一次点击。定期使用,则仍然不会要求——窗口会滑动,因为窗口会不断向前滑动。这限制了泄露的 refresh token 在泄露停止使用后还能存活多久,仅此而已。

停止窗口滑动。 这正是 issue #22 最初指定的方案,它限制的是连接的总寿命,而不是空闲时间:无论连接器有多繁忙,人类每 30 天批准一次。在 src/oauth-provider.tsexchangeRefreshToken 中,一行代码:

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

完全拒绝签发 refresh token。 最严格的设置:人类每小时批准一次,因为过期的 access token 没有东西可以续期。两处修改,两者都需要——仅修改元数据开关并不能阻止 token 被签发。

  1. src/oauth-router.ts 中,清空 SCOPES_SUPPORTED。只有当元数据声明支持 offline_access 时,Claude 才会在授权请求中追加 offline_access,而这个开关决定它是否会请求 refresh token。

  2. src/oauth-provider.ts 中,从 issueTokens 的返回值中删除 refresh_token 字段。目前无论请求的 scopes 是什么,它都会被签发。

缩短 access token。 src/oauth-provider.ts 中的 ACCESS_TOKEN_TTL_S 缩小了泄露的 access token 可用的窗口。它每次过期都会产生一次 token 请求,且不需要任何人类参与,因此成本很低——但它对泄露的 refresh token 毫无作用,而后者才是值得担心的凭据。

Keepalive

Microsoft 的委托 refresh token 在约 90 天未使用后会失效。token 只有在实际交换时才会滑动,而它只有在持有的 access token 过期后收到工具调用时才会被交换——因此,一个三个月无人使用的连接器,就是需要有人在浏览器中运行 npm run bootstrap 的连接器。服务器中没有任何东西能自行阻止这一点,因为当没有人调用它时,服务器中没有任何东西在运行。

POST /keepalive 就是修复方案。它调用 acquireTokenSilent 并设置 forceRefresh: true,这会跳过持有的 access token 并交换 refresh token,因此 Entra 会签发一个带新窗口的替换 token,src/token-cache.ts 会将其写入 Firestore。forceRefresh 是关键部分:没有它,MSAL 会从自己的缓存应答,请求不会到达 Entra,窗口也不会移动。

MCP_KEEPALIVE_SECRET 设置为至少 32 个随机字符,路由就会挂载;不设置则路径返回 404。调度器在 X-Keepalive-Secret 头中提供该值,在任何工作完成之前会进行常数时间比较。它是一个共享密钥,而不是 bearer token,因为调度器无法运行 OAuth 流程——它没有浏览器,也没有地方存放 refresh token——并且它是自己的变量,而不是 Layer-1 客户端密钥,这样能够访问整个 MCP 表面的凭据不会同时存在于调度器作业中。

gcloud scheduler jobs create http onenote-mcp-keepalive \
  --schedule="0 4 * * 1" \
  --time-zone=UTC \
  --uri="https://YOUR-SERVICE-URL/keepalive" \
  --http-method=POST \
  --headers="X-Keepalive-Secret=YOUR-SECRET" \
  --attempt-deadline=60s \
  --max-retry-attempts=3

每周一次对于 90 天的窗口来说绰绰有余,并且为几次错过的运行留有余地。该作业的成本是一次 token 端点往返和一次 Firestore 写入。

状态

含义

调度器应做什么

200

Refresh token 已交换,新 token 已存储

什么都不做

401

密钥缺失或无效

修复作业;路由未做任何工作

404

服务上未设置 MCP_KEEPALIVE_SECRET

设置它并重新部署

503 且 "retryable": true

Firestore 不可达

重试

503 且 "retryable": false

授权已失效

npm run bootstrap

没有防止的:条件访问的登录频率策略、密码更改、MFA 重置或管理员撤销授权。任何这些都会终止 refresh token,无论调度器怎么说,并且没有代码更改能避免。如果 Entra 租户是你的,请将此应用注册豁免从登录频率策略;如果租户不是你的,请将 90 天视为一个上限,后者可以在不通知你的情况下缩短它。

Keepalive 路由也与下面 Token lifetime 中的 Layer-1 30 天窗口无关。该 refresh token 存储在 Claude 的连接器存储中,只有 Claude 才能提供它或接收它的替换 token,因此这里运行的任何东西都无法保持它。失去它只需点击一次 Approve 按钮;失去 Microsoft 的那个则需要设备代码登录。

Alerting

有两种失败在没有日志的情况下是不可见的,因为两者都只出现在 Claude 对话中的消息或无人阅读的行中:

事件

含义

graph-auth-failureretryable: "false"

Microsoft 授权已失效。必须有人运行 npm run bootstrap

token-cache-write-refused

MSAL 交出了一个没有凭据的缓存。存储的副本仍然存在;有东西出错了。

gcloud logging metrics create onenote_mcp_auth_failure \
  --description="Microsoft Graph credential failures needing an operator" \
  --log-filter='resource.type="cloud_run_revision"
    resource.labels.service_name="onenote-mcp"
    jsonPayload.event=("graph-auth-failure" OR "token-cache-write-refused")
    jsonPayload.retryable!="true"'

然后对该指标设置一个零的警报策略。Consent 批准也值得关注:POST /consent 返回 302 应该只在你添加连接器时发生,而请求日志已经携带了它。

jsonPayload.event="request" jsonPayload.path="/consent" jsonPayload.status=302

Bootstrap

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 的设备代码消息,等待你在浏览器中批准,然后列出你的笔记本一次,并打印数量作为 token 生效的证明。它运行的最后几行会标明 Firestore 项目和写入的文档以及账户的主租户,以便你确认你登录到了正确的目录。该输出携带租户 ID;请将其从 issues、pull requests 和 workflow 日志中保持。

GOOGLE_CLOUD_PROJECTFIRESTORE_CACHE_DOC 在这里是必需的,这与服务器上不同,那里的第一个是推断的,第二个有默认值。CLI 使用你自己的 Application Default Credentials 写入,因此未设置的值会在你 gcloud 登录的项目中播种一个真实文档,并且仍然打印成功行。MCP_OAUTH_* 值不会被读取,因此运行此操作永远不会将 Layer-1 客户端密钥放在你的机器上。

每当服务器日志中出现 graph-auth-failureretryable: "false" 的事件时,重新运行它。Refresh token 在每次使用时都会轮换,如果服务闲置超过约 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,请求永远不会到达容器,因此外部 uptime 检查必须使用 /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,并断言 /healthzPORT 给定的端口上返回 200。如果没有设置 RUN_DOCKER_TESTS=1,docker 套件会被跳过,其余部分仍然运行。

部署

.github/workflows/deploy.yml 在每次推送到 main 以及触发 workflow_dispatch 时运行。它会执行类型检查、运行 npm test、构建,并以 commit sha 作为标签构建并推送容器镜像到 Artifact Registry,然后将该镜像部署到 Cloud Run。类型检查或测试一经失败,就会在构建镜像之前停止整个运行。

GitHub 中没有任何长期存续的凭据。该任务通过 Workload Identity Federation 进行身份验证:permissions: id-token: write 让它能够请求 GitHub OIDC 令牌,google-github-actions/auth@v2 会将其兑换为短期有效的 Google 凭据。scripts/gcp-bootstrap.sh 不会创建服务账号 JSON 密钥,任何地方也不需要它。身份提供方只接受其 repository 声明值属于本仓库的令牌。

镜像在 ubuntu-latest 上构建,也就是 linux/amd64——这正是 Cloud Run 运行的平台,也是 @resvg/resvg-js 预编译产物所针对的平台。因此,该工作流才自己构建镜像,而不是使用 gcloud run deploy --source;后者还意味着要启用 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 命令。工作流若缺少配置,会在第一步失败并指出缺少的内容,而不是部署一套半配置。

名称

类型

GCP_PROJECT

变量

项目 ID

GCP_REGION

变量

Cloud Run 区域

GAR_REGION

变量

Artifact Registry 区域

WIF_PROVIDER

变量

完整的工作负载身份提供方资源名称

DEPLOY_SA

变量

部署服务账号邮箱

RUNTIME_SA

变量

运行时服务账号邮箱

ONENOTE_CLIENT_ID

变量

Azure 应用注册客户端 ID

ONENOTE_AUTHORITY

变量

Entra 颁发机构 URL

MCP_OAUTH_CLIENT_ID

变量

第 1 层 OAuth 客户端 ID

MCP_PUBLIC_URL

变量

该服务的公共 URL;见下文

FIRESTORE_CACHE_DOC

变量

可选;默认为 tokencache/msal

MCP_OAUTH_CLIENT_SECRET

机密

第 1 层 OAuth 客户端机密

MCP_TOKEN_SIGNING_KEY

机密

访问令牌签名密钥,至少 32 个字符

MCP_KEEPALIVE_SECRET

机密

可选;未设置则不会挂载 POST /keepalive

这里只有三项是凭据。WIF 提供方名称和服务账号邮箱属于标识符;无法出示本仓库 OIDC 身份的人拿到它们也毫无用处,所以它们被设为变量而不是机密。

部署时指定 env_vars_update_strategy: overwrite,因此在每次修订中,工作流中的该列表就是服务的完整环境。该 action 的默认值是 merge;在这种模式下,一个从工作流中移除的变量会悄悄地从上一次修订中残留下来。PORTGOOGLE_CLOUD_PROJECT 特意不在列表中:Cloud Run 会提供这两者,而且它拒绝把 PORT 作为输入。

首次部署与 MCP_PUBLIC_URL

MCP_PUBLIC_URL 是 OAuth 颁发者,也是该服务器签发的每个访问令牌的受众;而在首次部署发生之前,服务还不存在,因此也就没有 URL。工作流分三步解析它:先使用 MCP_PUBLIC_URL 仓库变量,再使用 Cloud Run 已经分配给此服务的 URL,最后——仅当两者都不存在时——使用 https://placeholder.invalid,并在部署完成后立即将其替换为真实 URL。因此,第一次运行可以在无人值守的情况下完成,并在最后把所有配置准确地设置好。它会留下一条警告来指明该 URL;请将仓库变量设成这个 URL,因为在服务前配置自定义域名之后,三个来源里也只有这一种能保留下来。

修改 MCP_PUBLIC_URL 本身不会使任何东西失效,但所有已签发的访问令牌都已绑定到旧的受众,因此会被拒绝。发生这种情况时,Claude 会重新执行授权流程。

回滚

镜像标签是 commit sha,因此更早的镜像仍会保留在 Artifact Registry 中:

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

使用 workflow_dispatch 从较旧提交重新运行该工作流同样有效,并且这也是让已部署环境与该提交的 workflow 文件保持同步的方式。

配置

每个值都来自环境变量,并在启动时进行校验。某个变量缺失或格式错误,都会产生一个 ConfigError,一次性列出所有问题,随后进程自动以退出码 1 退出,不打印堆栈。

变量

必需

默认

用途

ONENOTE_CLIENT_ID

Azure 应用注册客户端 ID(公共客户端)

ONENOTE_AUTHORITY

该租户的 Entra ID 颁发机构 URL

MCP_OAUTH_CLIENT_ID

Claude 出示的第 1 层 OAuth 客户端 ID

MCP_OAUTH_CLIENT_SECRET

第 1 层 OAuth 客户端机密

MCP_TOKEN_SIGNING_KEY

对被签发的访问令牌签名的密钥(至少 32 个字符)

MCP_PUBLIC_URL

服务自身的公共 URL:https,不能有查询参数、片段或结尾斜杠

FIRESTORE_CACHE_DOC

服务端:否 · bootstrap:

tokencache/msal

存放 MSAL 令牌缓存的 Firestore 文档路径

GOOGLE_CLOUD_PROJECT

服务端:否 · bootstrap:

GCP 项目;在 Cloud Run 上会自动推断

PORT

8080

绑定端口。Cloud Run 会设置此值;服务器从不硬编码端口

MCP_KEEPALIVE_SECRET

至少 32 个字符。设置后才会挂载 POST /keepalive;不设置则路径返回 404。请参阅 Keepalive

FIRESTORE_CACHE_DOC 指定 src/token-cache.ts 中的 MSAL 缓存插件读写所希望的文档路径。它的值必须是文档路径,也就是斜杠分隔的段数应为偶数;loadConfig 会在启动时拒绝集合路径。

ONENOTE_CLIENT_IDONENOTE_AUTHORITY 标识 src/graph-auth.ts 向 Entra ID 出示的 Azure 应用注册。它是公共客户端,因此特意不设置第 2 层 OAuth 客户端机密;上面的 MCP_OAUTH_* 值属于第 2 层,位于 Claude 与该服务器之间,互不相干。

MCP_PUBLIC_URL 是 Claude 访问该服务的 URL。OAuth 颁发者、令牌绑定所依托的 resource 标识符以及受保护资源的元数据文档 URL,全都由它派生。Cloud Run 不会告诉进程它对外可被访问到哪个 URL;如果从 Host 头部取值,则会得到调用方所发送的任何值,所以这一项必须配置。它只能在第一次部署产生 URL 之后手动填入。

npm run bootstrap 只读取 ONENOTE_CLIENT_IDONENOTE_AUTHORITYFIRESTORE_CACHE_DOCGOOGLE_CLOUD_PROJECT —— 而不读 MCP_OAUTH_* 值 —— 并且它要求后两者必填,而不是使用默认值。请参阅 Bootstrap

仓库卫生

本仓库是公开仓库。请不要提交真实页面内容、渲染后的墨迹、Entra 租户名称或 ID,以及 Firestore 文档内容。.gitignore 已排除 output/ 和数据缓存文件模式;也就是说,写内容前先查看 project-spec.md 中的 "Repo Hygiene" 一节。

A
license - permissive license
Not graded
quality - not tested
B
maintenance

Maintenance

Maintainers
7hResponse time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

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.
    15
    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
    2
    MIT

View all related MCP servers

Related MCP Connectors

  • Microsoft OneNote (Microsoft 365) MCP Pack

  • MCP-native open-source Notion alternative: read & write pages, databases and kanban boards.

  • Access the Notra API for managing posts, brand identities, integrations, and schedules.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/dovrosenberg/onenote-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server