Skip to content

feat(mcp): 内联文本资源有界交付 - #238

Merged
3aKHP merged 2 commits into
devfrom
feat/1.15.3-mcp-resource-text
Sep 10, 2026
Merged

3aKHP merged 2 commits into
devfrom
feat/1.15.3-mcp-resource-text

Conversation

@3aKHP

@3aKHP 3aKHP commented Sep 10, 2026

Copy link
Copy Markdown
Owner

背景

生产群内 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 维持扣留 + 既有有限提示。
  • 有界:60,000 code point 截断 + 固定标记(普通 text 项无上限,资源项从严)。
  • 安全边界不变:URI 不随正文渲染(query 可能携带凭据);不自动下载 resource/link;blob/音频数据仍不注入。深审对 MIME 白名单做了定向绕过实测(伪装前缀、charset 附加、清洗截断伪造),确认 fail-closed 无绕过窗口。
  • 文档docs/dev/mcp-integration.md §8 内容边界逐点同步。

测试

  • 本地全量 2010 passed;SubAgent 深审无阻塞(2 MINOR 已收口:changelog 草稿、isError 交付用例)。
  • 单测:文本族交付(含缺省 MIME)、非文本 MIME 有 text 仍扣留、截断精确边界(60,000/60,001)、空白正文扣留、isError 路径交付、URI query 哨兵不泄漏。
  • 集成:既有扣留用例 resource 项改 blob;新增交付路径用例(正文达 provider、URI query 不可达)。

CHANGELOG 草稿

修复

  • 修复经 GitHub 等 MCP 工具读取文件正文时被内容安全边界整体扣留的问题:内联文本资源(文本类 MIME)的正文现在有界交付给模型,二进制内容与链接仍维持扣留。

- 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 不泄漏)

@khpilot khpilot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/yamlapplication/x-yamlapplication/tomlapplication/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 (base 17e531a)

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 判断。

Comment thread src/quickquip/llm/mcp/types.py Outdated
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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: 单测参数化列表未覆盖白名单中 application/yamlapplication/x-yamlapplication/tomlapplication/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():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 形态;截断断言去魔法值、常量导入提顶
@3aKHP
3aKHP merged commit e750483 into dev Sep 10, 2026
3 checks passed
@3aKHP
3aKHP deleted the feat/1.15.3-mcp-resource-text branch September 11, 2026 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant