feat(mcp): 内联文本资源有界交付 - #238
Conversation
- EmbeddedResource 的内联 text 在 MIME 属文本族(text/*、application/json|xml|yaml|x-yaml|toml|javascript、缺省视为文本)时交付进工具文本管线,GitHub MCP get_file_contents 读文件正文可达模型 - 60,000 code point 截断 + 固定标记;blob/非文本 MIME/空白正文/audio/link 维持扣留,URI 不随正文渲染(query 凭据边界不变) - 文档 §8 内容边界同步;单测补文本族交付、非文本扣留、精确截断边界、isError 交付;集成测试补交付路径(正文达 provider、URI query 不泄漏)
There was a problem hiding this comment.
Bot Review — ✅ Approve
本 PR 为 MCP 资源归一化层新增文本族 MIME(text/* 前缀 + application/json/xml/yaml/x-yaml/toml/javascript 白名单 + 缺省 MIME)的有界正文交付(60,000 code point 截断),解决 github-mcp-server 等返回 EmbeddedResource.text 时正文被整体扣留的核心场景。代码变更主要落在 src/quickquip/llm/mcp/types.py(_format_tool_result 分派链与新增辅助)及配套单测、文档。两层审阅均完成,覆盖完整,未发现 blocking finding。2 个 should-fix(E305 空行违规、MIME 参数未剥离导致含 charset 参数的 application/* 资源被误扣留)均为真实缺陷但非强制合并前修复——前者 ruff 会直接报出,后者有明确修复方案且属边界场景降级(text/* 分支不受影响);3 个 nit 均属代码组织和测试健壮性改进。综合判定:approve。
Findings
🟡 SHOULD-FIX: E305 违规:def _resource_mime_deliverable(...) 与其后的模块级常量 _MAX_INLINE_IMAGE_BYTES 之间只留了 1 个空行,ruff select=["E","F"] 且未 ignore E305,pre-push hook 会直接报错。
File: src/quickquip/llm/mcp/types.py
在函数定义与 _MAX_INLINE_IMAGE_BYTES 之间补一个空行,保持模块级定义之间两个空行的既有写法。
🟡 SHOULD-FIX: _resource_mime_deliverable 对 MIME 做白名单比较时未剥离参数(;charset=... 等),而 _mime_type_from 内部走 _safe_metadata 会把 ;、= 等替换为 _,导致带 charset 参数的合法 MIME(如 application/json; charset=utf-8)变成 application/json__charset_utf-8 后无法命中白名单,application/* 文本资源在此类 server 下被整体扣留。
File: src/quickquip/llm/mcp/types.py
比较前按 MIME 语法剥离参数,例如 lowered = mime_type.split(";", 1)[0].strip().lower(),再做 text/ 前缀与 _DELIVERABLE_RESOURCE_MIME_TYPES 判断。
⚪ NIT: 同一分支对正文执行了两次 text.strip():条件判断中一次,body = text.strip() 中又一次;大正文(预期可达数百 KB~MB 级)会导致不必要的全量副本分配。
File: src/quickquip/llm/mcp/types.py
先取一次结果并复用:stripped = text.strip() if isinstance(text, str) else "",随后用 if stripped and _resource_mime_deliverable(mime_type): 并将 stripped 直接赋给 body。
⚪ NIT: 新增资源交付常量组被插在 _IMAGE_FORMAT_MIME_TYPES 与 _MAX_INLINE_IMAGE_BYTES 之间,把原本相邻的图片 MIME 常量组切成两段,且与模块统一使用两个空行的写法不一致,阅读时容易误认为图片常量组已结束。
File: src/quickquip/llm/mcp/types.py
将 _DELIVERABLE_RESOURCE_MIME_TYPES / _MAX_RESOURCE_TEXT_CHARS / _RESOURCE_TRUNCATION_MARKER 与 _resource_mime_deliverable 整块移到 _MAX_INLINE_IMAGE_BYTES 之后,保持顶层定义之间两个空行。
⚪ NIT: 资源正文有界化策略(截断 + 标记)与 type 分派逻辑耦合在同一分支中,且 text.strip() 被写了两遍;可以抽为同域具名函数 _bounded_resource_text 统一承载截断常量与标记的单一所有者。
File: src/quickquip/llm/mcp/types.py
抽取 def _bounded_resource_text(resource: dict[str, Any]) -> str | None:(返回 None 表示扣留),分派处改为 body = _bounded_resource_text(resource)。
⚪ NIT: 单测参数化列表未覆盖白名单中 application/yaml、application/x-yaml、application/toml、application/javascript 以及大写 MIME、带 charset 参数等形态,后续白名单增删不会被测试发现。
File: tests/unit/llm/test_mcp_result_normalization.py
参数化列表直接由 _DELIVERABLE_RESOURCE_MIME_TYPES 派生(并补一条 application/json; charset=utf-8 用例锁定修好后的预期),避免常量与测试各自维护、漂移。
⚪ NIT: 截断边界断言用魔法值 +40(即截断后固定标记长度),与文档/实现契约脱钩;_MAX_RESOURCE_TEXT_CHARS 在函数体内导入而非模块顶部。
File: tests/unit/llm/test_mcp_result_normalization.py
把导入提到模块顶部,把 + 40 换成 len(_RESOURCE_TRUNCATION_MARKER) 或直接使用字面量 60_000,让期望值来自文档化契约。
Review provenance
- General reviewer: complete — 4/4 units completed
- Style reviewer: complete — 4/4 units completed
- Coverage: complete
- Reviewed head:
f110459(base17e531a)
Automated review by @KHPilot. Reply with @KHPilot to ask follow-up questions.
| if not lowered or lowered.startswith("text/"): | ||
| return True | ||
| return lowered in _DELIVERABLE_RESOURCE_MIME_TYPES | ||
|
|
There was a problem hiding this comment.
SHOULD-FIX: E305 违规:def _resource_mime_deliverable(...) 与其后的模块级常量 _MAX_INLINE_IMAGE_BYTES 之间只留了 1 个空行,ruff select=["E","F"] 且未 ignore E305,pre-push hook 会直接报错。
在函数定义与 _MAX_INLINE_IMAGE_BYTES 之间补一个空行,保持模块级定义之间两个空行的既有写法。
| _RESOURCE_TRUNCATION_MARKER = "\n…[MCP resource 正文超长,已截断]" | ||
|
|
||
|
|
||
| def _resource_mime_deliverable(mime_type: str) -> bool: |
There was a problem hiding this comment.
SHOULD-FIX: _resource_mime_deliverable 对 MIME 做白名单比较时未剥离参数(;charset=... 等),而 _mime_type_from 内部走 _safe_metadata 会把 ;、= 等替换为 _,导致带 charset 参数的合法 MIME(如 application/json; charset=utf-8)变成 application/json__charset_utf-8 后无法命中白名单,application/* 文本资源在此类 server 下被整体扣留。
比较前按 MIME 语法剥离参数,例如 lowered = mime_type.split(";", 1)[0].strip().lower(),再做 text/ 前缀与 _DELIVERABLE_RESOURCE_MIME_TYPES 判断。
| text = resource.get("text") | ||
| # 内联文本 + 文本族 MIME 的资源有界交付(如 GitHub MCP 的 | ||
| # get_file_contents 文件正文);blob / 非文本 MIME / 无正文维持扣留。 | ||
| if isinstance(text, str) and text.strip() and _resource_mime_deliverable(mime_type): |
There was a problem hiding this comment.
NIT: 同一分支对正文执行了两次 text.strip():条件判断中一次,body = text.strip() 中又一次;大正文(预期可达数百 KB~MB 级)会导致不必要的全量副本分配。
先取一次结果并复用:stripped = text.strip() if isinstance(text, str) else "",随后用 if stripped and _resource_mime_deliverable(mime_type): 并将 stripped 直接赋给 body。
| assert RESOURCE_QUERY_SENTINEL not in result.content | ||
| assert RESOURCE_BODY_SENTINEL not in result.content | ||
|
|
||
|
|
There was a problem hiding this comment.
NIT: 单测参数化列表未覆盖白名单中 application/yaml、application/x-yaml、application/toml、application/javascript 以及大写 MIME、带 charset 参数等形态,后续白名单增删不会被测试发现。
参数化列表直接由 _DELIVERABLE_RESOURCE_MIME_TYPES 派生(并补一条 application/json; charset=utf-8 用例锁定修好后的预期),避免常量与测试各自维护、漂移。
| assert RESOURCE_BODY_SENTINEL not in result.content | ||
|
|
||
|
|
||
| def test_oversized_resource_text_truncated_with_marker(): |
There was a problem hiding this comment.
NIT: 截断边界断言用魔法值 +40(即截断后固定标记长度),与文档/实现契约脱钩;_MAX_RESOURCE_TEXT_CHARS 在函数体内导入而非模块顶部。
把导入提到模块顶部,把 + 40 换成 len(_RESOURCE_TRUNCATION_MARKER) 或直接使用字面量 60_000,让期望值来自文档化契约。
- _mime_type_from 在清洗前剥离 MIME 参数(;charset=…),带参数的 application/json 等文本族资源与图片 MIME 精确匹配恢复命中 - 抽 _bounded_resource_text 统一承载 strip/截断/标记,消除双次 strip 的全量副本分配;资源常量块移至图片常量组之后,顶层定义两空行(E305) - 单测参数化改由 _DELIVERABLE_RESOURCE_MIME_TYPES 派生并补大写/charset 形态;截断断言去魔法值、常量导入提顶
背景
生产群内 bot 经 GitHub MCP 读仓库文件:定位/下载/SHA 正常,读正文时被 MCP 归一化层整体扣下(「尚未支持的内容类型」)。根因:官方 github-mcp-server 的
get_file_contents把文件正文作为 EmbeddedResource 返回(正文在resource.text/resource.blob),现行_format_tool_result对所有 resource 项一律扣留——文件正文读取恰是 GitHub 工具集的核心能力。实施计划见内部稿(dev/plans/2026-09-11-mcp-resource-text-delivery.md)。#237 同议题第一步(交付开关拆分)已合并。变更摘要
src/quickquip/llm/mcp/types.py):内联text且 MIME 属文本族(text/*前缀、application/json|xml|yaml|x-yaml|toml|javascript白名单、MIME 缺省视为文本)→ 交付进result.text,与普通 text 项同管线(含敏感词扫描);blob、非文本 MIME、空白正文、audio、link 维持扣留 + 既有有限提示。docs/dev/mcp-integration.md§8 内容边界逐点同步。测试
CHANGELOG 草稿