Skip to content

github-mcp 4.2.0: vendor bash-mcp-sdk v5.0.0, and run only the tools a server declares - #8

Merged
Martin Bens (SpiGAndromeda) merged 7 commits into
mainfrom
chore/bash-mcp-sdk-v5
Sep 13, 2026
Merged

Martin Bens (SpiGAndromeda) merged 7 commits into
mainfrom
chore/bash-mcp-sdk-v5

Conversation

@SpiGAndromeda

@SpiGAndromeda Martin Bens (SpiGAndromeda) commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Vendors shared/mcpserver_core.sh from shopwareLabs/bash-mcp-sdk v5.0.0, up from v3.0.0, and fixes what bumping it surfaced. Releases the plugin as 4.2.0.

A server ran tools it never declared

The largest change here, found while checking the bump against the open issues.

A tools/call resolves to a shell function by name, so a server could run any tool_* function it had sourced, declared or not. api.sh, label.sh and project.sh are shared by both servers and each carries tools the other does not declare. That put label_add, label_remove, project_item_add, project_status_set and api — which takes any HTTP method — on the read server, which is always active and needs no configuration.

enable_write_server gated what tools/list advertised and nothing else. All five ran against the authenticated gh CLI whether or not the write server was enabled.

An undeclared tool also has no schema, and argument validation treats a missing schema as nothing to check, so each of them ran with unvalidated arguments. That validation is what #3 identifies as the only thing keeping tool arguments away from the unguarded numeric comparisons in _gh_post_process; without it, a crafted max_lines reaches bash arithmetic evaluation and executes a command inside the server process, which holds the user's gh credentials.

_gh_unset_undeclared_tools removes every sourced tool function the running server's tools list does not name. It derives the permitted set from that list rather than a hardcoded roster, so a tool added to a shared lib cannot reopen the gap. An empty tools list leaves nothing callable, which is what now makes a disabled write server inert rather than merely quiet about what it can do.

This closes the reachable half of #3. The issue stays open for its own fix: the guarantee still rests on 45 schema declarations that nothing downstream re-checks, and a future edit dropping a type reopens it silently.

Dependency floors are now enforced

v5.0.0 checks bash 4.1+ and jq 1.7+ when the file is sourced, and refuses to start below either. Both were already the protocol layer's documented requirements and nothing checked them: below the bash floor a server died with {_MCP_LIFELINE_FD}: not found before writing a byte of JSON-RPC, which a host reports as nothing more than a failure to start; below the jq floor it ran while silently skipping part of the argument validation its own schemas declare.

This stops servers that start today. macOS keeps bash 3.2 at /bin/bash and no install replaces it, so a Mac needs a newer bash ahead of /usr/bin on the PATH the MCP host launches the server with — and a host started from the desktop reads no shell profile, so that is not necessarily the PATH your terminal has. A refusal names the requirement, the version found, and the install command for the platform.

README listed bash 4.0+, which was never the requirement, and gave no jq version at all. Both files now carry the floors, and SETUP.md's byte-identical copy under plugin-setup is re-synced.

Also fixed

Downloads are written atomically. repo_file and search_code with download_to redirected gh straight into the destination, so a call ending partway left a truncated file indistinguishable from a complete one. Cancellation makes that reachable — from v4.0.0 a cancelled call has its tool process group signalled, where the command previously ran to completion regardless. Both sites write to a .partial sibling and rename once gh exits cleanly. Two older defects go with it: _gh_download_file redirected stderr into the file as well, so a failed download wrote the API's error text to disk as the file's contents, and repo_file deleted whatever already sat at the destination when a download failed.

The disabled write server no longer writes its own tools list. The mktemp template placed its placeholder mid-name, so mktemp took it literally and every server on the machine shared one fixed path; the cleanup ran from an EXIT trap that run_mcp_server replaces, so it never ran at all. Harmless under v3.0.0, where a removed tools list read as an empty one — but from v4.0.0 an unreadable tools list answers -32603, so anything removing the file turned a disabled write server into one that failed every initialize. A shipped mcp-server-gh/tools-empty.json replaces it.

What the bump brings on its own

  • A malformed request no longer ends a server. A line carrying more than one JSON document, a document that is not an object, a tools/call whose params is not an object, and a request whose id is neither a string nor an integer were each fatal with no response, leaving every later request unanswered. Each is now answered.
  • Cancelling a tool call stops the work. notifications/cancelled was ignored, so a cancelled run_logs or search_code ran its gh command to completion. No plugin change was needed for this.
  • A configuration or tools file that cannot be read is refused rather than reported as an empty one.

Up from v3.0.0. The vendored copy is byte-identical to lib/mcpserver_core.sh
at that tag and .mcp-sdk.lock records the release and its hash.

What the two intervening majors change for this plugin:

v4.0.0 makes the server survive malformed input. A line carrying more than
one JSON document, a document that is not an object, a tools/call whose
params is not an object, and a request whose id is neither a string nor an
integer each used to stop the server with no response, leaving every later
request unanswered; each is now answered. It also handles
notifications/cancelled, so a cancelled call's process group is signalled
instead of running to completion, and it refuses to serve a configuration or
tools file it cannot read rather than reporting an empty one.

v5.0.0 enforces the bash 4.1+ and jq 1.7+ floors when the file is sourced.
Both were already documented requirements that nothing checked: below them a
server either died with {_MCP_LIFELINE_FD}: not found before speaking, or ran
while silently skipping part of the argument validation its schemas declare.

