Skip to content

feat: add a before-tool hook and a private directory per tool call - #32

Merged
Martin Bens (SpiGAndromeda) merged 1 commit into
mainfrom
feat/before-tool-hook-and-call-tmpdir
Oct 1, 2026
Merged

Martin Bens (SpiGAndromeda) merged 1 commit into
mainfrom
feat/before-tool-hook-and-call-tmpdir

Conversation

@SpiGAndromeda

@SpiGAndromeda Martin Bens (SpiGAndromeda) commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #28. Closes #27.

Problem

A server whose tools share a per-call step, such as an authorization check, a rebinding of where the tool works, or an audit line, had to repeat that step in every tool_* function. Nothing noticed a tool that skipped it (#28).

A tool that allocated a file for one call had no place for it that the SDK would clean up. The README did not say whether the tool's own trap … EXIT was supported (#27).

Behavior

Before-tool hook

A server may define mcp_before_tool_call. Every dispatched tools/call runs it after the declaration check and argument validation, with the tool name and the validated arguments JSON. It runs in the tool's wrapper subshell, so the hook and the tool share one shell.

  • Variables, the working directory, functions, traps and environment the hook sets reach the tool. Shell options it sets with set or shopt are restored before the tool runs.
  • Return 0: the hook's output is discarded and the tool runs.
  • Return non-zero: the tool does not run, and the call returns isError carrying the hook's output. This is logged as a refusal, not as a tool failure.
  • exit instead of return, or killed by a signal: the tool does not run, and the isError result names which of the two happened.
  • The hook is resolved with declare -F only. The SDK's own steps after the hook use only builtin-prefixed builtins and plain redirections, so a function the hook defines or a PATH it changes does not affect them. A hook must not define a function named builtin.
  • A nested handle_tools_call inside a tool runs the hook again.

Per-call directory

Each call gets one root from mktemp -d (mode 700). It holds the call's output, sentinel, hook output and markers, and a tmp/ directory exported as MCP_CALL_TMPDIR to the hook, the tool and the call's tool_<name>_cancel hook.

  • The server removes the root once the call's process group has ended: on return, failure, cancellation (including the SIGKILL escalation) and shutdown. Directories the tool left without write or search permission are fixed first, without following symlinks.
  • Removal refuses any path that is not an absolute mcp-call.* directory. Every path read back from the in-flight record is checked before use.
  • A root that cannot be created answers isError, and the tool does not run. Previously the output file's mktemp was unchecked, so a failure ended a server running under set -o posix without a response.
  • A server killed with SIGKILL leaves the root behind under TMPDIR. a call's root directory leaks when the dispatch subshell dies before the in-flight record is written #31 covers the window between mktemp and the in-flight record write.

EXIT trap contract

A tool may install trap … EXIT. It runs when the tool returns and on the SIGTERM step of a cancellation or shutdown, and not after SIGKILL. The hook and the tool share one EXIT trap.

A server whose tools share a per-call step, such as an authorization check or an audit line, had to repeat it in every `tool_*` function, and a tool added later could skip it (#28). A tool that allocated a file for one call had no place for it that the SDK would clean up, and no stated contract for its own `trap … EXIT` (#27).

`mcp_before_tool_call`, when the server defines it, runs before every dispatched tool with the tool name and the validated arguments, in the tool's own shell, so variables, the working directory, functions and traps it sets reach the tool. `_mcp_run_tool` saves and restores `set` and `shopt` options around it, and runs its own steps after the hook through `builtin` and bare redirections only, so a hook-defined function or a changed `PATH` cannot break them. A non-zero return refuses the call with an `isError` result carrying the hook's output. The markers `hook-running`, `hook-returned` and `hook-refused` let `handle_tools_call` tell a refusal, an `exit` and a signal apart, and each is logged as such.

Each call now creates one root with `mktemp -d` (mode 700) holding `output`, `sentinel`, `hook-output`, the markers, and `tmp/`, which is exported as `MCP_CALL_TMPDIR` to the hook, the tool and the cancel hook. The in-flight record keeps its format. `_mcp_call_root_of` checks a path before any root is derived from it, and `_mcp_remove_call_root` removes only `mcp-call.*` directories after the call's group has ended, restoring write and search permission on directories the tool locked. A root that cannot be created answers `isError`, which also stops a failed `mktemp` from ending a server under `set -o posix`. In-process test captures that spawn a tool keep stderr out of the parsed JSON, because bash can print a `child setpgid` line there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@SpiGAndromeda
Martin Bens (SpiGAndromeda) merged commit 29d8453 into main Oct 1, 2026
3 checks passed
@SpiGAndromeda
Martin Bens (SpiGAndromeda) deleted the feat/before-tool-hook-and-call-tmpdir branch October 1, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant