| ... | @@ -114,12 +114,15 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{ | ... | @@ -114,12 +114,15 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{ |
| 114 | }>()(makeMcpAuthMiddleware).layer; | 114 | }>()(makeMcpAuthMiddleware).layer; |
| 115 | | 115 | |
| 116 | /** | 116 | /** |
| 117 | * Claude Code drops every MCP result above 25k tokens (~100 KB of text) and | 117 | * Claude Code moves an MCP result above its output limit to a file and hands |
| 118 | * hands the agent a truncation notice instead, so a snapshot that carries the | 118 | * the agent a notice instead, so a snapshot that carries the full |
| 119 | * full accessibility tree and 20 KB of page text loses its locators too. Keep | 119 | * accessibility tree and page text loses its locators too. Claude Code also |
| 120 | * the text under that ceiling and tell the agent what was cut. | 120 | * shows the model `structuredContent` in place of the text blocks when a |
| | 121 | * result has both, so both carry the same bounded snapshot. Keep it near |
| | 122 | * 20 KB and tell the agent what was cut. The short `omitted` notes may go a |
| | 123 | * little over; the provider limit is far above this. |
| 121 | */ | 124 | */ |
| 122 | export const MAX_SNAPSHOT_TEXT_BYTES = 60_000; | 125 | export const MAX_SNAPSHOT_TEXT_BYTES = 20_000; |
| 123 | const MAX_SNAPSHOT_VISIBLE_TEXT_CHARS = 8_000; | 126 | const MAX_SNAPSHOT_VISIBLE_TEXT_CHARS = 8_000; |
| 124 | const MAX_SNAPSHOT_ELEMENT_NAME_CHARS = 200; | 127 | const MAX_SNAPSHOT_ELEMENT_NAME_CHARS = 200; |
| 125 | const MAX_SNAPSHOT_LOG_ENTRIES = 40; | 128 | const MAX_SNAPSHOT_LOG_ENTRIES = 40; |
| ... | @@ -164,12 +167,11 @@ type SnapshotMetadata = { | ... | @@ -164,12 +167,11 @@ type SnapshotMetadata = { |
| 164 | /** | 167 | /** |
| 165 | * Drops the accessibility tree, shortens page text, element names, identifiers, | 168 | * Drops the accessibility tree, shortens page text, element names, identifiers, |
| 166 | * and log strings, keeps only the newest log entries, and finally sheds | 169 | * and log strings, keeps only the newest log entries, and finally sheds |
| 167 | * interactive elements until the JSON fits. Returns the text plus notes on | 170 | * interactive elements until the JSON fits. Returns the bounded value, its |
| 168 | * what is missing so the agent can reach for preview_evaluate. | 171 | * text, and notes on what is missing so the agent can reach for |
| | 172 | * preview_evaluate. |
| 169 | */ | 173 | */ |
| 170 | const boundSnapshotMetadata = ( | 174 | const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { |
| 171 | metadata: SnapshotMetadata, | | |
| 172 | ): { readonly text: string; readonly omitted: ReadonlyArray<string> } => { | | |
| 173 | const omitted: Array<string> = []; | 175 | const omitted: Array<string> = []; |
| 174 | const { accessibilityTree, ...withoutTree } = metadata; | 176 | const { accessibilityTree, ...withoutTree } = metadata; |
| 175 | if (accessibilityTree !== undefined) { | 177 | if (accessibilityTree !== undefined) { |
| ... | @@ -198,16 +200,10 @@ const boundSnapshotMetadata = ( | ... | @@ -198,16 +200,10 @@ const boundSnapshotMetadata = ( |
| 198 | ) { | 200 | ) { |
| 199 | omitted.push(`element names longer than ${MAX_SNAPSHOT_ELEMENT_NAME_CHARS} characters`); | 201 | omitted.push(`element names longer than ${MAX_SNAPSHOT_ELEMENT_NAME_CHARS} characters`); |
| 200 | } | 202 | } |
| 201 | if (metadata.visibleText.length > MAX_SNAPSHOT_VISIBLE_TEXT_CHARS) { | | |
| 202 | omitted.push( | | |
| 203 | `visibleText after ${MAX_SNAPSHOT_VISIBLE_TEXT_CHARS} characters (use preview_evaluate for more)`, | | |
| 204 | ); | | |
| 205 | } | | |
| 206 | const bounded = { | 203 | const bounded = { |
| 207 | ...withoutTree, | 204 | ...withoutTree, |
| 208 | url: cutText(metadata.url, MAX_SNAPSHOT_IDENTIFIER_CHARS), | 205 | url: cutText(metadata.url, MAX_SNAPSHOT_IDENTIFIER_CHARS), |
| 209 | title: cutText(metadata.title, MAX_SNAPSHOT_IDENTIFIER_CHARS), | 206 | title: cutText(metadata.title, MAX_SNAPSHOT_IDENTIFIER_CHARS), |
| 210 | visibleText: cutText(metadata.visibleText, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS), | | |
| 211 | interactiveElements: metadata.interactiveElements.map((element) => ({ | 207 | interactiveElements: metadata.interactiveElements.map((element) => ({ |
| 212 | ...element, | 208 | ...element, |
| 213 | name: cutText(element.name, MAX_SNAPSHOT_ELEMENT_NAME_CHARS), | 209 | name: cutText(element.name, MAX_SNAPSHOT_ELEMENT_NAME_CHARS), |
| ... | @@ -218,9 +214,10 @@ const boundSnapshotMetadata = ( | ... | @@ -218,9 +214,10 @@ const boundSnapshotMetadata = ( |
| 218 | }; | 214 | }; |
| 219 | | 215 | |
| 220 | // Per-field caps do not sum below the ceiling: three log arrays of 40 capped | 216 | // Per-field caps do not sum below the ceiling: three log arrays of 40 capped |
| 221 | // entries alone can pass 60 KB. Shed the least useful lists first, halving | 217 | // entries alone can pass 60 KB, and the caps count characters, not bytes. |
| 222 | // one list per round, until the JSON fits. With every list empty the rest | 218 | // Halve one thing per round until the JSON fits: logs first, then page |
| 223 | // is bounded by the identifier and visibleText caps, so this terminates. | 219 | // text, then the locators. The identifier caps bound the rest, so this |
| | 220 | // terminates. |
| 224 | const shedOrder = [ | 221 | const shedOrder = [ |
| 225 | "actionTimeline", | 222 | "actionTimeline", |
| 226 | "networkEntries", | 223 | "networkEntries", |
| ... | @@ -239,31 +236,51 @@ const boundSnapshotMetadata = ( | ... | @@ -239,31 +236,51 @@ const boundSnapshotMetadata = ( |
| 239 | networkEntries: 0, | 236 | networkEntries: 0, |
| 240 | actionTimeline: 0, | 237 | actionTimeline: 0, |
| 241 | }; | 238 | }; |
| 242 | let text = encodeJsonText({ ...bounded, ...lists }); | 239 | let visibleTextChars = Math.min(metadata.visibleText.length, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS); |
| | 240 | const value = () => ({ |
| | 241 | ...bounded, |
| | 242 | visibleText: cutText(metadata.visibleText, visibleTextChars), |
| | 243 | ...lists, |
| | 244 | }); |
| | 245 | let text = encodeJsonText(value()); |
| 243 | while (utf8Length(text) > MAX_SNAPSHOT_TEXT_BYTES) { | 246 | while (utf8Length(text) > MAX_SNAPSHOT_TEXT_BYTES) { |
| 244 | // Elements carry the locators, so they go last; logs shed newest-last. | 247 | // Elements carry the locators, so they go last; logs shed newest-last. |
| 245 | const key = | 248 | const key = |
| 246 | shedOrder.find( | 249 | shedOrder.find( |
| 247 | (candidate) => candidate !== "interactiveElements" && lists[candidate].length > 0, | 250 | (candidate) => candidate !== "interactiveElements" && lists[candidate].length > 0, |
| 248 | ) ?? (lists.interactiveElements.length > 0 ? "interactiveElements" : undefined); | 251 | ) ?? |
| | 252 | (visibleTextChars > 0 |
| | 253 | ? "visibleText" |
| | 254 | : lists.interactiveElements.length > 0 |
| | 255 | ? "interactiveElements" |
| | 256 | : undefined); |
| 249 | if (key === undefined) break; | 257 | if (key === undefined) break; |
| 250 | const keep = Math.floor(lists[key].length / 2); | 258 | if (key === "visibleText") { |
| 251 | dropped[key] += lists[key].length - keep; | 259 | visibleTextChars = Math.floor(visibleTextChars / 2); |
| 252 | // slice(-0) keeps everything, so spell out the empty case. | 260 | } else { |
| 253 | lists[key] = | 261 | const keep = Math.floor(lists[key].length / 2); |
| 254 | keep === 0 | 262 | dropped[key] += lists[key].length - keep; |
| 255 | ? [] | 263 | // slice(-0) keeps everything, so spell out the empty case. |
| 256 | : key === "interactiveElements" | 264 | lists[key] = |
| 257 | ? lists[key].slice(0, keep) | 265 | keep === 0 |
| 258 | : lists[key].slice(-keep); | 266 | ? [] |
| 259 | text = encodeJsonText({ ...bounded, ...lists }); | 267 | : key === "interactiveElements" |
| | 268 | ? lists[key].slice(0, keep) |
| | 269 | : lists[key].slice(-keep); |
| | 270 | } |
| | 271 | text = encodeJsonText(value()); |
| | 272 | } |
| | 273 | if (visibleTextChars < metadata.visibleText.length) { |
| | 274 | omitted.push( |
| | 275 | `visibleText after ${visibleTextChars} characters (use preview_evaluate for more)`, |
| | 276 | ); |
| 260 | } | 277 | } |
| 261 | for (const key of shedOrder) { | 278 | for (const key of shedOrder) { |
| 262 | if (dropped[key] > 0) { | 279 | if (dropped[key] > 0) { |
| 263 | omitted.push(`${dropped[key]} of ${bounded[key].length} ${key}`); | 280 | omitted.push(`${dropped[key]} of ${bounded[key].length} ${key}`); |
| 264 | } | 281 | } |
| 265 | } | 282 | } |
| 266 | return { text, omitted }; | 283 | return { value: value(), text, omitted }; |
| 267 | }; | 284 | }; |
| 268 | | 285 | |
| 269 | export class PreviewScreenshotSaveError extends Schema.TaggedError<PreviewScreenshotSaveError>()( | 286 | export class PreviewScreenshotSaveError extends Schema.TaggedError<PreviewScreenshotSaveError>()( |
| ... | @@ -396,6 +413,18 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot | ... | @@ -396,6 +413,18 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot |
| 396 | const png = new Uint8Array(Buffer.from(screenshot.data, "base64")); | 413 | const png = new Uint8Array(Buffer.from(screenshot.data, "base64")); |
| 397 | const screenshotPath = | 414 | const screenshotPath = |
| 398 | payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined; | 415 | payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined; |
| | 416 | if (screenshotPath !== undefined && payload?.includeImage === false) { |
| | 417 | // The agent only wants a file to show the user. The url keeps the site icon on the tool row. |
| | 418 | const saved = { |
| | 419 | url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS), |
| | 420 | screenshotPath, |
| | 421 | }; |
| | 422 | return new McpSchema.CallToolResult({ |
| | 423 | isError: false, |
| | 424 | structuredContent: saved, |
| | 425 | content: [{ type: "text", text: encodeJsonText(saved) }], |
| | 426 | }); |
| | 427 | } |
| 399 | const metadata = { | 428 | const metadata = { |
| 400 | ...page, | 429 | ...page, |
| 401 | screenshot: { | 430 | screenshot: { |
| ... | @@ -408,7 +437,10 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot | ... | @@ -408,7 +437,10 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot |
| 408 | const bounded = boundSnapshotMetadata(metadata); | 437 | const bounded = boundSnapshotMetadata(metadata); |
| 409 | return new McpSchema.CallToolResult({ | 438 | return new McpSchema.CallToolResult({ |
| 410 | isError: false, | 439 | isError: false, |
| 411 | structuredContent: metadata, | 440 | structuredContent: |
| | 441 | bounded.omitted.length === 0 |
| | 442 | ? bounded.value |
| | 443 | : { ...bounded.value, omitted: bounded.omitted }, |
| 412 | content: [ | 444 | content: [ |
| 413 | // Keep the page identity readable even if a provider truncates the snapshot. | 445 | // Keep the page identity readable even if a provider truncates the snapshot. |
| 414 | { | 446 | { |