|
| 1 | +#!/usr/bin/env node |
| 2 | +// Claude Code PostToolUse hook — minify-mcp-output. |
| 3 | +// |
| 4 | +// Applies lossless minification stages (minify / strip-lines / |
| 5 | +// whitespace) to MCP-tool output text and returns the result via |
| 6 | +// `hookSpecificOutput.updatedMCPToolOutput` — the only documented |
| 7 | +// rewrite channel for PostToolUse, verified empirically. |
| 8 | +// |
| 9 | +// Scope: |
| 10 | +// - PostToolUse only. |
| 11 | +// - tool_name starts with `mcp__` (Claude Code's MCP tool naming |
| 12 | +// convention: mcp__<server>__<tool>). |
| 13 | +// - Other tool names (built-in: Read/Bash/Edit/etc.) pass through |
| 14 | +// untouched — those have no PostToolUse rewrite channel; use the |
| 15 | +// wire-level proxy (socket-token-minifier) instead. |
| 16 | +// |
| 17 | +// The hook fails OPEN on its own errors (exit 0 with no output) so a |
| 18 | +// bad deploy can't break tool result delivery. |
| 19 | +// |
| 20 | +// Stages here are inlined (not imported from packages/socket-token- |
| 21 | +// minifier/) because this hook cascades into every fleet repo via |
| 22 | +// sync-scaffolding, while packages/socket-token-minifier/ lives only |
| 23 | +// in wheelhouse. The stage logic is small enough that inlining is |
| 24 | +// cleaner than orchestrating a workspace dependency that downstream |
| 25 | +// repos don't have. |
| 26 | + |
| 27 | +import process from 'node:process' |
| 28 | + |
| 29 | +interface Payload { |
| 30 | + hook_event_name?: string |
| 31 | + tool_name?: string |
| 32 | + tool_response?: unknown |
| 33 | + // Plus session_id, cwd, etc. — we don't care. |
| 34 | +} |
| 35 | + |
| 36 | +// ---------- Inlined stages (synced with packages/socket-token-minifier/src/stages/) ---------- |
| 37 | + |
| 38 | +function minify(text: string): string { |
| 39 | + const trimmed = text.trimStart() |
| 40 | + if (trimmed.length === 0) return text |
| 41 | + const first = trimmed.charCodeAt(0) |
| 42 | + if (first !== 0x7b && first !== 0x5b) return text |
| 43 | + let parsed: unknown |
| 44 | + try { |
| 45 | + parsed = JSON.parse(text) |
| 46 | + } catch { |
| 47 | + return text |
| 48 | + } |
| 49 | + return JSON.stringify(parsed) |
| 50 | +} |
| 51 | + |
| 52 | +const LINE_PREFIX_RE = /^[ \t]*\d+\t/gm |
| 53 | +function stripLines(text: string): string { |
| 54 | + return text.replace(LINE_PREFIX_RE, '') |
| 55 | +} |
| 56 | + |
| 57 | +const BLANK_RUN_RE = /\n(?:[ \t]*\n){2,}/g |
| 58 | +function whitespace(text: string): string { |
| 59 | + return text.replace(BLANK_RUN_RE, '\n\n') |
| 60 | +} |
| 61 | + |
| 62 | +function applyStages(text: string): string { |
| 63 | + return whitespace(stripLines(minify(text))) |
| 64 | +} |
| 65 | + |
| 66 | +// ---------- Tool-response walker ---------- |
| 67 | + |
| 68 | +/** |
| 69 | + * Walk an MCP tool_response value and compress text content in place. |
| 70 | + * Returns the same structure with strings minified. Non-text content |
| 71 | + * (images, structured data we don't recognize) passes through |
| 72 | + * unchanged. |
| 73 | + * |
| 74 | + * Shapes we handle: |
| 75 | + * - string → minified string. |
| 76 | + * - { type: "text", text: string } → minified text. |
| 77 | + * - { content: <recurse> } |
| 78 | + * - { type: "text", text: string }[] (typical MCP shape). |
| 79 | + * - other → passes through. |
| 80 | + */ |
| 81 | +export function compressMCPOutput(value: unknown): unknown { |
| 82 | + if (typeof value === 'string') { |
| 83 | + return applyStages(value) |
| 84 | + } |
| 85 | + if (Array.isArray(value)) { |
| 86 | + return value.map(compressMCPOutput) |
| 87 | + } |
| 88 | + if (value !== null && typeof value === 'object') { |
| 89 | + const obj = value as Record<string, unknown> |
| 90 | + const out: Record<string, unknown> = { ...obj } |
| 91 | + if (typeof obj['text'] === 'string') { |
| 92 | + out['text'] = applyStages(obj['text']) |
| 93 | + } |
| 94 | + if (obj['content'] !== undefined) { |
| 95 | + out['content'] = compressMCPOutput(obj['content']) |
| 96 | + } |
| 97 | + return out |
| 98 | + } |
| 99 | + return value |
| 100 | +} |
| 101 | + |
| 102 | +// ---------- Hook IO ---------- |
| 103 | + |
| 104 | +export function isMCPToolName(name: string | undefined): boolean { |
| 105 | + return typeof name === 'string' && name.startsWith('mcp__') |
| 106 | +} |
| 107 | + |
| 108 | +function main() { |
| 109 | + let stdin = '' |
| 110 | + process.stdin.on('data', chunk => { |
| 111 | + stdin += chunk |
| 112 | + }) |
| 113 | + process.stdin.on('end', () => { |
| 114 | + try { |
| 115 | + let payload: Payload |
| 116 | + try { |
| 117 | + payload = JSON.parse(stdin) as Payload |
| 118 | + } catch { |
| 119 | + process.exit(0) |
| 120 | + } |
| 121 | + if (payload.hook_event_name !== 'PostToolUse') { |
| 122 | + process.exit(0) |
| 123 | + } |
| 124 | + if (!isMCPToolName(payload.tool_name)) { |
| 125 | + process.exit(0) |
| 126 | + } |
| 127 | + const original = payload.tool_response |
| 128 | + if (original === undefined) { |
| 129 | + process.exit(0) |
| 130 | + } |
| 131 | + const compressed = compressMCPOutput(original) |
| 132 | + const out = { |
| 133 | + hookSpecificOutput: { |
| 134 | + hookEventName: 'PostToolUse', |
| 135 | + updatedMCPToolOutput: compressed, |
| 136 | + }, |
| 137 | + } |
| 138 | + process.stdout.write(JSON.stringify(out)) |
| 139 | + process.exit(0) |
| 140 | + } catch { |
| 141 | + // Fail-open: silently exit 0 so Claude Code uses the original. |
| 142 | + process.exit(0) |
| 143 | + } |
| 144 | + }) |
| 145 | + if (process.stdin.readable === false) { |
| 146 | + process.exit(0) |
| 147 | + } |
| 148 | +} |
| 149 | + |
| 150 | +main() |
0 commit comments