Co-Authored-By: Claude <noreply@anthropic.com>
The write server wrote its own empty tools list at startup when
enable_write_server was not true, and removed it from an EXIT trap. Two
things were wrong with that.

The mktemp template was "tools-empty.XXXXXX.json". mktemp only substitutes a
run of X characters at the end of the template, so the placeholder was taken
literally and every server on the machine created, and later removed, the
same fixed path.

The cleanup never ran either. run_mcp_server installs its own EXIT trap and
replaces any handler the consumer set, so the file outlived every server that
created it. Under the vendored v3.0.0 that combination was survivable: a
tools list that had been removed read as an empty one, which is what the
disabled server wanted anyway. From v4.0.0 a tools list that cannot be read
is answered -32603, so anything removing the file — another instance, or
git clean -X, since the path was ignored — turned a disabled write server
into one that failed every initialize and tools/list.

A shipped mcp-server-gh/tools-empty.json removes the startup write, the
shared path, and the dependence on a trap the protocol layer owns. The
.gitignore entry that existed for the artifact goes with it; the log files it
appeared to also cover are matched by the repository-root .gitignore.

Co-Authored-By: Claude <noreply@anthropic.com>
A tools/call resolves to a shell function by name, so a server could run any
tool_* function it had sourced, declared or not. api.sh, label.sh and
project.sh are shared by both servers and each carries tools the other does
not declare, which put label_add, label_remove, project_item_add,
project_status_set and api — which takes any HTTP method — on the read
server. That server is always active and needs no configuration, so
enable_write_server gated what tools/list advertised and nothing else: all
five ran against the authenticated gh CLI whether or not the write server was
enabled. In the other direction the write server carried api_read,
label_list, project_list and project_view.

An undeclared tool has no schema, and argument validation treats a missing
schema as nothing to check, so every one of them also ran unvalidated. That
is the protocol layer this repository relies on to keep tool arguments away
from the unguarded numeric comparisons in _gh_post_process, and without it a
crafted max_lines reached bash arithmetic evaluation and executed a command
inside the server process, which holds the user's gh credentials.

_gh_unset_undeclared_tools removes every sourced tool function the running
server's tools list does not name. It derives the permitted set from that
list rather than from a hardcoded roster, so a tool added to a shared lib
cannot open the gap again, and an empty tools list leaves nothing callable —
which is what now makes a disabled write server inert rather than merely
silent about what it can do.

The new suite asserts the property for both servers in both states and
computes the undeclared set at runtime. Four of its five tests fail against
the previous code; the fifth is the control.

BREAKING CHANGE: a tool call that reached a write tool through the read
server, or any tool through a disabled write server, now answers
"Tool not found". Every tool a server declares is unaffected.

Co-Authored-By: Claude <noreply@anthropic.com>
repo_file and search_code with download_to redirected gh straight into the
destination. A call that ended partway through left a truncated file at the
path the caller asked for, with nothing to distinguish it from a complete
one. Cancellation makes that reachable: from bash-mcp-sdk v4.0.0 a cancelled
call has its tool process group signalled, where the command previously ran
to completion no matter what the client did.

Both sites now write to a .partial sibling and rename once gh has exited
successfully, so the destination only ever holds a whole body. Two existing
defects go with the change:

- _gh_download_file redirected stderr into the file as well (2>&1) and left
  it in place on failure, so a failed download wrote the GitHub API's error
  text to disk as the file's contents.
- repo_file removed the destination when a download failed, which deleted
  whatever was already at that path. A failure now leaves it untouched.

A cancelled call can leave a .partial file behind. That is deliberate: it is
visibly not the requested file, where a truncated destination is not.

Co-Authored-By: Claude <noreply@anthropic.com>
Also states the dependency floors the servers now enforce. README listed bash
4.0+, which was never the protocol layer's requirement and is now two minor
versions below it, and gave no jq version at all. SETUP gains a bash
prerequisite and a jq version check.

Both say what a Mac needs, because the shape of the failure is specific:
macOS keeps bash 3.2 at /bin/bash and no install replaces it, so installing a
newer bash is only half the job — its directory has to precede /usr/bin on
the PATH the MCP host launches the server with, and a host started from the
desktop reads no shell profile.

plugin-setup's skill reference is a byte-identical copy of SETUP.md and is
re-synced here.

Co-Authored-By: Claude <noreply@anthropic.com>
…e run

The assertion that a disabled write server writes no tools list of its own
searched the server directory and required the result to be empty. The
directory is shared by every run of the suite, and running an earlier
revision there leaves a startup-written list behind, so the test then failed
against code that is correct — for a file its own run never created.

It now records the matching paths before and after and requires them to be
equal, which answers the question the test is actually asking. The shipped
tools-empty.json does not match the pattern.

Still fails against the previous server-write.sh, along with the two dispatch
assertions.

Co-Authored-By: Claude <noreply@anthropic.com>
ShellCheck SC2013: iterating $(grep ...) with `for` splits on every character
in IFS and glob-expands each field, which is wrong for a list of paths even
where these particular names happen to be safe.

CI lints .bats files alongside .sh and .bash, so this failed the ShellCheck
step while a local run limited to shell scripts passed.

Co-Authored-By: Claude <noreply@anthropic.com>
@SpiGAndromeda
Martin Bens (SpiGAndromeda) merged commit 60cb703 into main Sep 13, 2026
2 checks passed
@SpiGAndromeda
Martin Bens (SpiGAndromeda) deleted the chore/bash-mcp-sdk-v5 branch September 13, 2026 21:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant