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 .mcp-sdk.lock
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
version=v3.0.0
sha256=3fc8bc7a58fec1a7e9df1fff64d6f022fd14d9561b1faf82bf4e464e70637e46
version=v5.0.0
sha256=b8e55b1f05fe64d165412a5bedc8b5baba764812e13ef0821a536a6c58b7cf07
112 changes: 112 additions & 0 deletions plugin-tests/github-mcp/tool_dispatch_declaration.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env bats
# bats file_tags=github-mcp,dispatch,gating
# A server must run only the tools its own tools list declares.
#
# api.sh, label.sh, and project.sh are shared by both servers and each carries
# tools the other does not declare. Dispatch resolves a tools/call to a shell
# function by name, so without _gh_unset_undeclared_tools every sourced tool is
# callable — which put the write-side label_add, label_remove, project_item_add,
# project_status_set, and api on the always-active read server, each running
# with no schema to validate its arguments against.
bats_require_minimum_version 1.11.0

load 'test_helper/common_setup'

setup() {
PROJECT_DIR="${BATS_TEST_TMPDIR}/project"
mkdir -p "${PROJECT_DIR}"
}

# Ask a server to run one tool with no arguments. A tool that is not dispatchable
# answers "Tool not found"; one that is reports a missing parameter of its own.
call_tool() {
local server="$1" tool="$2" project_root="$3"
printf '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"%s","arguments":{}}}\n' "$tool" \
| env PROJECT_ROOT="$project_root" bash "${GH_SERVER_DIR}/${server}" 2>/dev/null \
| tail -1 \
| jq -r '.result.content[0].text // .error.message // "no answer"'
}

# Every tool_* function the given server sources but does not declare.
undeclared_tools() {
local server="$1" tools_file="$2" lib sourced declared
sourced=$(
grep -o 'lib/[a-z_]*\.sh' "${GH_SERVER_DIR}/${server}" | sort -u \
| while IFS= read -r lib; do
grep -h -o '^tool_[a-z_0-9]*' "${GH_SERVER_DIR}/${lib}" 2>/dev/null
done \
| sed 's/^tool_//' | sort -u
)
declared=$(jq -r '.tools[].name' "${GH_SERVER_DIR}/${tools_file}" | sort -u)
comm -23 <(printf '%s\n' "$sourced") <(printf '%s\n' "$declared")
}

@test "read server does not dispatch any tool it does not declare" {
local tool found=0
while read -r tool; do
[[ -n "$tool" ]] || continue
found=$(( found + 1 ))
run call_tool "server-read.sh" "$tool" "${PROJECT_DIR}"
assert_success
assert_output --partial "Tool not found: ${tool}"
done < <(undeclared_tools "server-read.sh" "tools-read.json")

# The read server sources write tools today; if that ever stops being true
# this test would pass while checking nothing.
[[ "$found" -gt 0 ]]
}

@test "read server still dispatches a tool it declares" {
run call_tool "server-read.sh" "api_read" "${PROJECT_DIR}"
assert_success
refute_output --partial "Tool not found"
assert_output --partial "endpoint"
}

@test "write server dispatches nothing while it is disabled" {
printf '%s\n' '{"enable_write_server": false}' > "${PROJECT_DIR}/.mcp-gh-tooling.json"

local tool
for tool in pr_create issue_create label_add label_list api; do
run call_tool "server-write.sh" "$tool" "${PROJECT_DIR}"
assert_success
assert_output --partial "Tool not found: ${tool}"
done
}

@test "enabled write server dispatches its own tools but not undeclared ones" {
printf '%s\n' '{"enable_write_server": true}' > "${PROJECT_DIR}/.mcp-gh-tooling.json"

run call_tool "server-write.sh" "pr_create" "${PROJECT_DIR}"
assert_success
refute_output --partial "Tool not found"

local tool found=0
while read -r tool; do
[[ -n "$tool" ]] || continue
found=$(( found + 1 ))
run call_tool "server-write.sh" "$tool" "${PROJECT_DIR}"
assert_success
assert_output --partial "Tool not found: ${tool}"
done < <(undeclared_tools "server-write.sh" "tools-write.json")

[[ "$found" -gt 0 ]]
}

