authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-06 05:34:27-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-06 02:34:27-07:00
log3da315e7b5c4537cbc7280f33dadb3f5f0e3baf0
treee9f4378f66af901b3b8558af847ef2ae98f41792
parenta2ca89aa10f13a2222e08afd98c66285121d5ba2
signature Signed by PGP key B5690EEEBB952194

perf(server): stop shipping full MCP tool results in thread payloads (#5482)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

3 files changed, 203 insertions(+), 7 deletions(-)

apps/server/src/orchestration/ActivityPayloadProjection.test.ts+54
......@@ -44,6 +44,60 @@ describe("projectActivityPayload agent-field survival", () => {
4444 expect(data.somethingClientNeverReads).toBeUndefined();
4545 });
4646
47 it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => {
48 const projected = projectActivityPayload(
49 activity({
50 itemType: "mcp_tool_call",
51 data: {
52 item: {
53 type: "mcpToolCall",
54 id: "item-1",
55 tool: "fetch_pr",
56 server: "github",
57 status: "completed",
58 arguments: { pr: 42 },
59 durationMs: 1200,
60 result: {
61 content: [{ type: "text", text: `PR body line one\n${"x".repeat(5000)}` }],
62 structuredContent: { huge: "y".repeat(5000) },
63 },
64 _meta: { internal: true },
65 },
66 },
67 }),
68 );
69 const data = (projected.payload as Record<string, unknown>).data as Record<string, unknown>;
70 const item = data.item as Record<string, unknown>;
71 expect(item.tool).toBe("fetch_pr");
72 expect(item.server).toBe("github");
73 expect(item.arguments).toEqual({ pr: 42 });
74 expect(item._meta).toBeUndefined();
75 expect(item.result).toEqual({ content: "PR body line one" });
76 expect(JSON.stringify(projected.payload).length).toBeLessThan(500);
77 });
78
79 it("slims Claude-shaped mcp_tool_call data (toolName/input/result block)", () => {
80 const projected = projectActivityPayload(
81 activity({
82 itemType: "mcp_tool_call",
83 data: {
84 toolName: "mcp__github__fetch_pr",
85 input: { pr: 42 },
86 result: {
87 type: "tool_result",
88 tool_use_id: "toolu_1",
89 content: [{ type: "text", text: `first line of output\n${"z".repeat(5000)}` }],
90 },
91 },
92 }),
93 );
94 const data = (projected.payload as Record<string, unknown>).data as Record<string, unknown>;
95 expect(data.toolName).toBe("mcp__github__fetch_pr");
96 expect(data.input).toEqual({ pr: 42 });
97 expect(data.result).toEqual({ content: "first line of output" });
98 expect(JSON.stringify(projected.payload).length).toBeLessThan(500);
99 });
100
47101 it("passes task lifecycle payloads (no data field) through untouched", () => {
48102 const source = activity({
49103 taskId: "task-9",
apps/server/src/orchestration/ActivityPayloadProjection.ts+119-1
......@@ -123,6 +123,114 @@ function summarizeToolTextOutput(value: string): string | null {
123123 return null;
124124}
125125
126/**
127 * Fields of an MCP tool-call item both clients render in the expanded
128 * work-log row. Everything else — notably `result`, which carries the full
129 * tool output and dominates wire size on MCP-heavy threads — is summarized
130 * or dropped. Full payloads remain in persistence.
131 */
132const MCP_ITEM_KEPT_FIELDS = [
133 "type",
134 "id",
135 "tool",
136 "server",
137 "status",
138 "arguments",
139 "appContext",
140 "error",
141 "durationMs",
142] as const;
143
144/**
145 * Pulls renderable text out of an MCP tool result: either a Codex-style
146 * `{content: [{type: "text", text}, ...]}` record or a raw Claude
147 * `tool_result` block whose `content` is a string or block array.
148 */
149function extractMcpResultText(result: unknown): string | null {
150 const record = asRecord(result);
151 if (!record) {
152 return typeof result === "string" ? result : null;
153 }
154 if (typeof record.content === "string") {
155 return record.content;
156 }
157 if (Array.isArray(record.content)) {
158 const texts: string[] = [];
159 for (const entry of record.content) {
160 const text = asRecord(entry)?.text;
161 if (typeof text === "string" && text.trim().length > 0) {
162 texts.push(text);
163 }
164 }
165 if (texts.length > 0) {
166 return texts.join("\n");
167 }
168 }
169 return null;
170}
171
172function summarizeMcpResult(result: unknown): Record<string, unknown> | undefined {
173 if (result === undefined || result === null) {
174 return undefined;
175 }
176 const text = extractMcpResultText(result);
177 const summary = text ? summarizeToolTextOutput(text) : null;
178 return summary ? { content: summary } : undefined;
179}
180
181/**
182 * MCP tool calls carry full tool results (`data.item.result` on Codex,
183 * `data.result` on Claude/OpenCode) that used to bypass slimming entirely to
184 * keep the expanded-row UI working. Keep the fields the UI actually renders
185 * and summarize the result like regular tool output.
186 */
187function projectMcpToolCallData(data: Record<string, unknown>): Record<string, unknown> {
188 const projectedData: Record<string, unknown> = {};
189
190 const item = asRecord(data.item);
191 if (item) {
192 const projectedItem: Record<string, unknown> = {};
193 for (const key of MCP_ITEM_KEPT_FIELDS) {
194 if (key in item) {
195 projectedItem[key] = item[key];
196 }
197 }
198 const result = summarizeMcpResult(item.result);
199 if (result) {
200 projectedItem.result = result;
201 }
202 projectedData.item = projectedItem;
203 }
204
205 if ("toolName" in data) {
206 projectedData.toolName = data.toolName;
207 }
208 if ("input" in data) {
209 projectedData.input = data.input;
210 }
211 if (!item) {
212 const result = summarizeMcpResult(data.result);
213 if (result) {
214 projectedData.result = result;
215 }
216 }
217
218 if ("toolCallId" in data) {
219 projectedData.toolCallId = data.toolCallId;
220 }
221 if ("kind" in data) {
222 projectedData.kind = data.kind;
223 }
224
225 const changedFiles: string[] = [];
226 collectChangedFiles(data, changedFiles, new Set<string>(), 0);
227 if (changedFiles.length > 0) {
228 projectedData.files = changedFiles.map((path) => ({ path }));
229 }
230
231 return projectedData;
232}
233
126234function projectRawOutput(value: unknown): Record<string, unknown> | undefined {
127235 const rawOutput = asRecord(value);
128236 if (!rawOutput) {
......@@ -160,10 +268,20 @@ export function projectActivityPayload(
160268): OrchestrationThreadActivity {
161269 const payload = asRecord(activity.payload);
162270 const data = asRecord(payload?.data);
163 if (!payload || !data || payload.itemType === "mcp_tool_call") {
271 if (!payload || !data) {
164272 return activity;
165273 }
166274
275 if (payload.itemType === "mcp_tool_call") {
276 return {
277 ...activity,
278 payload: {
279 ...payload,
280 data: projectMcpToolCallData(data),
281 },
282 };
283 }
284
167285 const projectedData: Record<string, unknown> = {};
168286 const item = projectCommandData(data);
169287 if (item) {
apps/server/test/ActivityPayloadProjection.test.ts+30-6
......@@ -117,9 +117,9 @@ const fixtures = [
117117 server: "repository",
118118 tool: "search",
119119 arguments: { query: "activity projection" },
120 aggregatedOutput: "mcp payload remains available",
120 aggregatedOutput: "mcp bulk is dropped",
121121 },
122 ignored: "MCP data is rendered verbatim",
122 ignored: "top-level bulk",
123123 }),
124124 makeActivity("search", "web_search", {
125125 rawOutput: {
......@@ -184,13 +184,37 @@ describe("projectActivityPayload", () => {
184184 });
185185 });
186186
187 it("passes MCP tool data through unchanged", () => {
188 expect(projectActivityPayload(fixtures[4]!)).toBe(fixtures[4]);
187 it("slims MCP tool data to the fields the expanded row renders", () => {
188 expect(projectActivityPayload(fixtures[4]!).payload).toEqual({
189 itemType: "mcp_tool_call",
190 title: "mcp_tool_call",
191 detail: "mcp_tool_call detail",
192 status: "completed",
193 requestKind: "command",
194 data: {
195 item: {
196 server: "repository",
197 tool: "search",
198 arguments: { query: "activity projection" },
199 },
200 },
201 });
189202 });
190203
191204 it("keeps current web and mobile derived output identical for every tool item type", () => {
192205 for (const activity of fixtures) {
193206 const projected = projectActivityPayload(activity);
207 if (activity === fixtures[4]) {
208 // MCP is the one deliberate difference: the expanded row's toolData
209 // loses result bulk but keeps the rendered identity fields.
210 const [entry] = deriveWorkLogEntries([projected]);
211 expect(entry?.toolData).toEqual({
212 server: "repository",
213 tool: "search",
214 arguments: { query: "activity projection" },
215 });
216 continue;
217 }
194218 expect(deriveWorkLogEntries([projected])).toEqual(deriveWorkLogEntries([activity]));
195219 expect(comparableThreadFeed([projected])).toEqual(comparableThreadFeed([activity]));
196220 }
......@@ -328,12 +352,12 @@ describe("context-window snapshot dedup", () => {
328352 );
329353 });
330354
331 it("leaves snapshots without context-window activities untouched", () => {
355 it("applies only payload slimming when there are no context-window activities", () => {
332356 const projected = projectThreadDetailSnapshot({
333357 snapshotSequence: 7,
334358 thread: makeThread([fixtures[4]!]),
335359 });
336 expect(projected.thread.activities).toEqual([fixtures[4]]);
360 expect(projected.thread.activities).toEqual([projectActivityPayload(fixtures[4]!)]);
337361 });
338362
339363 it("does not filter live activity-appended events", () => {