【Bug已解决】How to handle token limit when processing large JSON response with MCP client-server? 解决方案
一、现象长什么样
你用 MCP 的 client-server 架构,某个 tool 返回了一个很大的 JSON(比如上千行记录、一份长配置),结果:
- 工具结果塞进对话后,直接触发 token 超限,后续请求被拒或截断;
- Claude 处理这个大 JSON 时变慢、甚至把上下文窗口挤爆,导致"输入过长";
- 你不想丢掉数据,但又不能把整份 JSON 全塞进
toolResult; - MCP 没有内建的"大响应分页",默认是整个结果一次回传;
- 你尝试截断 JSON,但截断破坏了结构,模型解析失败。
一句话:MCP tool 返回超大 JSON 时,若原样塞进toolResult(或 messages),会撑爆 token 预算——需要在 server 侧做"裁剪/分页/摘要/外置存储",只把模型真正需要的部分送进上下文。
二、背景
MCP 的工具结果最终会变成一条消息进入 LLM 上下文。LLM 上下文是有限且昂贵的。一个动辄几万 token 的 JSON 如果原样回传:
- 直接吃掉上下文窗口,挤掉 system/history/其他工具结果;
- 让每次请求都更贵、更慢;
- 模型其实只需要其中一小部分来回答用户问题。
正确思路不是"把大 JSON 硬塞",而是在 server 侧把它变成模型可消化的大小:分页取前 N 条、按查询过滤、生成摘要、或把完整数据写到文件/外部存储、只回传"路径 + 摘要"。
三、根因
根因是server 把超大原始 JSON 直接作为工具结果回传,未做任何尺寸治理:
# 错误:整份大 JSON 直接回传 @app.tool() def query_all_orders(): rows = db.fetch_all() # 10 万行 return json.dumps(rows) # 直接进上下文 -> token 爆炸# 正确:server 侧裁剪/分页/摘要 @app.tool() def query_orders(limit=20, keyword=None): rows = db.fetch_filtered(keyword, limit=limit) return json.dumps({ "total_matched": db.count(keyword), "returned": len(rows), "sample": rows, # 只回前 N 条 })四、最小可运行复现
下面用 Python 模拟"大 JSON 裁剪后回传":
import json from dataclasses import dataclass @dataclass class _OrderTool: def fetch_all(self) -> list: return [{"id": i, "amount": i * 10} for i in range(100_000)] # 巨大 def tool_result(self, keyword=None, limit: int = 20) -> str: rows = self.fetch_all() if keyword: rows = [r for r in rows if keyword in str(r)] total = len(rows) sample = rows[:limit] # 只回传总数 + 样本,而非全量 return json.dumps({ "total_matched": total, "returned": len(sample), "note": "仅返回前 %d 条样本,完整数据请分页获取" % limit, "sample": sample, }, ensure_ascii=False) def main(): tool = _OrderTool() print(len(tool.tool_result())) # 远小于全量 print(tool.tool_result(keyword="5", limit=5)) # 带过滤 if __name__ == "__main__": main()运行后工具结果从"十万行"降到"总数+样本",token 量可控。
五、解决方案(第一层:最小直接修复)
最小修复是server 侧给工具加limit/keyword参数,只回传必要部分:
@mcp.tool() def search_orders(keyword: str = "", limit: int = 20) -> str: rows = db.query(keyword=keyword) total = len(rows) sample = rows[:limit] return json.dumps({ "total_matched": total, "returned": len(sample), "sample": sample, }, ensure_ascii=False)若数据必须完整保留,改为外置存储:把完整 JSON 写文件,只回传路径与摘要:
import tempfile, os @mcp.tool() def export_large_report() -> str: data = generate_huge_report() # 大 JSON path = tempfile.mktemp(suffix=".json") with open(path, "w") as f: json.dump(data, f, ensure_ascii=False) # 只回传摘要 + 路径 return json.dumps({ "summary": f"报告含 {len(data)} 条记录", "saved_to": path, "hint": "需要具体内容请用 read_file 工具读取该路径", }, ensure_ascii=False)六、解决方案(第二层:结构化改进)
把"大响应治理"做成策略,集中决定裁剪/分页/外置:
from dataclasses import dataclass, field import json import tempfile from pathlib import Path from typing import Any, Dict, List @dataclass(frozen=True) class McpLargeJsonPolicy: """MCP 大 JSON 响应策略:尺寸治理,保护 token 预算。 规则: - 超过阈值则自动裁剪为 (总数, 样本, 提示) - 或外置存储,只回传路径+摘要 - 绝不允许原始全量直接进上下文 """ token_threshold: int = 2000 # 超过则治理 sample_limit: int = 20 def chars_of(self, obj: Any) -> int: return len(json.dumps(obj, ensure_ascii=False)) def respond(self, data: Any, *, external_dir: str = None) -> str: if self.chars_of(data) <= self.token_threshold * 4: return json.dumps(data, ensure_ascii=False) # 小,直接回 # 大:裁剪 if isinstance(data, list): total = len(data) sample = data[: self.sample_limit] payload = {"total": total, "returned": len(sample), "sample": sample, "note": "已裁剪"} else: payload = {"note": "对象过大已裁剪", "keys": list(data.keys())} # 可选:外置完整数据 if external_dir: p = Path(external_dir) / "large_result.json" p.write_text(json.dumps(data, ensure_ascii=False)) payload["saved_to"] = str(p) return json.dumps(payload, ensure_ascii=False) def demo() -> None: policy = McpLargeJsonPolicy() big = [{"id": i} for i in range(100_000)] out = policy.respond(big, external_dir="/tmp") assert "total" in json.loads(out) print("大 JSON 治理 OK:", len(out), "字符") if __name__ == "__main__": demo()七、解决方案(第三层:断言 / CI 守护)
import json import pytest from your_module import McpLargeJsonPolicy def test_small_passthrough(): policy = McpLargeJsonPolicy() data = [{"id": 1}] out = json.loads(policy.respond(data)) assert out == [{"id": 1}] def test_large_truncated(): policy = McpLargeJsonPolicy(token_threshold=1) # 极小阈值触发治理 big = [{"id": i} for i in range(100)] out = json.loads(policy.respond(big)) assert "total" in out and "sample" in out assert out["total"] == 100 assert len(out["sample"]) <= policy.sample_limit def test_external_saved(): policy = McpLargeJsonPolicy(token_threshold=1) big = [{"id": i} for i in range(50)] out = json.loads(policy.respond(big, external_dir="/tmp")) assert "saved_to" in out def test_list_type(): policy = McpLargeJsonPolicy(token_threshold=1) out = json.loads(policy.respond([1, 2, 3])) assert out["total"] == 3 def test_dict_type(): policy = McpLargeJsonPolicy(token_threshold=1) out = json.loads(policy.respond({"a": 1, "b": 2})) assert "keys" in out def test_threshold_respected(): policy = McpLargeJsonPolicy(token_threshold=100000) small = [{"id": 1}] out = json.loads(policy.respond(small)) assert out == [{"id": 1}] # 未触发治理CI 里加一条:对所有 MCP tool 返回,断言若超过 token 阈值则已治理(含 total/样本/或外置路径),避免大 JSON 撑爆上下文。
八、排查清单
- tool 返回是否原样塞了整份大 JSON?那必爆 token。
- 是否给工具加了
limit/keyword参数做服务端过滤?只回必要部分。 - 是否用"总数+样本+提示"替代全量?模型通常只需样本。
- 是否考虑外置存储(写文件)只回路径+摘要?
- MCP 是否支持分页?让客户端分批拉。
- 是否用
count_tokens_approximately预估返回尺寸,超阈值即治理?
九、小结
MCP client-server 处理大 JSON 响应触发 token 超限,根因是 server 把超大原始 JSON 直接作为工具结果回传,撑爆上下文。最小修复是 server 侧加limit/keyword只回样本、或外置存储只回路径+摘要;结构化做法是抽成McpLargeJsonPolicy,按 token 阈值自动裁剪/外置;最后用 pytest 守护"超阈值必治理",保护 LLM 的 token 预算。