@test "disabled write server writes no tools list of its own" {
printf '%s\n' '{"enable_write_server": false}' > "${PROJECT_DIR}/.mcp-gh-tooling.json"

# Compared before and after rather than asserted absent: the server
# directory is shared, and a run of any earlier revision leaves its own
# startup-written list behind. What matters is that this run writes none.
# The shipped tools-empty.json does not match the pattern.
local before after
before=$(find "${GH_SERVER_DIR}" -maxdepth 1 -name 'tools-empty.*.json' | sort)

printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
| env PROJECT_ROOT="${PROJECT_DIR}" bash "${GH_SERVER_DIR}/server-write.sh" >/dev/null 2>&1

after=$(find "${GH_SERVER_DIR}" -maxdepth 1 -name 'tools-empty.*.json' | sort)

[[ "$before" == "$after" ]]
}
2 changes: 1 addition & 1 deletion plugins/github-mcp/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "github-mcp",
"version": "4.1.0",
"version": "4.2.0",
"description": "GitHub CLI MCP servers wrapping gh for pull requests, issues, CI runs, jobs, commits, search, labels, projects, and reviews. Read server (always active) with 31 tools and write server (opt-in) with 25 tools. Includes SessionStart hook that injects MCP tool directives and PreToolUse hooks that enforce MCP tool usage. Configuration-optional: works without config when gh is authenticated.",
"author": {
"name": "Shopware Labs"
Expand Down
2 changes: 1 addition & 1 deletion plugins/github-mcp/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "github-mcp",
"version": "4.1.0",
"version": "4.2.0",
"description": "GitHub CLI MCP servers for pull requests, issues, CI, repositories, and other GitHub workflows, with shared lifecycle hooks that steer agents to the MCP tools.",
"author": {
"name": "Shopware Labs"
Expand Down
3 changes: 0 additions & 3 deletions plugins/github-mcp/.gitignore

This file was deleted.

18 changes: 18 additions & 0 deletions plugins/github-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [4.2.0] - 2026-09-13

### Security

- A server now runs only the tools its own tools list declares. Tool calls resolve to a shell function by name, and `api.sh`, `label.sh`, and `project.sh` are shared by both servers, so every tool one server sourced for the other was callable on it. 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, so those five write tools 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 all of them ran with their arguments unvalidated; combined with the unguarded numeric comparisons in `_gh_post_process`, a crafted `max_lines` reached bash arithmetic evaluation and executed a command inside the server process. Tools not declared by the running server are now removed from dispatch at startup and answer `Tool not found`. **Breaking for callers** who reached a write tool through the read server, or any tool through the write server while it was disabled: both now refuse. Every tool a server declares is unaffected.

### Fixed

- `repo_file` and `search_code` with `download_to` write to a sibling file and rename it once the body is complete. Both wrote straight to the destination, so a call interrupted partway left a truncated file that reads as a complete one — reachable for the first time now that a cancelled call has its process group killed mid-write. A failed download also no longer removes an existing file at that path, and `search_code` no longer leaves the GitHub API's error text on disk as the file's contents.
- The write server no longer writes its own empty tools list at startup. The file was created with a `mktemp` template whose placeholder was not at the end of the name, so every server on the machine shared one fixed path, and its cleanup ran from an `EXIT` trap the protocol layer replaces. A shipped `mcp-server-gh/tools-empty.json` takes its place.

### Changed

- `shared/mcpserver_core.sh` is vendored from [shopwareLabs/bash-mcp-sdk](https://github.com/shopwareLabs/bash-mcp-sdk) `v5.0.0`, up from `v3.0.0`.
- **The servers now require bash 4.1+ and jq 1.7+, and refuse to start below either.** Both floors were already the protocol layer's documented requirements, and a server below them started anyway: on bash 3.2 it died with `{_MCP_LIFELINE_FD}: not found` before speaking, and on jq 1.6 it ran while silently skipping part of the argument validation its schemas declare. A refusal now names the requirement, the version found, and the install command for the platform. **This stops servers that start today.** macOS ships bash 3.2 as `/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.
- Cancelling a tool call now stops the work. `notifications/cancelled` was previously ignored, so a cancelled `run_logs` or `search_code` ran its `gh` command to completion; the call's process group is now signalled and the command stops. No configuration or tool change was needed for this.
- 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 are each answered and the server keeps reading. Any one of them previously stopped it with no response, leaving every later request unanswered.

## [4.1.0] - 2026-09-04

### Changed
Expand Down
4 changes: 2 additions & 2 deletions plugins/github-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,8 @@ Seven read tools need `fields` before a filter has JSON to run on, and `issue_vi

## Dependencies

- **bash** (4.0+)
- **jq** (JSON processor)
- **bash** (4.1+) — the MCP servers refuse to start below this and say so on stderr. macOS ships 3.2 as `/bin/bash` and no install replaces it, so a Mac needs a newer bash on the `PATH` the host launches the server with.
- **jq** (1.7+) — below this, tool argument validation is skipped, so the servers refuse to start rather than validate less than they claim.
- **gh** CLI (GitHub CLI, authenticated)

## License
Expand Down
9 changes: 7 additions & 2 deletions plugins/github-mcp/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@
- **Required by**: The gh-tooling MCP server. The CLI must be authenticated with a GitHub account that has access to the repositories you want to work with.

### jq
- **Check**: `jq --version`
- **Check**: `jq --version` (1.7 or newer)
- **Install**: https://jqlang.github.io/jq/download/
- **Required by**: The gh-tooling MCP server (JSON processing)
- **Required by**: The gh-tooling MCP server (JSON processing). Below 1.7 the server refuses to start: tool argument validation cannot hold every constraint it declares on an older jq, and starting anyway would validate less than the schemas promise.

### bash
- **Check**: `bash --version` (4.1 or newer)
- **Install**: `brew install bash` on macOS; already satisfied by any current Linux distribution
- **Required by**: The gh-tooling MCP server. macOS ships bash 3.2 as `/bin/bash` and no install replaces it, so installing a newer bash is only half the job — its directory has to come before `/usr/bin` on the `PATH` the MCP host launches the server with. A host started from the desktop reads no shell profile, so that `PATH` may not be the one your terminal has.

## Configuration Files

Expand Down
48 changes: 47 additions & 1 deletion plugins/github-mcp/mcp-server-gh/lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,37 @@ _gh_validate_sha() {
fi
}

# Restrict dispatch to the tools this server declares.
#
# The protocol layer resolves a tools/call to a shell function by name, so every
# sourced tool_* function is callable whether or not this server's tools list
# declares it. api.sh, label.sh, and project.sh are shared by both servers and
# each carries tools the other does not declare, which put the write-side
# label_add, label_remove, project_item_add, project_status_set, and api on the
# always-active read server. An undeclared tool also has no schema, and argument
# validation treats a missing schema as nothing to check, so those tools ran
# with their arguments unvalidated.
#
# Dropping the undeclared functions makes the tools list the only thing that
# decides what this server runs. An empty tools list therefore leaves no tool
# callable, which is how the write server stays inert until it is enabled.
_gh_unset_undeclared_tools() {
local declared fn name
declared=$(jq -r '.tools[]?.name // empty' "${MCP_TOOLS_LIST_FILE}" 2>/dev/null) || {
log "ERROR" "Cannot read declared tool names from ${MCP_TOOLS_LIST_FILE}"
return 1
}

while IFS= read -r fn; do
[[ -n "${fn}" ]] || continue
name="${fn#tool_}"
if ! printf '%s\n' "${declared}" | grep -qxF -- "${name}"; then
unset -f "${fn}"
log "INFO" "Removed from dispatch, not declared here: ${name}"
fi
done < <(declare -F | awk '{print $3}' | grep '^tool_' || true)
}

# Resolve the effective repository to use for an API call.
# Uses the provided repo arg first, then falls back to GH_DEFAULT_REPO.
# Args: $1 = repo from tool arguments (may be empty)
Expand Down Expand Up @@ -367,10 +398,25 @@ _gh_download_file() {
echo "Error: cannot create directory ${parent_dir}"
return 1
}
"${cmd[@]}" > "${local_path}" 2>&1 || {
# Write to a sibling and rename once the body is complete. A cancelled call
# has its process group killed mid-write, and a half-written file at the
# target path reads as a complete one. The redirect also captures gh's
# diagnostics, which would otherwise land in the file as its contents.
local tmp_path
tmp_path=$(mktemp "${local_path}.partial.XXXXXX") || {
echo "Error: cannot create a temporary file next to ${local_path}"
return 1
}
"${cmd[@]}" > "${tmp_path}" 2>&1 || {
rm -f "${tmp_path}"
echo "Error: failed to download ${owner}/${repo}/${remote_path}"
return 1
}
mv -- "${tmp_path}" "${local_path}" || {
rm -f "${tmp_path}"
echo "Error: cannot write ${local_path}"
return 1
}
}

# Resolve owner/repo from multiple sources with priority:
Expand Down
20 changes: 17 additions & 3 deletions plugins/github-mcp/mcp-server-gh/lib/repo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -132,20 +132,34 @@ tool_repo_file() {
echo "Error: cannot create directory ${parent_dir}"
return 1
}
# Write to a sibling and rename once the body is complete. A cancelled
# call has its process group killed mid-write, and a half-written file
# at the target path reads as a complete one. A failed download now also
# leaves any existing file at that path alone.
local __tmp_dl
__tmp_dl=$(mktemp "${download_to}.partial.XXXXXX") || {
echo "Error: cannot create a temporary file next to ${download_to}"
return 1
}
local __exit=0 __dl_err=""
if [[ "${suppress_errors}" == "true" ]]; then
"${cmd[@]}" > "${download_to}" 2>/dev/null || __exit=$?
"${cmd[@]}" > "${__tmp_dl}" 2>/dev/null || __exit=$?
else
# Capture stderr separately — don't write API errors into the download file
__dl_err=$({ "${cmd[@]}" > "${download_to}"; } 2>&1) || __exit=$?
__dl_err=$({ "${cmd[@]}" > "${__tmp_dl}"; } 2>&1) || __exit=$?
fi
if [[ ${__exit} -ne 0 ]]; then
rm -f "${download_to}" 2>/dev/null
rm -f "${__tmp_dl}" 2>/dev/null
[[ -n "${fallback}" ]] && { echo "${fallback}"; return 0; }
[[ -n "${__dl_err}" ]] && { echo "${__dl_err}"; return ${__exit}; }
echo "Error: failed to download ${owner}/${repo}/${path}"
return ${__exit}
fi
mv -- "${__tmp_dl}" "${download_to}" || {
rm -f "${__tmp_dl}" 2>/dev/null
echo "Error: cannot write ${download_to}"
return 1
}
echo "Downloaded ${owner}/${repo}/${path} to ${download_to}"
return 0
fi
Expand Down
1 change: 1 addition & 0 deletions plugins/github-mcp/mcp-server-gh/server-read.sh
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ trap 'log "ERROR" "Unexpected error on line ${LINENO}"' ERR

_load_gh_config "${PROJECT_ROOT}"
_read_gh_config
_gh_unset_undeclared_tools

log "INFO" "======================================"
log "INFO" "GitHub CLI MCP Server starting"
Expand Down
11 changes: 6 additions & 5 deletions plugins/github-mcp/mcp-server-gh/server-write.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,12 @@ _check_write_enabled() {
fi
if [[ "${enabled}" != "true" ]]; then
log "INFO" "Write server disabled (enable_write_server != true)"
local empty_tools
empty_tools=$(mktemp "${SCRIPT_DIR}/tools-empty.XXXXXX.json")
printf '{"tools":[]}\n' > "${empty_tools}"
MCP_TOOLS_LIST_FILE="${empty_tools}"
# A shipped empty list rather than one written at startup. The protocol
# layer owns the process's EXIT trap, so a cleanup trap set here never
# runs, and it answers -32603 for a tools list it cannot read — which a
# startup-written file becomes the moment anything removes it.
MCP_TOOLS_LIST_FILE="${SCRIPT_DIR}/tools-empty.json"
export MCP_TOOLS_LIST_FILE
trap 'rm -f "'"${empty_tools}"'"' EXIT
else
log "INFO" "Write server enabled"
fi
Expand All @@ -120,6 +120,7 @@ trap 'log "ERROR" "Unexpected error on line ${LINENO}"' ERR
_load_gh_config "${PROJECT_ROOT}"
_read_gh_config
_check_write_enabled
_gh_unset_undeclared_tools

log "INFO" "======================================"
log "INFO" "GitHub CLI MCP Server (WRITE) starting"
Expand Down
3 changes: 3 additions & 0 deletions plugins/github-mcp/mcp-server-gh/tools-empty.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"tools": []
}
Loading
Loading