notebooklm-py 远程 MCP 文件传输设计全解:signed-URL 旁路与 ADR-0024 的实现细节
【免费下载链接】notebooklm-pyUnofficial Python API and agentic skill for Google Gemini Notebook. Full programmatic access to NotebookLM's features—including capabilities the web UI doesn't expose—via Python, CLI, and AI agents like Claude Code, Codex, and OpenClaw.项目地址: https://gitcode.com/GitHub_Trending/no/notebooklm-py
本篇基于 ADR-0024(docs/adr/0024-mcp-remote-file-transfer.md)及其配套实现,完整拆解 notebooklm-py 如何让source_add(上传本地文件)与studio_download(下载播客/视频/PDF 等工件)在远程 HTTP 传输(claude.ai connector)下正常工作。读完你能掌握:MCP 生态缺少原生文件传输原语时如何设计"签名 URL 旁路"、无状态 HMAC 令牌如何编码操作参数、单用(single-use)jti 追踪如何封住令牌重放,以及整条链路的安全边界与运维配置方式。
为什么字节不能走 MCP 通道
notebooklm-py 的 MCP 服务器可以通过--transport http暴露为远程 HTTP 服务,供 claude.ai connector 通过 Cloudflare/Tailscale 隧道访问(远程传输与自托管 OAuth 分别由 ADR #1645、#1647 先行确立)。但此时仍有两个工具停留在stdio 心智模型——假设服务器的文件系统就是用户的文件系统:
source_add的source_type="file"接受path参数,它是服务器主机上的路径(见 source_add 工具);studio_download的path参数指向服务器主机上的输出文件(见 下载工具)。
在 claude.ai connector 下,服务器位于隧道后方的容器里:用户提供的path指向一个用户看不见的文件系统,而服务器写出的文件也落在用户够不到的地方。"上传一份本地 PDF"和"下载我的播客"因此在远程 connector 上双双失效,尽管它们在 stdio 下工作正常。
ADR-0024 的调研还确认了 MCP 协议本身帮不上忙:
- 上传没有原生原语。官方 File Uploads Working Group 章程(2026-04-23,Anthropic)指出,服务器今天"只能退化为用文字说明,请求 base64 字符串或本地路径";提议中的声明式文件输入描述符(SEP-2356)仍是 Draft,不可落地,且章程本身就把预签名上传 URL列为候选方案。也就是说,signed-URL 旁路是当前生态公认的最佳实践,不是重复发明。
- 下载有一个原生原语但不够用。Resources +
BlobResourceContents在 claude.ai connector 上确实支持二进制资源,但自定义 connector 的工具结果上限约150,000 字符(base64 后约 110 KB 二进制)——而 NotebookLM 的播客、视频、幻灯片、PDF 工件远大于此,原生路径对真实载荷不可用。
结论:二进制必须走出MCP JSON-RPC 通道——"上下文窗口留给控制消息,不留给大块数据"。仓库后续也补了一个窄路径:当 agent 已经持有字节、且浏览器与agent_uploadPOST 都走不通时,source_add提供bytes_base64参数,在通道内传递 ≤10,000 字符的 base64(约 7 KB),见 _fileupload.py 中的_MAX_UPLOAD_B64_CHARS常量——请求体积上限正是这个数值这么小的原因,更大的文件仍要走本 ADR 定义的签名 URL 流程。
总体决策:挂载在同一个 FastMCP http app 上的签名 URL 旁路
ADR-0024 的决策是:MCP 工具负责签发短期签名 URL,用户的浏览器直接对隧道做字节传输。没有字节经过 MCP/claude.ai。
具体是三条(后扩展为四条)FastMCPcustom_route:
GET /files/dl/{token} -> 流式下载工件 (Starlette FileResponse) GET /files/ul/{token} -> 极简上传页 (文件选择器 + fetch POST) POST|PUT /files/ul/{token} -> 流式读原始 body -> 添加 sourcePOST服务浏览器fetch,PUT服务代码执行沙箱的curl——同一个处理器同时覆盖两条投递路径:人打开链接在浏览器里上传,或 Claude 的代码执行沙箱把它已经持有的文件 curl 到预签名 URL。实现落在三个文件:
- _filelink.py:令牌签发/校验、jti 追踪;
- _fileroutes.py:
/files/*路由处理器; - _uploadwidget.py:实验性应用内上传 widget(ADR-0027 扩展,复用同一条
/files/ul路由)。
下载流程(http 传输下的 studio_download)
- 工具把签名链接作为
resource_link内容项返回(claude.ai 会渲染成可点击链接),同时携带结构化载荷{"status": "download_ready", "url": "<base>/files/dl/<token>", "expires_at": …}——而不是往服务器路径写文件。为什么不直接用原生BlobResourceContents?正是上面说的 ~150 KB connector 上限排除了它。 - 浏览器 GET → 处理器校验令牌,调用既有下载核心(
_app.download的build_download_plan/execute_download)把工件落到私有临时目录,返回带BackgroundTask清理的FileResponse(与 REST 服务器 server/routes/artifacts.py 的模式一致)。
从源码看,download_route 还做了几件 ADR 文字背后更细的事:并发下载上限_MAX_CONCURRENT_DOWNLOADS = 4(防止泄漏令牌驱动 N 路并行流 = N 份临时磁盘占用 + N 倍上游抓取放大),超限直接 429;槽位由_SlotHeldFileResponse(FileResponse子类)在流结束或客户端断开时才释放并清理临时目录,保证慢速/挂起的流仍然计入限额、槽位永不泄漏;工件输出路径必须断言位于临时目录内,否则拒绝服务。下载工具侧的download_ready构造 会附带filename、mime_type、size_bytes等自描述元数据,且与路由实际使用的Content-Type来自同一套中央解析函数,保证"宣称的"与"流出的"不漂移。
上传流程(http 传输下的 source_add type=file)
- 工具返回
{"status": "upload_required", "url": "<base>/files/ul/<token>", "expires_at": …}而不是读取服务器路径。_broker_upload 的实际返回比 ADR 文字更丰富:它区分两条一等执行路径——human_upload(用短链/u/<shortid>,抗移动端聊天里长令牌被截断/自动纠错破坏)与agent_upload(raw-body POST,附带可直接复制的 curl 示例和agent_instructions回退规则),另带mime_locked标志(签了 mime 时请求头 Content-Type 即被忽略)。 - 浏览器 GET → 返回单屏 HTML 页(文件选择器 +
fetch脚本,见 _UPLOAD_PAGE)。 - 页面把文件作为原始请求体上传:
fetch(url + "?filename=" + encodeURIComponent(file.name), {method:"POST", headers:{"Content-Type": file.type || "application/octet-stream"}, body: file})——不是multipart 表单。原因在 ADR 中讲得很透:- 原始 body 省略了文件名,而 NotebookLM 上传要求真实的 basename+扩展名(无扩展名会 400,见 _web/sources/upload.py),所以页面把浏览器选中的
file.name作为查询参数、MIME 作为Content-Type传递; - 处理器用
request.stream()逐块写入权限0o600的临时文件(文件名取自清洗后的?filename),带滚动字节上限(真正的 DoS 防御),之前先做Content-Length早拒(413); - 之后运行中立的
source_add核心(source_type="file"),在finally中删除临时文件(含流中断开的情形),返回 HTML "added (id=…)" 页。 - 刻意不用
<form enctype=multipart/form-data>/request.form(),还因为python-multipart只在serverextra 里、不在mcpextra 里;更因为 multipart 会先把整个 body 落盘、然后才能做逐块大小检查——那个磁盘耗尽漏洞在 REST 路由上要靠应用层 Content-Length 中间件弥补,而 FastMCP 自定义路由并不继承该中间件。
- 原始 body 省略了文件名,而 NotebookLM 上传要求真实的 basename+扩展名(无扩展名会 400,见 _web/sources/upload.py),所以页面把浏览器选中的
- Agent 用
await_upload(轮询进程内完成记录,返回{source_id, file:{name,size,mime,sha256}})或source_list确认。
值得注意的两个实现细节(源码在 upload_route):
- 上传上限
MAX_UPLOAD_BYTES = 200 * 1024 * 1024(与 REST 路由一致),滚动上限才是权威防御,Content-Length只是对声明超限的 body 做早拒——chunked 或未如实声明的长度能绕过它; - agent 提供的
title/mime走签名令牌(签过名、不可篡改);而filename(令牌签发时用户还没选文件,令牌无从知道)由浏览器/沙箱作为清洗后的?filename=到达,用作临时文件的 basename+扩展名。此外还支持可选的?sha256=<hex>完整性声明:服务端流式计算收到的字节摘要,不匹配就在添加 source之前以干净的 400 拒绝(可重试),坏值(非 64 位小写 hex)也先被拦成 400 而非 500。
无状态令牌:参数编码进令牌,处理器不持有状态
签名令牌编码操作参数,所以/files/*处理器不保留任何服务端状态:
- 下载令牌载荷:
{op:"dl", nb, atype, fmt?, aid?, exp, jti} - 上传令牌载荷:
{op:"ul", nb, title?, mime?, exp, jti}
把title/mime放进上传令牌,使source_add type=file的参数能在浏览器往返中存活且不可被篡改。令牌线上格式(见 FileLinkSigner):
base64url(json(payload)) + "." + base64url(HMAC-SHA256(key, body))只依赖标准库hmac/hashlib/base64/json/secrets,没有新增第三方依赖(itsdangerous未安装)。verify的校验顺序在源码里刻得很死:先做令牌长度上限检查(_MAX_TOKEN_LEN = 4096,任何 decode/HMAC 工作之前,防止异常长的路径段驱动解码/分配成本),再重新补齐 base64url、用hmac.compare_digest常数时间比较 MAC(伪造令牌在 MAC 通过前不会到达 JSON 解析器),最后才查exp和op——上传链接不能重放到下载路由,反之亦然。失败统一抛FileLinkError,路由一律返回扁平 403,探测者学不到"为什么"失败(上传路由所有拒绝共用同一句文案)。
没有 ref-registry,也没有后台清扫任务。唯一的进程内状态是ul的单用追踪器ConsumedJtiStore(见下节):一个临时、有界、内联清扫的进程内已消费jti集合,与签名密钥同生共死。dl保持完全无状态(TTL 内多用途)。
认证模型:HMAC 签名令牌是/files/*的唯一且充分的认证
打开签名 URL 的浏览器无法携带 MCP bearer/OAuth 凭证,所以旁路必须自证。ADR 对 fastmcp 3.2.0 的核实结论(与 FastMCP 挂载路由的方式 一致):
auth.get_middleware()作为全局中间件挂载——它认证(填充请求作用域)但不拒绝未认证请求(BearerAuthBackend.authenticate返回None而非抛错,MultiAuth/McpBearerAuthProvider继承这一"不拒绝"基线);RequireAuthMiddleware只包裹MCP 路由(streamable-http 传输);- 自定义路由被未包裹地追加,可以不经bearer 门禁到达。
因此HMAC 签名令牌就是/files/*唯一且充分的认证,而这是正确的(浏览器没有 bearer)。仓库里有一个回归测试钉住这个 FastMCP 行为——一旦升级开始对自定义路由强制鉴权,测试会大声失败。
另一个容易被忽略的问题:自定义路由处理器收到的是 StarletteRequest而不是 MCPContext,用不了工具的get_client(ctx)。解决方案是经request.app.state.fastmcp_server(FastMCP 会把它设在 Starlette app 上)→._lifespan_result(lifespan 产出的AppState)拿到进程唯一 client,并以._lifespan_result_set守卫;_lifespan_result是 FastMCP 私有属性,所以同样有回归测试钉住这条访问路径。处理器签名必须是(request)——(request, token)会直接弄崩 Starlette 的request_response。
配置:签名密钥、公共 URL 与"不崩启动"
从源码(_build_file_transfer)看,配置解析规则是:
- 密钥:服务器启动时生成临时
secrets.token_bytes(32)。令牌 TTL 短(上传15 分钟、下载30 分钟,见 UPLOAD_TTL / DOWNLOAD_TTL),重启使存量链接失效是可接受的,且省去一个要管理的秘密——无需任何配置项。 - 公共 base URL:环境变量
NOTEBOOKLM_MCP_PUBLIC_URL,回退到NOTEBOOKLM_MCP_OAUTH_BASE_URL(OAuth 流程本就已要求的公共 https 隧道地址)。两者都经共享的 _validate_bare_https_origin 校验为裸 https origin(https scheme、无 path/query/fragment),与 OAuth base URL 用同一个检查,防止/mcp后缀或非 https 值产生坏链/不安全链。 - 不崩启动:文件传输是可选能力。只设 bearer、没设公共 URL 的远程部署仍然合法(聊天等一切正常)——服务器不会
SystemExit,_build_file_transfer直接返回None,两个文件工具在调用时干净地报错 "remote file transfer is not configured; set NOTEBOOKLM_MCP_PUBLIC_URL"。(早期草案提议启动时SystemExit,被两次评审都点名否决:那会弄坏从不用文件传输的纯 bearer 远程服务器。) - 传输分支:
create_server增加一个可选的 file-transfer 配置(signer + 校验过的公共 base URL),只在 http 分支构造并挂在AppState上;配置存在时两个工具发 URL,不存在时(stdio,或 http 未配公共 URL)stdio 保持既有的 path 行为不变、http 则报"未配置"。stdio 路径不受任何影响。
运维上最简单的结论:如果你已经为 claude.ai OAuth 配了NOTEBOOKLM_MCP_OAUTH_BASE_URL,文件传输就已经开着(见 docs/mcp-guide.md 与 docs/configuration.md)。
安全侧还有一个值得注意的权衡:令牌签过名但未加密,泄漏的 URL 会让读到日志的人 base64 解码出 notebook id / 工件类型 / 标题。这是被接受的——单租户自己的低敏感元数据,HMAC 的职责是防伪造而非防披露(加密需要不透明服务端状态,会毁掉无状态设计)。令牌随 URL 路径走,会被隧道访问日志、浏览器历史、Referer捕获,所以 HTML 页统一发Referrer-Policy: no-referrer、Cache-Control: no-store,并加X-Frame-Options: DENY与严格 CSP(见 _HTML_SECURITY_HEADERS),所有插值一律 HTML 转义。
残留风险:TTL 内的令牌重放——ul单用,dl接受
FileLinkSigner.verify检查长度上限、HMAC、exp、op;一个通过这些检查的令牌,在过期前每次都能通过。泄漏不是理论问题(令牌在 URL 路径里,会被 claude.ai、浏览器历史、隧道日志、Referer捕获),且两种操作的爆炸半径不同:
- 泄漏的
ul令牌是内容无关的写原语:载荷只有{nb, title?, mime?},上传字节就是原始请求体——持有链接的人可以把任意内容(≤200 MiB)作为 source 写进属主的 notebook,是内容/提示注入向量,不只是同文件重放; - 泄漏的
dl令牌可在 30 分钟 TTL 内反复重取该类型工件的最新版本。令牌钉的是{nb, atype, fmt?, aid?}而非字节快照,重放会流出发放时下载核心解析到的东西。
ADR-0024 的更新决策(#1746,2026-07-02 多模型 MCP 差距评审把ul严重度重新加权为"写/内容注入原语"后):ul单用,dl多用途。
ul— 强制单用。每个令牌携带随机jti(128 位,由sign()注入并被 MAC 覆盖)。/files/ulPOST 路由在落盘之前通过 ConsumedJtiStore.try_begin原子认领jti,只在source_add成功后commit(烧毁);失败/中止/429 的上传在路由finally中rollback认领,链接可重试(保留大文件重试窗口——200 MiB 上传在差链路上要 ~13 分钟,逼近 15 分钟 TTL,所以只在成功时记账)。效果:
- 顺序重放(日志泄漏的现实情形)在发生一次成功使用后即被 403 拒绝,且不触碰任何落盘;
- 原子认领还拒绝了并发重复 POST(先于落盘),把 #1746 之前 "N 个并发 × 200 MiB 临时盘" 的窗口坍缩为一个窄竞态,且被
_MAX_CONCURRENT_UPLOADS = 4再兜一层; - 单用追踪器是临时、有界(8192)、内联清扫的进程内集合,与签名密钥同死——重启即全灭(密钥轮换本就使所有令牌失效),所以没有引入 ref-registry,也没有后台清扫任务,无状态设计依然成立。
dl— TTL 内重放被接受(多用途)。jti存在但对下载不强制执行,因为GET /files/dl/{token}是直流的,Range/断点续传客户端会合法地重发 GET(带Range:的断线重连)——单用会把续传 403 掉、弄坏大工件下载。dl本身也更轻(重读同一工件,不是写原语),并已被四层约束:30 分钟短 TTL、每进程临时签名密钥(重启全灭)、单租户范围(无跨租户升级)、HMAC 完整性(重放需要一个合法签发的令牌);再加_MAX_CONCURRENT_DOWNLOADS = 4并发上限封住扇出。
原 ADR 列过三条会翻转权衡的条件(多租户、TTL 变长、有重放事故报告),均只针对dl;dl的 Range/续传约束是它保持多用途的常设理由。
边界:为什么 REST 服务器不在范围内
REST 服务器(server/extra)原生就支持二进制文件传输:POST /v1/notebooks/{id}/sources/file(multipart 上传,server/routes/sources.py)和POST /v1/notebooks/{id}/artifacts/download(FileResponse,server/routes/artifacts.py)。REST 客户端是带 bearer 令牌、直接流式传字节的程序化 HTTP 客户端,两个逼出本设计的约束都不存在。所以 ADR-0024 是刻意MCP-only,不是遗漏。ADR 同时否决了三个替代方案:
- 字节走 MCP 结果(base64):尺寸上限 + claude.ai 不会把文件存下来;
- 有状态上传 broker + ref 注册表 + TTL 清扫:令牌既已编码参数,它就是多余代码加一个后台任务;
- 直接复用 FastAPI 的
server/路由:那是另一个部署(独立 ASGI app、FastAPIDepends),旁路必须长在 MCP app 上——共享的是逻辑,不是 FastAPI 管道。
测试如何钉住这些行为
相关单测集中在 tests/unit/mcp/test_fileroutes.py,覆盖面与 ADR 的承诺一一对应:无 bearer 下好令牌正常流字节、坏令牌/跨操作令牌 403、并发上限 429 与槽位释放、mkdtemp失败的干净 500、服务路径必须留在临时目录内、上传页安全头、短链 302/404、CORS 预检、客户端 sha256 声明的匹配/不匹配/坏值三分支、文件名清洗(含..与按字节截断保留扩展名)、Content-Length 超限 413 不落临时文件、jti完成记录供await_upload轮询等。另有回归测试专门钉住"自定义路由不经 bearer 门禁"与app.state.fastmcp_server._lifespan_result访问路径这两个 FastMCP 行为,确保升级不静默破坏旁路。
小结
ADR-0024 给出的方案可以浓缩为四条设计不变式:
- 字节永远不走 JSON-RPC——MCP 通道只传控制消息(签名 URL),字节走隧道直连;这与 MCP 生态正在收敛的方向(SEP-2356/SEP-2631 的预签名 URL 侧通道)前向兼容,届时现有
/files/*端点直接成为规格协商的预签名目标,迁移主要只是把 UX 移进客户端原生文件选择器; - 令牌即状态——操作参数(notebook、工件类型、title/mime、过期、jti)全部编码进 HMAC 签名令牌,处理器零持久状态,唯一进程内状态(jti 集合)临时、有界、内联清扫、随进程死亡;
- 写比读严——
ul单用(防内容注入写原语)、dl多用途(保 Range 续传),TTL 分别 15/30 分钟,临时密钥重启全灭; - 可选能力不崩启动——未配公共 URL 时调用期干净报错,stdio 行为零改动。
对操作者而言需要记住的实际事项很少:为 http 传输设置NOTEBOOKLM_MCP_PUBLIC_URL(裸 https origin,无/mcp后缀,否则回退到NOTEBOOKLM_MCP_OAUTH_BASE_URL);若想让 Claude 的代码执行沙箱PUT文件,需在 claude.ai 开启 Code Execution 并把服务器域加入 Settings → Capabilities → additional allowed domains,浏览器上传路径则无此要求、是通用回退;链接在服务器重启后一律失效,上传上限 200 MiB,单文件超小(≤10,000 base64 字符 ≈ 7 KB)的场景可直接用bytes_base64跳过签名 URL。
【免费下载链接】notebooklm-pyUnofficial Python API and agentic skill for Google Gemini Notebook. Full programmatic access to NotebookLM's features—including capabilities the web UI doesn't expose—via Python, CLI, and AI agents like Claude Code, Codex, and OpenClaw.项目地址: https://gitcode.com/GitHub_Trending/no/notebooklm-py
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考