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
4 changes: 2 additions & 2 deletions .claude/extensions/software-writer/writing-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
| Check a call's arguments against a schema | ad-hoc `jq` at the call site | `validate_tool_arguments` (`lib/mcpserver_core.sh`) | Enforces the whole `inputSchema` with diagnostics in precedence order missing > unknown > type > pattern > range > items > enum, and treats a jq failure as a rejection, never a skip. |
| Drive one request in a test | spawning the stdio loop | `process_request` (`lib/mcpserver_core.sh`) | Parses and routes a single JSON-RPC line, so a suite exercises a server without `run_mcp_server`'s read loop. |
- `code.di_pattern` = A consumer's server script is the composition root: it sets and exports the module inputs (`MCP_CONFIG_FILE`, `MCP_TOOLS_LIST_FILE`, `MCP_LOG_FILE`, `MCP_EXTRA_LOG_FILE`, `PROJECT_ROOT`), defines its `tool_<name>` functions, sources `mcpserver_core.sh`, and calls `run_mcp_server`. The module reads its inputs from those exported variables and discovers nothing itself. Inside this repository there is no composition root — the BATS suites build throwaway server scripts in `BATS_TEST_TMPDIR` to play that role.
- `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 entry without a schema is dispatched unvalidated.
- `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, which `handle_tools_call` captures into an `isError` result.
- **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.
- 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.
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Source of truth for one file, `lib/mcpserver_core.sh`, the Bash MCP server frame
| `tests/exit_trap_isolation.bats` | Pins that `run_mcp_server`'s EXIT trap stays with the shell that runs it: a caller that isolates the call in a subshell keeps its own EXIT trap, and that subshell is where the post-loop reset of `_MCP_IN_SERVER_LOOP` is observable |
| `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/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 Expand Up @@ -57,7 +58,7 @@ Tightening the validator is a **major** bump even though it fixes a hole: argume

## Stdout discipline

Stdout carries the JSON-RPC stream. `run_mcp_server` captures each dispatch's stdout and echoes it, so only response construction writes there: `create_response`, `create_error_response`, and the deferred responses `handle_tools_call` replays for requests that arrived mid-call. Diagnostics go to `log`. `read_json_file` prints the parsed document, and every call site captures it in a command substitution, so that output never reaches the protocol stream. `_mcp_install_hint` prints its remediation lines to stdout so the function stays pure and directly testable; every call site redirects them to stderr with `>&2`, which keeps them off the protocol stream, and a new call site has to add that redirect. `validate_tool_arguments` is the one deliberate exception — it prints a human-readable message and returns 1, which `handle_tools_call` turns into an `isError` result. `run_mcp_server` also takes over the process's EXIT trap and expects to be that process's last call. The handler it displaces is not lost: `_server_teardown` runs it after the SDK's own cleanup, with its stdout redirected to stderr so it stays off the protocol stream. The consumer contract is stated in `README.md` §Cancelling and shutting down.
Stdout carries the JSON-RPC stream. `run_mcp_server` captures each dispatch's stdout and echoes it, so only response construction writes there: `create_response`, `create_error_response`, and the deferred responses `handle_tools_call` replays for requests that arrived mid-call. Diagnostics go to `log`. `read_json_file` prints the parsed document, and every call site captures it in a command substitution, so that output never reaches the protocol stream. `_mcp_tool_schema` and `_mcp_validate_against_schema` print a schema or a diagnostic to stdout the same way, and every call site captures that in a command substitution except the one inside `validate_tool_arguments`. `_mcp_install_hint` prints its remediation lines to stdout so the function stays pure and directly testable; every call site redirects them to stderr with `>&2`, which keeps them off the protocol stream, and a new call site has to add that redirect. `validate_tool_arguments` is the one deliberate exception: it prints a human-readable message and returns 1, and `handle_tools_call` turns a message of that shape, built by the `_mcp_validate_against_schema` call it makes itself, into an `isError` result. `run_mcp_server` also takes over the process's EXIT trap and expects to be that process's last call. The handler it displaces is not lost: `_server_teardown` runs it after the SDK's own cleanup, with its stdout redirected to stderr so it stays off the protocol stream. The consumer contract is stated in `README.md` §Cancelling and shutting down.

## Pre-flight guards

Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to this project are documented here. The format follows [Kee

## [Unreleased]

### 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.
- A `tool_<name>_cancel` hook is no longer callable as tool `<name>_cancel` unless the tools list declares `<name>_cancel`. It previously answered as a tool whenever the hook function existed.
- A declared entry with no `inputSchema`, or a `null` one, returns an `isError` result instead of dispatching the tool unvalidated. The message is `Cannot validate arguments for <tool>: its entry in <file> declares no inputSchema.`
- A name the tools list declares more than once returns an `isError` result with the message `Cannot validate arguments for <tool>: the tool list at <file> declares it more than once.` It previously returned `isError` with `they could not be evaluated against its schema.`
- The tools list is consulted before the `tool_<name>` function. A call for a name with no function now returns the list's `isError` result when the list cannot be read, declares the name twice, or gives it no `inputSchema`. It previously answered `-32601`, which hid the broken list.
- `validate_tool_arguments` rejects a tool the tools list does not declare. It prints `Cannot validate arguments for <tool>: the tool list at <file> does not declare it.` and returns 1. It previously returned 0 with no output.

### 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.

## [5.1.0] - 2026-09-15

### Changed
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ There is no install step. Copy `lib/mcpserver_core.sh` into your project and `so
| `handle_initialize` | The `initialize` handler `process_request` routes to. Answers `protocolVersion`, `serverInfo` and `capabilities`. |
| `handle_tools_list` | The `tools/list` handler `process_request` routes to. Answers the `tools` array. |
| `handle_tools_call` | The `tools/call` handler `process_request` routes to. A consumer can drive it directly to dispatch one call without the read loop. |
| `validate_tool_arguments` | Check a call's arguments against the tool's `inputSchema`. |
| `validate_tool_arguments` | Check a call's arguments against the tool's `inputSchema`. Rejects a tool that the tools list does not declare exactly once with a non-null `inputSchema`. |
| `create_response` | Build a JSON-RPC result envelope. |
| `create_error_response` | Build a JSON-RPC error envelope. Optional 4th arg `data` (JSON value) is included when non-empty. |
| `log` | Append to `MCP_LOG_FILE`, and to `MCP_EXTRA_LOG_FILE` when set. |
Expand Down Expand Up @@ -94,17 +94,23 @@ tool_greet() {
run_mcp_server
```

`tools.json` decides which tools exist. A `tools/call` runs `tool_<name>` only when the list declares `<name>` and a shell function of that name is defined. A name the list does not declare answers `-32601`, even when a `tool_<name>` function is sourced. A declared name with no such function answers `-32601` too. An executable, alias or builtin named `tool_<name>` is never dispatched.

The same rule holds for a nested `handle_tools_call` or `process_request` made from inside a tool. A plain shell call such as `tool_greet "$args"` is not a dispatch, so it runs whether or not the list declares the tool.

A tool may also define an optional `tool_<name>_cancel` hook, which the server calls when the call is cancelled. *Cancelling and shutting down* below gives its contract.

The hook is resolved by name. A tool whose own name ends in `_cancel` is therefore also the cancellation hook of whatever precedes that suffix. A tool named `foo_cancel` is dispatched as a tool, and it is called when `foo` is cancelled. Do not name a tool `<other>_cancel` unless that is what you mean.
The hook is resolved by name, and only a shell function counts. A hook is not a tool: `tool_foo_cancel` is callable as tool `foo_cancel` only when `tools.json` declares `foo_cancel`. A declared tool named `foo_cancel` is still the cancellation hook of `foo`, and it is called when `foo` is cancelled. Do not declare a tool `<other>_cancel` unless that is what you mean.

Every `inputSchema` in `tools.json` is enforced before the tool function runs. The keywords are `required`, `additionalProperties: false`, `type`, `pattern`, `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum`, array `items.type` / `items.enum`, and `enum`.

A `type` — on a property or on `items` — may be one name or a list of alternatives (e.g. `"type": ["integer", "string"]`). A value satisfies it by matching any member.

A range bound applies only to a number-valued argument. A string, boolean, or other non-number carries no bound. A bound that is not itself a number is left unenforced, which also covers the JSON Schema draft-04 boolean form `"exclusiveMinimum": true`.

Diagnostics report the most fundamental defect first, in that order. A tool with no `inputSchema` is dispatched unvalidated.
Diagnostics report the most fundamental defect first, in that order.

A declared tool whose entry has no `inputSchema`, or a `null` one, is not dispatched. The call returns an `isError` result instead. A name `tools.json` declares more than once also returns an `isError` result.

A tool that exits non-zero returns its combined output as an `isError` result rather than killing the server.

Expand Down
Loading
Loading