Skip to content

Commit d7000c6

Browse files
committed
feat: add PostToolUse hook to compress MCP tool responses
1 parent c85e348 commit d7000c6

4 files changed

Lines changed: 478 additions & 0 deletions

File tree

hooks/rtk-post-tool-use.sh

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env bash
2+
3+
if ! command -v jq &>/dev/null; then
4+
exit 0
5+
fi
6+
7+
if ! command -v rtk &>/dev/null; then
8+
exit 0
9+
fi
10+
11+
RTK_LOG_DIR="${RTK_LOG_DIR:-$HOME/.local/share/rtk/logs}"
12+
mkdir -p "$RTK_LOG_DIR"
13+
LOG_FILE="$RTK_LOG_DIR/mcp-filter.log"
14+
15+
INPUT=$(cat)
16+
17+
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
18+
19+
if [ -z "$TOOL_NAME" ]; then
20+
exit 0
21+
fi
22+
23+
case "$TOOL_NAME" in
24+
mcp__*) ;;
25+
*) exit 0 ;;
26+
esac
27+
28+
RESULT=$(echo "$INPUT" | rtk filter-mcp-output 2>>"$LOG_FILE")
29+
EXIT_CODE=$?
30+
31+
if [ $EXIT_CODE -ne 0 ]; then
32+
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] filter-mcp-output failed (exit $EXIT_CODE) for tool: $TOOL_NAME" >> "$LOG_FILE"
33+
exit 0
34+
fi
35+
36+
if [ -z "$RESULT" ]; then
37+
exit 0
38+
fi
39+
40+
echo "$RESULT"

src/init.rs

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::integrity;
88

99
// Embedded hook script (guards before set -euo pipefail)
1010
const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh");
11+
const POST_TOOL_USE_HOOK: &str = include_str!("../hooks/rtk-post-tool-use.sh");
1112

1213
// Embedded Cursor hook script (preToolUse format)
1314
const CURSOR_REWRITE_HOOK: &str = include_str!("../hooks/cursor-rtk-rewrite.sh");
@@ -282,6 +283,122 @@ pub fn run(
282283
Ok(())
283284
}
284285

