Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/extensions/software-writer/writing-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
- `code.export_conventions` = A leading `_` marks an internal function (`_configure_extra_log_file`); every unprefixed function, its argument order, what it writes to stdout, and the consumer-set variables are public API governed by `AGENTS.md` §Compatibility contract. Classify every surface change against that contract before writing it: rename, argument-order, or stdout change is a major bump; a new function, handled method, or enforced schema keyword is a minor bump; tightening the validator is a major bump even though it fixes a hole. A consumer exports a tool by doing both: defining `tool_<name>` and adding a `tools.json` entry with an `inputSchema` — an undeclared function is not dispatched, and an entry without a schema answers an `isError` result.
- `code.footgun_additions` =
- **Stdout is the JSON-RPC stream.** Anything written to stdout outside `create_response` / `create_error_response` corrupts the protocol. `validate_tool_arguments` is the one deliberate exception: it prints a human-readable diagnostic and returns 1 for its caller to capture. `handle_tools_call` does not call it; it builds its `isError` result from the internal `_mcp_tool_schema` and `_mcp_validate_against_schema`, whose stdout every call site captures in a command substitution, except the `_mcp_validate_against_schema` call inside `validate_tool_arguments`, which passes it through as that function's own output.
- A tool function is dispatched in a background wrapper subshell that redirects to the call's output file: `( set +e; _reset_tool_dispatch_state; "$func_name" "$arguments" ) >"$output_file" 2>&1 </dev/null &`. Three consequences: errexit is disabled inside every tool function, so each step's status must be checked explicitly; stderr is merged into the result the client sees, not discarded; and the function's stdin is `/dev/null`, so a read returns EOF rather than consuming the client's JSON-RPC pipe.
- A tool function is dispatched in a background wrapper subshell that redirects to the call's output file: `( set +e; _reset_tool_dispatch_state; export MCP_CALL_TMPDIR="$call_tmpdir"; _mcp_run_tool "$func_name" "$tool_name" "$arguments" "$call_root" ) >"$output_file" 2>&1 </dev/null &`. The server-loop dispatch site opens the call's lifeline, starts the sentinel beside it, and writes the sentinel pid ahead of `_reset_tool_dispatch_state`; the rest is identical. Consequences: errexit is disabled inside the tool function and inside the `mcp_before_tool_call` hook, so each step's status must be checked explicitly; stderr is merged into the result the client sees, not discarded, for the tool function — a hook's output, stderr included, reaches the result only when the hook refuses or ends the call instead of returning; the function's stdin is `/dev/null`, so a read returns EOF rather than consuming the client's JSON-RPC pipe; `_mcp_run_tool` runs the hook and the tool in this one shell, so a variable the hook sets, a directory it changes to, and an EXIT trap it installs reach the tool, while the shell options it sets are restored before the tool runs; and `MCP_CALL_TMPDIR` holds the call's own scratch directory, removed with the call's other files.
- jq's `//` treats a present `null` and a present `false` as absent. `handle_tools_call` derives arguments with `has("arguments")` for exactly this reason; keep that shape wherever the null/false distinction matters.
- A jq `as` binding over zero outputs skips its entire body. The validator's `items` checks read `.type`/`.enum` by plain field access instead of `// empty` because of it — `// empty` on an absent key would silently discard every element, including violations of the constraint that was declared.
- `lib/mcpserver_core.sh` sources nothing, and `tests/core_standalone.bats` fails when a dependency is introduced. A change that needs a helper from outside the file does not belong in this repository.
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Source of truth for one file, `lib/mcpserver_core.sh`, the Bash MCP server frame
| `tests/exit_trap_chaining.bats` | Pins that the teardown runs the EXIT handler a caller installed before `run_mcp_server`: on clean exit and on `SIGTERM`, a failing handler leaves the exit status alone, a `;`-joined handler runs whole, and the handler's stdout stays off the protocol stream |
| `tests/mcp_argument_validation.bats` | Pins the validator, including its diagnostic precedence |
| `tests/tool_declaration.bats` | Pins that the tools list decides which tools a `tools/call` reaches: an undeclared `tool_*` function or cancel hook answers `-32601`, an entry with no or a `null` `inputSchema` and a name declared twice answer `isError`, an unreadable list answers `isError` rather than `-32601`, and an executable on `PATH` runs neither as a tool nor as a cancel hook |
| `tests/before_tool_hook.bats` | Pins `mcp_before_tool_call`: run before every dispatched tool in the tool's own shell with errexit off and stdin `/dev/null`, its output discarded when it returns 0 and what it sets — variables, working directory, functions, traps, environment — passed on to the tool while its shell options are put back, a function or `PATH` change reaching the tool but not the SDK's own steps, an `isError` result carrying its output and no tool run when it fails, not run for an undeclared tool, rejected arguments or a `PATH` executable, run again for a nested dispatch, and stopped by a cancellation |
| `tests/call_tmpdir.bats` | Pins `MCP_CALL_TMPDIR`: one empty, owner-only directory per call, shared with the hook and the tool's children, its own for a nested dispatch, removed on return, on failure, on a cancellation that SIGKILLs the group and on shutdown, readable by the cancel hook, and an uncreatable directory answering `isError` without running the tool; also a tool's own EXIT trap, which runs on return and on the SIGTERM step but not after SIGKILL |
| `tests/tools_call_params.bats` | Pins that a `tools/call` whose `params` is a number, a string or an array answers `-32602 Invalid params` without ending the server under `set -o posix` and without a jq diagnostic on stderr |
| `tests/error_response.bats` | Pins the error envelope builder, including its optional `data` argument |
| `tests/read_json_file.bats` | Pins `read_json_file`: one JSON object per file, and the `-32603` each handler answers with when its configuration file is missing, empty, multi-document, unparseable, or holds a document that is not a JSON object |
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project are documented here. The format follows [Kee

## [Unreleased]

### Added

- A server may define an optional `mcp_before_tool_call` function. Every dispatched `tools/call` runs it before the tool function, with the tool name and the validated `arguments` JSON, in the same shell as the tool, so a global variable it assigns and a directory it changes to reach the tool. Shell options it sets with `set` or `shopt` are restored before the tool runs. A hook that returns 0 has its output discarded. A hook that returns non-zero stops the call: the tool does not run, and the call returns an `isError` result carrying the hook's output. A hook that ends the call's shell with `exit` instead of returning stops the call too, whatever status it exits with: the tool does not run, and the `isError` result names the hook and carries what it printed. A hook killed by a signal stops the call with an `isError` result naming the signal. Only a shell function counts, never an executable on `PATH`. A nested `handle_tools_call` or `process_request` made from inside a tool runs the hook again. A server that already defines a function named `mcp_before_tool_call` now has it called before every tool.
- Each dispatched call gets a directory of its own, exported as `MCP_CALL_TMPDIR` to the before-tool hook, the tool and its child processes, and to the call's `tool_<name>_cancel` hook. The server removes it with its contents when the call returns, fails, is cancelled, or is in flight at a shutdown, including after the `SIGKILL` a tool that ignores `SIGTERM` gets. A server killed with `SIGKILL` leaves it behind under `TMPDIR` inside the call's `mcp-call.*` directory. A call whose directory cannot be created returns an `isError` result and does not run.
- `README.md` §Writing a server states that a tool's own `trap … EXIT` is supported. It runs when the tool returns and when the `SIGTERM` step of a cancellation or shutdown ends the tool, and not after a `SIGKILL`.

### Changed

- `tools/call` dispatches only tools the tools list declares. A `tool_<name>` function the list does not declare now answers `-32601 Tool not found: <name>`. It was previously dispatched with its arguments unchecked, because the validator skipped a tool it found no entry for. A nested `handle_tools_call` or `process_request` made from inside a tool follows the same rule.
Expand All @@ -16,6 +22,7 @@ All notable changes to this project are documented here. The format follows [Kee
### Fixed

- An executable on `PATH` named `tool_<name>` is no longer dispatched as a tool, and one named `tool_<name>_cancel` is no longer run as a cancel hook. Both lookups used `type`, which also matches executables. They now match shell functions only.
- A `tools/call` whose temporary files could not be created, for example because `TMPDIR` became unwritable after startup, ended a server running under `set -o posix` without a response. The status of the `mktemp` call was not checked. The call now returns an `isError` result and the server keeps running.

## [5.1.0] - 2026-09-15

Expand Down
Loading
Loading