Skip to content

Commit ce21241

Browse files
committed
chore(sync): cascade fleet template@5f1d703
Auto-applied by socket-wheelhouse sync-scaffolding into vscode-socket-security. 6 file(s) touched: - .claude/hooks/minify-mcp-output/README.md - .claude/hooks/minify-mcp-output/index.mts - .claude/hooks/minify-mcp-output/package.json - .claude/hooks/minify-mcp-output/test/index.test.mts - .claude/hooks/minify-mcp-output/tsconfig.json - .claude/settings.json
1 parent 6feacf5 commit ce21241

6 files changed

Lines changed: 441 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# minify-mcp-output
2+
3+
A **Claude Code PostToolUse hook** that compresses MCP-tool output text
4+
losslessly before it enters Claude's context. Pairs with the wire-level
5+
proxy [`@socketsecurity/token-minifier`](../../packages/socket-token-minifier/)
6+
for built-in tools (Read, Bash, Edit, etc.) — those have no PostToolUse
7+
rewrite channel, so they only benefit from wire-level compression.
8+
9+
## Why this rule
10+
11+
MCP tools (declared via `.mcp.json`) can produce verbose output: JSON
12+
arrays, nested objects, long text fields with whitespace and line
13+
prefixes. Stage compression saves tokens **both** on the wire AND in
14+
context (because Claude reads the compressed version going forward).
15+
16+
Built-in tool results don't go through this hook — Claude Code's hook
17+
runtime accepts `updatedMCPToolOutput` only when `tool_name` starts
18+
with `mcp__`. For built-in tools, use the proxy instead.
19+
20+
## Stages (identical to socket-token-minifier)
21+
22+
| Stage | What it does |
23+
| ------------- | ------------------------------------------------------------------ |
24+
| `minify` | `JSON.stringify` without indent on JSON-shaped strings. |
25+
| `strip-lines` | Removes ` 42\t` cat -n style line prefixes. |
26+
| `whitespace` | Collapses 3+ blank lines to a single blank line. |
27+
28+
All are deterministic, information-preserving transforms. No semantic
29+
compression, no ML, no Python.
30+
31+
## What's enforced
32+
33+
- Hook fires only on `PostToolUse`.
34+
- Hook activates only when `tool_name` starts with `mcp__`.
35+
- Stages applied to all text content in the MCP `tool_response`,
36+
including string-shaped responses, `{type:"text", text:"..."}` blocks,
37+
and arrays thereof.
38+
- Non-text content (images, structured data) passes through unchanged.
39+
- The hook fails **open** on any internal error (exit 0 with no output)
40+
so a bad deploy can't break tool delivery.
41+
42+
## What's not enforced
43+
44+
- Built-in tools (Read, Bash, Edit, Write, etc.) — Claude Code's
45+
runtime does not accept `updatedMCPToolOutput` for them. Use the
46+
proxy for wire-level compression.
47+
48+
## Wiring
49+
50+
In `.claude/settings.json`:
51+
52+
```json
53+
{
54+
"hooks": {
55+
"PostToolUse": [
56+
{
57+
"matcher": "mcp__.*",
58+
"hooks": [
59+
{
60+
"type": "command",
61+
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/minify-mcp-output/index.mts"
62+
}
63+
]
64+
}
65+
]
66+
}
67+
}
68+
```
69+
70+
The matcher `mcp__.*` is a belt-and-suspenders narrowing — the hook
71+
itself also checks `tool_name` startsWith `mcp__` and exits 0 if it
72+
doesn't match.
73+
74+
## Cross-fleet sync
75+
76+
This hook lives in
77+
[`socket-wheelhouse`](https://github.com/SocketDev/socket-wheelhouse/tree/main/template/.claude/hooks/minify-mcp-output)
78+
and is required to be byte-identical across every fleet repo.
79+
`scripts/sync-scaffolding.mts` flags drift; `--fix` rewrites it.
80+
81+
The compression-stage logic is intentionally **inlined** here rather
82+
than imported from `packages/socket-token-minifier/` — that package
83+
lives only in wheelhouse, while this hook cascades fleet-wide.
84+
Inlining keeps the dependency-resolution graph trivial for downstream
85+
repos.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "hook-minify-mcp-output",
3+
"private": true,
4+
"type": "module",
5+
"main": "./index.mts",
6+
"exports": {
7+
".": "./index.mts"
8+
},
9+
"scripts": {
10+
"test": "node --test test/*.test.mts"
11+
}
12+
}

0 commit comments

Comments
 (0)