286+
fn prepare_post_tool_use_hook_path() -> Result<PathBuf> {
287+
let claude_dir = resolve_claude_dir()?;
288+
let hook_dir = claude_dir.join("hooks");
289+
fs::create_dir_all(&hook_dir)
290+
.with_context(|| format!("Failed to create hook directory: {}", hook_dir.display()))?;
291+
Ok(hook_dir.join("rtk-post-tool-use.sh"))
292+
}
293+
294+
#[cfg(unix)]
295+
fn ensure_post_tool_use_hook_installed(hook_path: &Path, verbose: u8) -> Result<bool> {
296+
let changed = if hook_path.exists() {
297+
let existing = fs::read_to_string(hook_path)
298+
.with_context(|| format!("Failed to read existing hook: {}", hook_path.display()))?;
299+
if existing == POST_TOOL_USE_HOOK {
300+
if verbose > 0 {
301+
eprintln!("PostToolUse hook already up to date: {}", hook_path.display());
302+
}
303+
false
304+
} else {
305+
fs::write(hook_path, POST_TOOL_USE_HOOK)
306+
.with_context(|| format!("Failed to write hook to {}", hook_path.display()))?;
307+
true
308+
}
309+
} else {
310+
fs::write(hook_path, POST_TOOL_USE_HOOK)
311+
.with_context(|| format!("Failed to write hook to {}", hook_path.display()))?;
312+
true
313+
};
314+
315+
use std::os::unix::fs::PermissionsExt;
316+
fs::set_permissions(hook_path, fs::Permissions::from_mode(0o755))
317+
.with_context(|| format!("Failed to set hook permissions: {}", hook_path.display()))?;
318+
319+
Ok(changed)
320+
}
321+
322+
fn insert_post_tool_use_hook_entry(root: &mut serde_json::Value, hook_command: &str) {
323+
let root_obj = match root.as_object_mut() {
324+
Some(obj) => obj,
325+
None => {
326+
*root = serde_json::json!({});
327+
root.as_object_mut().expect("Just created object")
328+
}
329+
};
330+
331+
let hooks = root_obj
332+
.entry("hooks")
333+
.or_insert_with(|| serde_json::json!({}))
334+
.as_object_mut()
335+
.expect("hooks must be an object");
336+
337+
let post_tool_use = hooks
338+
.entry("PostToolUse")
339+
.or_insert_with(|| serde_json::json!([]))
340+
.as_array_mut()
341+
.expect("PostToolUse must be an array");
342+
343+
post_tool_use.push(serde_json::json!({
344+
"matcher": "mcp__.*",
345+
"hooks": [{
346+
"type": "command",
347+
"command": hook_command
348+
}]
349+
}));
350+
}
351+
352+
fn post_tool_use_hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool {
353+
let arr = match root
354+
.get("hooks")
355+
.and_then(|h| h.get("PostToolUse"))
356+
.and_then(|p| p.as_array())
357+
{
358+
Some(a) => a,
359+
None => return false,
360+
};
361+
362+
arr.iter()
363+
.filter_map(|entry| entry.get("hooks")?.as_array())
364+
.flatten()
365+
.filter_map(|hook| hook.get("command")?.as_str())
366+
.any(|cmd| {
367+
cmd == hook_command
368+
|| (cmd.contains("rtk-post-tool-use.sh")
369+
&& hook_command.contains("rtk-post-tool-use.sh"))
370+
})
371+
}
372+
373+
fn remove_post_tool_use_hook_entry(root: &mut serde_json::Value, hook_command: &str) {
374+
let hooks = match root.get_mut("hooks").and_then(|h| h.get_mut("PostToolUse")) {
375+
Some(h) => h,
376+
None => return,
377+
};
378+
379+
let arr = match hooks.as_array_mut() {
380+
Some(a) => a,
381+
None => return,
382+
};
383+
384+
arr.retain(|entry| {
385+
let hooks_arr = match entry.get("hooks").and_then(|h| h.as_array()) {
386+
Some(a) => a,
387+
None => return true,
388+
};
389+
!hooks_arr.iter().any(|hook| {
390+
hook.get("command")
391+
.and_then(|c| c.as_str())
392+
.map(|cmd| {
393+
cmd == hook_command
394+
|| (cmd.contains("rtk-post-tool-use.sh")
395+
&& hook_command.contains("rtk-post-tool-use.sh"))
396+
})
397+
.unwrap_or(false)
398+
})
399+
});
400+
}
401+
285402
/// Prepare hook directory and return paths (hook_dir, hook_path)
286403
fn prepare_hook_paths() -> Result<(PathBuf, PathBuf)> {
287404
let claude_dir = resolve_claude_dir()?;
@@ -515,6 +632,62 @@ fn remove_hook_from_settings(verbose: u8) -> Result<bool> {
515632
Ok(removed)
516633
}
517634

635+
fn remove_post_tool_use_hook_from_settings(verbose: u8) -> Result<bool> {
636+
let claude_dir = resolve_claude_dir()?;
637+
let settings_path = claude_dir.join("settings.json");
638+
639+
if !settings_path.exists() {
640+
return Ok(false);
641+
}
642+
643+
let content = fs::read_to_string(&settings_path)
644+
.with_context(|| format!("Failed to read {}", settings_path.display()))?;
645+
646+
if content.trim().is_empty() {
647+
return Ok(false);
648+
}
649+
650+
let mut root: serde_json::Value = serde_json::from_str(&content)
651+
.with_context(|| format!("Failed to parse {} as JSON", settings_path.display()))?;
652+
653+
let hook_command = resolve_claude_dir()?
654+
.join("hooks")
655+
.join("rtk-post-tool-use.sh");
656+
let hook_command = hook_command.to_string_lossy().to_string();
657+
658+
let had_entry = post_tool_use_hook_already_present(&root, &hook_command);
659+
if !had_entry {
660+
return Ok(false);
661+
}
662+
663+
remove_post_tool_use_hook_entry(&mut root, &hook_command);
664+
665+
// Verify removal
666+
let still_present = root
667+
.get("hooks")
668+
.and_then(|h| h.get("PostToolUse"))
669+
.and_then(|p| p.as_array())
670+
.map(|a| !a.is_empty())
671+
.unwrap_or(false);
672+
673+
let backup_path = settings_path.with_extension("json.bak");
674+
fs::copy(&settings_path, &backup_path)
675+
.with_context(|| format!("Failed to backup to {}", backup_path.display()))?;
676+
677+
let serialized =
678+
serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?;
679+
atomic_write(&settings_path, &serialized)?;
680+
681+
if verbose > 0 {
682+
eprintln!("Removed PostToolUse hook from settings.json");
683+
if still_present {
684+
eprintln!(" Note: other PostToolUse hooks remain");
685+
}
686+
}
687+
688+
Ok(true)
689+
}
690+
518691
/// Full uninstall for Claude, Gemini, Codex, or Cursor artifacts.
519692
pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: u8) -> Result<()> {
520693
if codex {
@@ -617,6 +790,19 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose:
617790
removed.push(format!("OpenCode plugin: {}", path.display()));
618791
}
619792

793+
// Remove PostToolUse hook file
794+
let post_hook_path = claude_dir.join("hooks").join("rtk-post-tool-use.sh");
795+
if post_hook_path.exists() {
796+
fs::remove_file(&post_hook_path)
797+
.with_context(|| format!("Failed to remove hook: {}", post_hook_path.display()))?;
798+
removed.push(format!("PostHook: {}", post_hook_path.display()));
799+
}
800+
801+
// Remove PostToolUse entry from settings.json
802+
if remove_post_tool_use_hook_from_settings(verbose)? {
803+
removed.push("settings.json: removed PostToolUse hook entry".to_string());
804+
}
805+
620806
// 6. Remove Cursor hooks
621807
let cursor_removed = remove_cursor_hooks(verbose)?;
622808
removed.extend(cursor_removed);
@@ -678,6 +864,54 @@ fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result<Vec<String>> {
678864
Ok(removed)
679865
}
680866

867+
fn patch_settings_json_post_tool_use(
868+
hook_path: &Path,
869+
_mode: PatchMode,
870+
verbose: u8,
871+
) -> Result<PatchResult> {
872+
let claude_dir = resolve_claude_dir()?;
873+
let settings_path = claude_dir.join("settings.json");
874+
let hook_command = hook_path
875+
.to_str()
876+
.context("Hook path contains invalid UTF-8")?;
877+
878+
let mut root = if settings_path.exists() {
879+
let content = fs::read_to_string(&settings_path)
880+
.with_context(|| format!("Failed to read {}", settings_path.display()))?;
881+
if content.trim().is_empty() {
882+
serde_json::json!({})
883+
} else {
884+
serde_json::from_str(&content)
885+
.with_context(|| format!("Failed to parse {}", settings_path.display()))?
886+
}
887+
} else {
888+
serde_json::json!({})
889+
};
890+
891+
if post_tool_use_hook_already_present(&root, hook_command) {
892+
if verbose > 0 {
893+
eprintln!("settings.json: PostToolUse hook already present");
894+
}
895+
return Ok(PatchResult::AlreadyPresent);
896+
}
897+
898+
insert_post_tool_use_hook_entry(&mut root, hook_command);
899+
900+
if settings_path.exists() {
901+
let backup_path = settings_path.with_extension("json.bak");
902+
fs::copy(&settings_path, &backup_path)
903+
.with_context(|| format!("Failed to backup to {}", backup_path.display()))?;
904+
}
905+
906+
let serialized =
907+
serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?;
908+
atomic_write(&settings_path, &serialized)?;
909+
910+
println!(" settings.json: PostToolUse hook added");
911+
912+
Ok(PatchResult::Patched)
913+
}
914+
681915
/// Orchestrator: patch settings.json with RTK hook
682916
/// Handles reading, checking, prompting, merging, backing up, and atomic writing
683917
fn patch_settings_json(
@@ -891,6 +1125,9 @@ fn run_default_mode(
8911125
// 1. Prepare hook directory and install hook
8921126
let (_hook_dir, hook_path) = prepare_hook_paths()?;
8931127
let hook_changed = ensure_hook_installed(&hook_path, verbose)?;
1128+
let post_hook_path = prepare_post_tool_use_hook_path()?;
1129+
let _post_hook_changed = ensure_post_tool_use_hook_installed(&post_hook_path, verbose)?;
1130+
patch_settings_json_post_tool_use(&post_hook_path, patch_mode, verbose)?;
8941131

8951132
// 2. Write RTK.md
8961133
write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?;
@@ -1030,6 +1267,9 @@ fn run_hook_only_mode(
10301267
// Prepare and install hook
10311268
let (_hook_dir, hook_path) = prepare_hook_paths()?;
10321269
let hook_changed = ensure_hook_installed(&hook_path, verbose)?;
1270+
let post_hook_path = prepare_post_tool_use_hook_path()?;
1271+
let _post_hook_changed = ensure_post_tool_use_hook_installed(&post_hook_path, verbose)?;
1272+
patch_settings_json_post_tool_use(&post_hook_path, patch_mode, verbose)?;
10331273

10341274
let opencode_plugin_path = if install_opencode {
10351275
let path = prepare_opencode_plugin_path()?;

src/main.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ mod lint_cmd;
3535
mod local_llm;
3636
mod log_cmd;
3737
mod ls;
38+
mod mcp_filter;
3839
mod mypy_cmd;
3940
mod next_cmd;
4041
mod npm_cmd;
@@ -676,6 +677,9 @@ enum Commands {
676677
since: u64,
677678
},
678679

680+
/// Filter MCP tool output from a PostToolUse hook (reads JSON from stdin)
681+
FilterMcpOutput,
682+
679683
/// Rewrite a raw command to its RTK equivalent (single source of truth for hooks)
680684
///
681685
/// Exits 0 and prints the rewritten command if supported.
@@ -2046,6 +2050,10 @@ fn main() -> Result<()> {
20462050
}
20472051
},
20482052

2053+
Commands::FilterMcpOutput => {
2054+
mcp_filter::run()?;
2055+
}
2056+
20492057
Commands::Rewrite { args } => {
20502058
let cmd = args.join(" ");
20512059
rewrite_cmd::run(&cmd)?;

0 commit comments

Comments
 (0)