From 180f9523f7e1c19bad3684e7536da09c28a54fe9 Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Wed, 2 Sep 2026 23:25:03 +0200 Subject: [PATCH 1/3] refactor(github-mcp)!: vendor the protocol handler from bash-mcp-sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shared/mcpserver_core.sh was maintained here after the split from ai-coding-tools and drifted three commits behind it. It is now vendored byte-identical from shopwareLabs/bash-mcp-sdk lib/mcpserver_core.sh at v1.0.0, with .mcp-sdk.lock recording the release and renovate.json watching it for new ones. Protocol changes go to the SDK repository and arrive here as a lock bump; a local edit is overwritten by the next update. The upgrade carries the validator work this repo never picked up. Argument validation now enforces enum, a declared type, a pattern on string values, and items.type / items.enum per array element, on top of required and additionalProperties. It also closes a hole where a trailing || true masked a jq failure: an arguments value that was present but not an object, such as null or false, made the pipeline error and every check was skipped while the call dispatched. project_view.number and the issue_number / sub_issue_number pairs on sub_issue_add and sub_issue_remove were the only numeric identifiers still declared integer-only, so the stricter type check would have refused the string form clients send. They now declare ["integer", "string"], matching every other identifier. The tool functions already read both forms through jq -r and check the result with _gh_validate_number; only the schemas were narrower. The SDK tests its own surface, so the suites covering validate_tool_arguments, handle_tools_call, log and _configure_extra_log_file are removed. plugin-tests/github-mcp/tool_schemas.bats replaces them with what only this repository can check: that the shipped tool schemas describe the calls clients make, verified against the vendored validator itself. BREAKING CHANGE: an argument whose type does not match its schema now returns an isError result instead of being passed through to gh. This affects the integer-typed paging and output parameters — limit, max_lines, tail_lines, grep_context_before, grep_context_after, line_start, line_end — where a quoted number such as "20" is refused. Send them as JSON numbers. Identifier parameters accept both forms and are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- .mcp-sdk.lock | 1 + AGENTS.md | 12 +- plugin-tests/github-mcp/extra_log_file.bats | 66 -------- plugin-tests/github-mcp/tool_schemas.bats | 126 +++++++++++++++ .../mcp-shared/mcp_argument_validation.bats | 109 ------------- plugins/github-mcp/AGENTS.md | 6 +- plugins/github-mcp/CHANGELOG.md | 13 ++ .../github-mcp/mcp-server-gh/tools-read.json | 2 +- .../github-mcp/mcp-server-gh/tools-write.json | 8 +- plugins/github-mcp/shared/mcpserver_core.sh | 148 ++++++++++++++++-- renovate.json | 15 ++ 11 files changed, 311 insertions(+), 195 deletions(-) create mode 100644 .mcp-sdk.lock delete mode 100644 plugin-tests/github-mcp/extra_log_file.bats create mode 100644 plugin-tests/github-mcp/tool_schemas.bats delete mode 100644 plugin-tests/mcp-shared/mcp_argument_validation.bats create mode 100644 renovate.json diff --git a/.mcp-sdk.lock b/.mcp-sdk.lock new file mode 100644 index 0000000..c7d8b25 --- /dev/null +++ b/.mcp-sdk.lock @@ -0,0 +1 @@ +version=v1.0.0 diff --git a/AGENTS.md b/AGENTS.md index e09b7f1..48eae78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,9 +94,15 @@ wire a new read/write tool and its schema). Start there for any change inside th ## mcpserver_core.sh -`plugins/github-mcp/shared/mcpserver_core.sh` is the JSON-RPC protocol handler. -`plugin-tests/mcp-shared/mcp_argument_validation.bats` sources it directly. Edit it in place -when changing the protocol handler. +`plugins/github-mcp/shared/mcpserver_core.sh` is the JSON-RPC protocol handler. It is vendored +verbatim from [shopwareLabs/bash-mcp-sdk](https://github.com/shopwareLabs/bash-mcp-sdk) +(`lib/mcpserver_core.sh`); `.mcp-sdk.lock` records the release it came from, and `renovate.json` +watches that lock for new releases. + +Do not edit it here — a local change is overwritten by the next update. Protocol changes go to +the SDK repository and arrive as a lock bump plus a refreshed copy of the file. The SDK owns the +tests for its own surface (argument validation, logging); `plugin-tests/` covers this plugin's +tools and schemas. ## Commit Messages diff --git a/plugin-tests/github-mcp/extra_log_file.bats b/plugin-tests/github-mcp/extra_log_file.bats deleted file mode 100644 index b34e623..0000000 --- a/plugin-tests/github-mcp/extra_log_file.bats +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bats -# bats file_tags=github-mcp,extra-log -bats_require_minimum_version 1.11.0 - -load 'test_helper/common_setup' - -setup() { - MCP_LOG_FILE="${BATS_TEST_TMPDIR}/server.log" - MCP_EXTRA_LOG_FILE="" - PROJECT_ROOT="${BATS_TEST_TMPDIR}" - MCP_CONFIG_FILE="/dev/null" - MCP_TOOLS_LIST_FILE="/dev/null" - export MCP_LOG_FILE MCP_EXTRA_LOG_FILE PROJECT_ROOT MCP_CONFIG_FILE MCP_TOOLS_LIST_FILE - source "${SHARED_DIR}/mcpserver_core.sh" -} - -teardown() { - unset MCP_LOG_FILE MCP_EXTRA_LOG_FILE PROJECT_ROOT MCP_CONFIG_FILE MCP_TOOLS_LIST_FILE -} - -# --- _configure_extra_log_file --- - -@test "_configure_extra_log_file: empty path is a no-op" { - _configure_extra_log_file "" - [[ -z "$MCP_EXTRA_LOG_FILE" ]] -} - -@test "_configure_extra_log_file: relative path resolves against PROJECT_ROOT" { - mkdir -p "${BATS_TEST_TMPDIR}/subdir" - _configure_extra_log_file "subdir/debug.log" - [[ "$MCP_EXTRA_LOG_FILE" == "${BATS_TEST_TMPDIR}/subdir/debug.log" ]] -} - -@test "_configure_extra_log_file: absolute path used as-is" { - _configure_extra_log_file "/tmp/bats-test-mcp.log" - [[ "$MCP_EXTRA_LOG_FILE" == "/tmp/bats-test-mcp.log" ]] -} - -@test "_configure_extra_log_file: non-existent parent dir warns and skips" { - _configure_extra_log_file "nonexistent/debug.log" - [[ -z "$MCP_EXTRA_LOG_FILE" ]] - run grep "WARN" "${BATS_TEST_TMPDIR}/server.log" - assert_success - assert_output --partial "log_file parent directory does not exist" -} - -# --- log() dual-write --- - -@test "log: writes to both files when extra log configured" { - local extra="${BATS_TEST_TMPDIR}/extra.log" - MCP_EXTRA_LOG_FILE="$extra" - log "INFO" "dual write test" - run grep "dual write test" "${BATS_TEST_TMPDIR}/server.log" - assert_success - run grep "dual write test" "$extra" - assert_success -} - -@test "log: writes only to MCP_LOG_FILE when no extra log" { - local extra="${BATS_TEST_TMPDIR}/extra.log" - MCP_EXTRA_LOG_FILE="" - log "INFO" "single write test" - run grep "single write test" "${BATS_TEST_TMPDIR}/server.log" - assert_success - [[ ! -f "$extra" ]] -} diff --git a/plugin-tests/github-mcp/tool_schemas.bats b/plugin-tests/github-mcp/tool_schemas.bats new file mode 100644 index 0000000..f60bc67 --- /dev/null +++ b/plugin-tests/github-mcp/tool_schemas.bats @@ -0,0 +1,126 @@ +#!/usr/bin/env bats +# bats file_tags=github-mcp,tool-schemas +# The tool schemas this plugin ships, checked against the vendored SDK +# validator that enforces them at call time. +# +# The SDK owns the validator's own semantics (shopwareLabs/bash-mcp-sdk, +# tests/mcp_argument_validation.bats). What only this repository can check is +# the seam: whether tools-read.json and tools-write.json describe the calls +# clients actually make, now that a declared `type` is refused when it does +# not match. +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +setup() { + READ_TOOLS="${GH_SERVER_DIR}/tools-read.json" + WRITE_TOOLS="${GH_SERVER_DIR}/tools-write.json" + + MCP_LOG_FILE="${BATS_TEST_TMPDIR}/server.log" + MCP_CONFIG_FILE="/dev/null" + PROJECT_ROOT="${BATS_TEST_TMPDIR}" + export MCP_LOG_FILE MCP_CONFIG_FILE PROJECT_ROOT +} + +teardown() { + unset MCP_LOG_FILE MCP_CONFIG_FILE PROJECT_ROOT MCP_TOOLS_LIST_FILE +} + +# Source the vendored SDK against one tool list. Deferred to the test body so +# each test picks the read or the write registry. +load_validator_for() { + export MCP_TOOLS_LIST_FILE="$1" + source "${SHARED_DIR}/mcpserver_core.sh" +} + +# --- schema shape --- + +@test "an identifier parameter accepts the number written as a string" { + # Clients send issue and PR numbers both ways. An identifier declared + # "integer" alone makes the string form an isError once the validator + # enforces type, so every numeric identifier declares the union. + # String-only identifiers (commit_id, a SHA) are not numbers and are left + # alone. + run jq -r '.tools[] | .name as $tool | (.inputSchema.properties // {}) | to_entries[] + | select(.key == "number" or (.key | endswith("_id")) or (.key | endswith("_number"))) + | select(.value.type == "integer") + | "\($tool).\(.key) is integer-only"' "$READ_TOOLS" "$WRITE_TOOLS" + + assert_success + assert_output "" +} + +@test "a required parameter is declared in the tool's own properties" { + # A name in `required` but not in `properties` makes the tool uncallable + # under additionalProperties:false — supplying it is rejected as unknown, + # omitting it as missing. + run jq -r '.tools[] | .name as $tool | .inputSchema as $schema + | ($schema.required // [])[] | . as $field + | select((($schema.properties // {}) | has($field)) | not) + | "\($tool) requires \($field) but declares no such property"' "$READ_TOOLS" "$WRITE_TOOLS" + + assert_success + assert_output "" +} + +@test "a parameter's default satisfies the constraints declared beside it" { + # A default outside its own enum, or of the wrong type, documents a call + # the validator refuses. + run jq -r '.tools[] | .name as $tool | (.inputSchema.properties // {}) | to_entries[] + | .key as $field | .value as $prop + | select($prop.default != null) + | select( + ($prop.enum != null and ($prop.enum | index($prop.default)) == null) + or (($prop.type | type) == "string" and ( + if $prop.type == "integer" + then ($prop.default | type) != "number" + else ($prop.default | type) != $prop.type + end)) + ) + | "\($tool).\($field) default \($prop.default | tojson) violates its own schema"' \ + "$READ_TOOLS" "$WRITE_TOOLS" + + assert_success + assert_output "" +} + +# --- validator round-trip against the shipped registries --- + +@test "a read tool accepts an issue number sent as a string" { + load_validator_for "$READ_TOOLS" + + run validate_tool_arguments "issue_view" '{"number": "339", "repo": "shopwareLabs/github-agent-tools"}' + + assert_success + assert_output "" +} + +@test "a read tool accepts a project number sent as a string" { + load_validator_for "$READ_TOOLS" + + run validate_tool_arguments "project_view" '{"number": "12", "owner": "shopwareLabs"}' + + assert_success + assert_output "" +} + +@test "a write tool accepts sub-issue numbers sent as strings" { + load_validator_for "$WRITE_TOOLS" + + run validate_tool_arguments "sub_issue_add" '{"issue_number": "339", "sub_issue_number": "340"}' + + assert_success + assert_output "" +} + +@test "a paging limit sent as a string is refused, naming the parameter" { + # The counterpart to the identifier union: limit and max_lines are + # integer-only on purpose, so the string form must fail rather than reach + # gh as an unvalidated value. + load_validator_for "$READ_TOOLS" + + run validate_tool_arguments "pr_list" '{"limit": "20"}' + + assert_failure + assert_output --partial "limit expected integer, got string" +} diff --git a/plugin-tests/mcp-shared/mcp_argument_validation.bats b/plugin-tests/mcp-shared/mcp_argument_validation.bats deleted file mode 100644 index 3bc8036..0000000 --- a/plugin-tests/mcp-shared/mcp_argument_validation.bats +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bats -# bats file_tags=mcp-core,argument-validation -# Tests for the shared mcpserver_core argument validator: -# validate_tool_arguments() and its wiring into handle_tools_call(). -# Enforces `required` always, and `additionalProperties: false` when declared. -# Sources the github-mcp plugin's copy of the shared handler -# (plugins/github-mcp/shared/mcpserver_core.sh) — the single source of truth now -# that the templates/ directory has been removed. -bats_require_minimum_version 1.11.0 - -load "${BATS_TEST_DIRNAME}/../test_helper/common_setup" - -setup() { - MCP_LOG_FILE="${BATS_TEST_TMPDIR}/server.log" - MCP_EXTRA_LOG_FILE="" - MCP_CONFIG_FILE="/dev/null" - MCP_TOOLS_LIST_FILE="${BATS_TEST_TMPDIR}/tools.json" - PROJECT_ROOT="${BATS_TEST_TMPDIR}" - export MCP_LOG_FILE MCP_EXTRA_LOG_FILE MCP_CONFIG_FILE MCP_TOOLS_LIST_FILE PROJECT_ROOT - - # Fixture tool list: - # strict — required:[number], additionalProperties:false - # loose — no required, additionalProperties unset (defaults to allowed) - cat > "${MCP_TOOLS_LIST_FILE}" <<'JSON' -{ - "tools": [ - { - "name": "strict", - "inputSchema": { - "type": "object", - "required": ["number"], - "properties": { "number": {"type": "string"}, "repo": {"type": "string"} }, - "additionalProperties": false - } - }, - { - "name": "loose", - "inputSchema": { - "type": "object", - "properties": { "a": {"type": "string"} } - } - } - ] -} -JSON - - source "${REPO_ROOT}/plugins/github-mcp/shared/mcpserver_core.sh" - - # A dispatchable stub that echoes a marker so dispatch can be observed. - tool_strict() { printf 'DISPATCHED:%s\n' "$1"; } -} - -teardown() { - unset MCP_LOG_FILE MCP_EXTRA_LOG_FILE MCP_CONFIG_FILE MCP_TOOLS_LIST_FILE PROJECT_ROOT -} - -# --- validate_tool_arguments: direct unit behavior --- - -@test "validate_tool_arguments: missing required field fails with its name" { - run validate_tool_arguments "strict" '{"repo": "a/b"}' - assert_failure - assert_output --partial "Missing required parameter(s): number" -} - -@test "validate_tool_arguments: unknown field fails and lists allowed parameters" { - run validate_tool_arguments "strict" '{"number": "5", "pr": 339}' - assert_failure - assert_output --partial "Unknown parameter(s): pr" - assert_output --partial "Allowed parameters: number, repo" -} - -@test "validate_tool_arguments: valid arguments pass with no output" { - run validate_tool_arguments "strict" '{"number": "5", "repo": "a/b"}' - assert_success - assert_output "" -} - -@test "validate_tool_arguments: unknown field allowed when additionalProperties is unset" { - run validate_tool_arguments "loose" '{"a": "x", "anything": "y"}' - assert_success - assert_output "" -} - -@test "validate_tool_arguments: tool absent from the schema list is not validated" { - run validate_tool_arguments "nonexistent" '{"whatever": 1}' - assert_success - assert_output "" -} - -# --- handle_tools_call: wiring (validation runs before dispatch) --- - -@test "handle_tools_call: invalid arguments return an isError result, not a dispatch" { - local params - params=$(jq -n -c '{name: "strict", arguments: {repo: "a/b"}}') - run handle_tools_call 1 "$params" - assert_success - assert_output --partial '"isError":true' - assert_output --partial "Missing required parameter(s): number" - refute_output --partial "DISPATCHED" -} - -@test "handle_tools_call: valid arguments are dispatched to the tool" { - local params - params=$(jq -n -c '{name: "strict", arguments: {number: "5"}}') - run handle_tools_call 1 "$params" - assert_success - assert_output --partial "DISPATCHED" - assert_output --partial '"isError":false' -} diff --git a/plugins/github-mcp/AGENTS.md b/plugins/github-mcp/AGENTS.md index 54f0f65..1343ad7 100644 --- a/plugins/github-mcp/AGENTS.md +++ b/plugins/github-mcp/AGENTS.md @@ -123,7 +123,7 @@ Captures `__raw` and `__exit` separately; branches on `suppress_errors` for `2>/ | Disable hook enforcement | `.mcp-gh-tooling.json` | - | `enforce_mcp_tools: false` | | Enable write server | `.mcp-gh-tooling.json` | - | `enable_write_server: true` | | Configure label semantics | `.mcp-gh-tooling.json` | - | `labels: {...}` map | -| Modify protocol | `shared/mcpserver_core.sh` | - | `process_request()`, `handle_*()` | +| Modify protocol | upstream `shopwareLabs/bash-mcp-sdk` | - | `shared/mcpserver_core.sh` is vendored; see root `AGENTS.md` | | Update read tool schemas | `mcp-server-gh/tools-read.json` | - | JSON Schema Draft 7 | | Update write tool schemas | `mcp-server-gh/tools-write.json` | - | JSON Schema Draft 7 | @@ -186,7 +186,9 @@ BATS tests for hook scripts and MCP tool functions are in `plugin-tests/github-m | `session_start.bats` | Shared SessionStart context and host-specific config discovery | | `write_server_gating.bats` | Write-server gating and active-host config priority | | `mcp_tool_gh.bats` | MCP tool shared parameters (_gh_validate_jq_filter, _gh_post_process, suppress_errors, fallback) | -| `extra_log_file.bats` | Extra log file configuration and dual-write log() | + +The vendored SDK's own surface — argument validation and logging — is tested upstream in +`shopwareLabs/bash-mcp-sdk`, not here. Run tests: ```bash diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index 617fcbc..3ee924c 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -5,6 +5,19 @@ 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). +## [Unreleased] + +### Changed +- `shared/mcpserver_core.sh` is now vendored from [shopwareLabs/bash-mcp-sdk](https://github.com/shopwareLabs/bash-mcp-sdk) `v1.0.0` instead of being maintained in this repository. The file is byte-identical to `lib/mcpserver_core.sh` at that tag; `.mcp-sdk.lock` records the release and `renovate.json` opens a PR when a new one is published. Protocol changes now go to the SDK repository and arrive here as a lock bump — a local edit is overwritten by the next update. +- Tool-call argument validation now enforces a declared `type`, a declared `pattern` on string values, and `items.type` / `items.enum` on every element of an array, alongside the `required`, `additionalProperties` and `enum` checks that were already applied. Diagnostics report the most fundamental defect first, in the order missing, unknown, type, pattern, items, enum. **Breaking for callers:** an argument of the wrong type that was previously accepted and passed through to `gh` now returns an `isError` result naming the parameter, its expected type and the value received. This affects the integer-typed paging and output parameters — `limit`, `max_lines`, `tail_lines`, `grep_context_before`, `grep_context_after`, `line_start`, `line_end` — where a quoted number such as `"20"` is now refused. Identifier parameters are unaffected: they declare `["integer", "string"]` and accept both forms. +- `project_view.number`, and `issue_number` / `sub_issue_number` on `sub_issue_add` and `sub_issue_remove`, now declare `["integer", "string"]`. They were the only numeric identifiers still declared integer-only, so the string form a client may send would have been refused by the stricter validation above. The tool functions already read both forms through `jq -r` and check the result with `_gh_validate_number`; only the schemas were narrower. + +### Fixed +- Argument validation no longer skips every check when `arguments` is present but is not a JSON object. A `null` or `false` value made the validator's jq pipeline fail, the failure was masked by a trailing `|| true`, and the call was dispatched with `required`, `additionalProperties` and `enum` all unenforced. A non-object is now rejected by name and type, and a validator that cannot evaluate its input reports that instead of returning success. + +### Removed +- `plugin-tests/mcp-shared/mcp_argument_validation.bats` and `plugin-tests/github-mcp/extra_log_file.bats`. Both covered functions that belong to the vendored SDK (`validate_tool_arguments`, `handle_tools_call`, `log`, `_configure_extra_log_file`), which tests them in its own suite. `plugin-tests/github-mcp/tool_schemas.bats` replaces them with what only this repository can check: that the shipped tool schemas describe the calls clients make. + ## [3.5.0] - 2026-07-13 ### Added diff --git a/plugins/github-mcp/mcp-server-gh/tools-read.json b/plugins/github-mcp/mcp-server-gh/tools-read.json index 93c6cc3..fa87a88 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-read.json +++ b/plugins/github-mcp/mcp-server-gh/tools-read.json @@ -1793,7 +1793,7 @@ ], "properties": { "number": { - "type": "integer", + "type": ["integer", "string"], "description": "Project number." }, "owner": { diff --git a/plugins/github-mcp/mcp-server-gh/tools-write.json b/plugins/github-mcp/mcp-server-gh/tools-write.json index 5b7f116..8031722 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-write.json +++ b/plugins/github-mcp/mcp-server-gh/tools-write.json @@ -676,8 +676,8 @@ "type": "object", "required": ["issue_number", "sub_issue_number"], "properties": { - "issue_number": { "type": "integer", "description": "Parent issue number." }, - "sub_issue_number": { "type": "integer", "description": "Issue number to add as sub-issue." }, + "issue_number": { "type": ["integer", "string"], "description": "Parent issue number." }, + "sub_issue_number": { "type": ["integer", "string"], "description": "Issue number to add as sub-issue." }, "repo": { "type": "string", "description": "GitHub repository in 'owner/repo' format." }, "suppress_errors": { "type": "boolean", "default": false }, "fallback": { "type": "string" } @@ -691,8 +691,8 @@ "type": "object", "required": ["issue_number", "sub_issue_number"], "properties": { - "issue_number": { "type": "integer", "description": "Parent issue number." }, - "sub_issue_number": { "type": "integer", "description": "Sub-issue number to remove." }, + "issue_number": { "type": ["integer", "string"], "description": "Parent issue number." }, + "sub_issue_number": { "type": ["integer", "string"], "description": "Sub-issue number to remove." }, "repo": { "type": "string", "description": "GitHub repository in 'owner/repo' format." }, "suppress_errors": { "type": "boolean", "default": false }, "fallback": { "type": "string" } diff --git a/plugins/github-mcp/shared/mcpserver_core.sh b/plugins/github-mcp/shared/mcpserver_core.sh index 9a932b3..e2a4742 100755 --- a/plugins/github-mcp/shared/mcpserver_core.sh +++ b/plugins/github-mcp/shared/mcpserver_core.sh @@ -115,40 +115,165 @@ handle_tools_list() { } # Validate call arguments against the tool's declared inputSchema. -# Enforces `required` (every listed field must be present) and, when the schema -# sets `additionalProperties: false`, rejects any field not in `properties`. -# Tools without a schema (or with an unreadable tools list) are not validated. -# Args: $1 = tool name, $2 = arguments JSON object +# Rejects arguments that are not a JSON object, enforces `required` (every +# listed field must be present), when the schema sets +# `additionalProperties: false` rejects any field not in `properties`, +# enforces a declared scalar `type` (string, integer, number, boolean, array, +# object) on any present field, enforces a declared `pattern` against any +# present string-valued field, enforces a declared array `items.type` and +# `items.enum` against every element of a present array-valued field, and +# rejects any present field whose schema declares an `enum` when the supplied +# value is not one of the declared values. Diagnostics take precedence in +# that order — missing, unknown, type, pattern, items, enum — so a value that +# fails more than one constraint is reported with the most fundamental defect +# first (a type mismatch is reported before an unrelated enum mismatch). +# A tool with no entry in the tools list, or whose entry declares no +# inputSchema, is not validated. A jq failure is a rejection and never a skip: +# a validator that could not evaluate its input has not validated it, and +# reporting success there would wave every constraint through. That branch is +# defense-in-depth for a direct call rather than a live remote-input guard — +# process_request gates the whole request through `jq -e '.'`, so arguments +# arriving over the protocol are always parseable JSON. The non-object branch +# is NOT in that category: `null`, `false` and every other JSON scalar are +# parseable, so a client can send them and they reach this validator. +# Args: $1 = tool name, $2 = arguments JSON # On violation: prints a human-readable message to stdout and returns 1. validate_tool_arguments() { local tool_name="$1" local arguments="$2" - local tools_config schema - tools_config=$(read_json_file "$MCP_TOOLS_LIST_FILE") + local tools_config schema rc + # errexit is off inside this function — handle_tools_call tests it in a + # conditional — so the unreadable-tools-list fallback must be explicit + # rather than left to the call site's shape. + tools_config=$(read_json_file "$MCP_TOOLS_LIST_FILE" 2>/dev/null) || tools_config='{}' + rc=0 schema=$(echo "$tools_config" | jq -c --arg n "$tool_name" \ - '(.tools[]? | select(.name == $n) | .inputSchema) // empty' 2>/dev/null || true) + '(.tools[]? | select(.name == $n) | .inputSchema) // empty' 2>/dev/null) || rc=$? + if [[ $rc -ne 0 ]]; then + printf '%s' "Cannot validate arguments for ${tool_name}: the tool list at ${MCP_TOOLS_LIST_FILE} is not parseable JSON." + return 1 + fi [[ -z "$schema" || "$schema" == "null" ]] && return 0 + # A non-object `arguments` is rejected in the first branch because every + # constraint below reads `$args | keys`, which errors on any other type and + # would take the whole schema down with it. local message + rc=0 message=$(jq -n -r \ --argjson schema "$schema" \ --argjson args "$arguments" \ ' - ($schema.required // []) as $req + # `want == "integer"` treats a whole-valued JSON number as satisfying + # it (JSON has no distinct integer type); every other `want` is a + # plain jq `type` comparison. + def type_ok(want; val): + if want == "integer" then + (val | type) == "number" and (val == (val | floor)) + else + (val | type) == want + end; + def type_label(want; val): + if want == "integer" and (val | type) == "number" then + "number (non-integer)" + else + (val | type) + end; + if ($args | type) != "object" then + "Invalid arguments: expected a JSON object, got " + + ($args | type) + "." + else + ($schema.required // []) as $req | ($args | keys) as $present | (($schema.properties // {}) | keys) as $allowed + | (($schema.properties // {})) as $props | [ $req[] | . as $r | select(($present | index($r)) == null) ] as $missing | ( if ($schema.additionalProperties == false) then [ $present[] | . as $p | select(($allowed | index($p)) == null) ] else [] end ) as $unknown + | [ $present[] | . as $p + | ($props[$p].type // empty) as $t + | select($t != null and ($t | type) == "string") + | ($args[$p]) as $v + | select((type_ok($t; $v)) | not) + | {p: $p, expected: $t, actual: type_label($t; $v), v: $v} + ] as $invalid_type + | [ $present[] | . as $p + | ($props[$p].pattern // empty) as $pat + | select($pat != null) + | ($args[$p]) as $v + | select(($v | type) == "string") + | select(($v | test($pat)) | not) + | {p: $p, pattern: $pat, v: $v} + ] as $invalid_pattern + | [ $present[] | . as $p + | ($props[$p].items // empty) as $items + | select($items != null) + | ($args[$p]) as $v + | select(($v | type) == "array") + # Plain field access, not `// empty`: `.items` may declare only + # one of `type`/`enum`. A missing key yields `null` here (one + # output), so the other, present constraint still reaches the + # comprehension below. `// empty` on either would yield zero + # outputs when that key is absent, and an `as` binding with zero + # outputs runs its body zero times — silently discarding every + # element of this property, including violations of the + # constraint that *was* declared. + | ($items.type) as $it + | ($items.enum) as $ie + | ( $v | to_entries[] + | . as $entry + | ($entry.value) as $ev + | ($entry.key) as $idx + | if ($it != null and ($it | type) == "string" and (type_ok($it; $ev) | not)) then + {p: $p, index: $idx, issue: "type", expected: $it, actual: type_label($it; $ev), v: $ev} + elif ($ie != null and ($ie | index($ev)) == null) then + {p: $p, index: $idx, issue: "enum", enum: $ie, v: $ev} + else empty end + ) + ] as $invalid_items + | [ $present[] | . as $p + | ($props[$p].enum // empty) as $enum + | ($args[$p]) as $v + | select(($enum | index($v)) == null) + | {p: $p, v: $v, enum: $enum} + ] as $invalid_enum | if ($missing | length) > 0 then "Missing required parameter(s): " + ($missing | join(", ")) + "." elif ($unknown | length) > 0 then "Unknown parameter(s): " + ($unknown | join(", ")) + ". Allowed parameters: " + ($allowed | join(", ")) + "." + elif ($invalid_type | length) > 0 then + "Invalid type(s): " + ($invalid_type | map( + .p + " expected " + .expected + ", got " + .actual + + " (" + (.v | tojson) + ")" + ) | join("; ")) + "." + elif ($invalid_pattern | length) > 0 then + "Invalid value(s): " + ($invalid_pattern | map( + .p + "=" + (.v | tojson) + " does not match pattern " + .pattern + ) | join("; ")) + "." + elif ($invalid_items | length) > 0 then + "Invalid array item(s): " + ($invalid_items | map( + if .issue == "type" then + .p + "[" + (.index | tostring) + "] expected " + .expected + + ", got " + .actual + " (" + (.v | tojson) + ")" + else + .p + "[" + (.index | tostring) + "]=" + (.v | tojson) + + " (allowed: " + (.enum | join(", ")) + ")" + end + ) | join("; ")) + "." + elif ($invalid_enum | length) > 0 then + "Invalid value(s): " + ($invalid_enum | map( + .p + "=\"" + (.v | tostring) + "\" (allowed: " + (.enum | join(", ")) + ")" + ) | join("; ")) + "." else "" end - ' 2>/dev/null || true) + end + ' 2>/dev/null) || rc=$? + if [[ $rc -ne 0 ]]; then + printf '%s' "Cannot validate arguments for ${tool_name}: they could not be evaluated against its schema." + return 1 + fi if [[ -n "$message" ]]; then printf '%s' "$message" @@ -164,8 +289,11 @@ handle_tools_call() { local tool_name tool_name=$(echo "$params" | jq -r '.name // ""') + # `.arguments // {}` would substitute {} for a present `null` or `false`, + # because jq's `//` treats both as absent — the validator's non-object + # branch would then never see either. Only a genuinely absent key defaults. local arguments - arguments=$(echo "$params" | jq -c '.arguments // {}') + arguments=$(echo "$params" | jq -c 'if has("arguments") then .arguments else {} end') log "INFO" "Handling tools/call: $tool_name" diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..81474c4 --- /dev/null +++ b/renovate.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "enabledManagers": ["custom.regex"], + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/^\\.mcp-sdk\\.lock$/"], + "matchStrings": ["version=(?v\\d+\\.\\d+\\.\\d+)"], + "depNameTemplate": "shopwareLabs/bash-mcp-sdk", + "datasourceTemplate": "github-releases", + "versioningTemplate": "semver" + } + ] +} From e1ef9a3beb8c38a33abe522d74d154e44ae5747b Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Wed, 2 Sep 2026 23:31:08 +0200 Subject: [PATCH 2/3] ci: gate the vendored sdk copy against its lock file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .github/scripts/vendor-mcp-sdk.sh reads the pinned release from .mcp-sdk.lock and writes shopwareLabs/bash-mcp-sdk lib/mcpserver_core.sh to every path in this repository that carries a copy. With --check it compares instead of writing and exits non-zero on drift, which is what the CI job runs. Nothing enforced the lock before this: it recorded which release the file was supposed to come from, and a local edit to the vendored copy passed every gate. The lock and the vendored file have to move together, so a Renovate bump on its own fails the check until the refreshed file lands in the same PR. That is the intended coupling rather than a rough edge — the alternative is a lock claiming a release the tree does not contain. .mcp-sdk.lock joins the CI path filters, replacing templates/**, which matched nothing since the split from ai-coding-tools. Without it a lock-only change triggers no workflow run at all, and the gate it exists to trip never executes. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/vendor-mcp-sdk.sh | 151 ++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 7 +- AGENTS.md | 14 ++- 3 files changed, 168 insertions(+), 4 deletions(-) create mode 100755 .github/scripts/vendor-mcp-sdk.sh diff --git a/.github/scripts/vendor-mcp-sdk.sh b/.github/scripts/vendor-mcp-sdk.sh new file mode 100755 index 0000000..f1b8e8c --- /dev/null +++ b/.github/scripts/vendor-mcp-sdk.sh @@ -0,0 +1,151 @@ +#!/bin/bash +# Vendor the bash-mcp-sdk protocol handler into this repository +# ============================================================= +# Reads the pinned release from .mcp-sdk.lock and writes the SDK's +# lib/mcpserver_core.sh to every consuming path. +# +# vendor-mcp-sdk.sh re-vendor, overwriting each target +# vendor-mcp-sdk.sh --check compare only; non-zero when a copy has drifted + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")" + +LOCK_FILE="${REPO_ROOT}/.mcp-sdk.lock" +SDK_REPO="shopwareLabs/bash-mcp-sdk" +SDK_FILE="lib/mcpserver_core.sh" + +# Every path in this repository holding a copy of the SDK file. Add a row here +# when a second plugin starts consuming it. +VENDOR_TARGETS=( + "plugins/github-mcp/shared/mcpserver_core.sh" +) + +# Set once the download destination exists. Global rather than a local of +# main(), because the EXIT trap runs after main() has returned and its locals +# are gone — under `set -u` that reads as an unbound variable, not an empty one. +TMPFILE="" + +####################################### +# Remove the download destination, if one was created. +# Globals: +# TMPFILE +####################################### +cleanup() { + if [[ -n "$TMPFILE" ]]; then + rm -f -- "$TMPFILE" + fi +} +trap cleanup EXIT + +####################################### +# Read the pinned release tag from the lock file. +# Globals: +# LOCK_FILE +# Outputs: +# The tag (vX.Y.Z) on stdout; diagnostics on stderr. +# Returns: +# 0 on success, 1 when the lock file is missing or its version is malformed. +####################################### +read_pinned_version() { + if [[ ! -f "$LOCK_FILE" ]]; then + printf 'Lock file not found: %s\n' "$LOCK_FILE" >&2 + return 1 + fi + + local version + version=$(grep -m 1 -oE '^version=v[0-9]+\.[0-9]+\.[0-9]+$' "$LOCK_FILE" 2>/dev/null | cut -d= -f2) || true + + if [[ -z "$version" ]]; then + printf 'No version=vX.Y.Z line in %s\n' "$LOCK_FILE" >&2 + return 1 + fi + + printf '%s\n' "$version" +} + +####################################### +# Download the SDK file at a tag into a local path. +# Globals: +# SDK_REPO, SDK_FILE +# Arguments: +# Release tag, e.g. v1.0.0. +# Destination path. +# Outputs: +# Diagnostics on stderr. +# Returns: +# 0 on success, 1 when the download fails or returns something that is not +# the SDK file. +####################################### +download_sdk() { + local version="$1" destination="$2" + local url="https://raw.githubusercontent.com/${SDK_REPO}/${version}/${SDK_FILE}" + + if ! curl -fsSL --retry 3 --retry-delay 2 -o "$destination" -- "$url"; then + printf 'Download failed: %s\n' "$url" >&2 + return 1 + fi + + # A tag that exists but carries no such file, or a proxy error page, both + # arrive as a 200 with the wrong bytes. The SDK is a shell script. + if [[ ! -s "$destination" ]] || ! head -n 1 -- "$destination" | grep -q '^#!'; then + printf 'Downloaded file is not a shell script: %s\n' "$url" >&2 + return 1 + fi +} + +main() { + local check_only=false + if [[ "${1:-}" == "--check" ]]; then + check_only=true + elif [[ $# -gt 0 ]]; then + printf 'Usage: %s [--check]\n' "$(basename -- "$0")" >&2 + exit 2 + fi + + local version + version=$(read_pinned_version) || exit 1 + + TMPFILE=$(mktemp "${TMPDIR:-/tmp}/mcpserver_core.XXXXXX") + + download_sdk "$version" "$TMPFILE" || exit 1 + + printf '%s %s at %s\n' \ + "$([[ "$check_only" == true ]] && printf 'Checking' || printf 'Vendoring')" \ + "${SDK_REPO}/${SDK_FILE}" "$version" + + local drifted=0 target absolute + for target in "${VENDOR_TARGETS[@]}"; do + absolute="${REPO_ROOT}/${target}" + + if [[ "$check_only" == true ]]; then + if [[ ! -f "$absolute" ]]; then + printf ' MISSING %s\n' "$target" + drifted=$(( drifted + 1 )) + elif cmp -s -- "$TMPFILE" "$absolute"; then + printf ' ok %s\n' "$target" + else + printf ' DRIFTED %s\n' "$target" + drifted=$(( drifted + 1 )) + fi + continue + fi + + cp -- "$TMPFILE" "$absolute" + # No `--` here: BSD chmod has no end-of-options marker and reads it as + # a file name. Every target is a fixed path under REPO_ROOT. + chmod 755 "$absolute" + printf ' wrote %s\n' "$target" + done + + if [[ "$check_only" == true && $drifted -gt 0 ]]; then + printf '\nVendored copies out of date: %d (pinned release %s).\n' \ + "$drifted" "$version" >&2 + printf 'Run .github/scripts/vendor-mcp-sdk.sh to refresh them, and send\n' >&2 + printf 'protocol changes to the SDK repository rather than editing here.\n' >&2 + exit 1 + fi +} + +main "$@" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a18f5f..2ad77b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: paths: - 'plugins/**' - 'plugin-tests/**' - - 'templates/**' + - '.mcp-sdk.lock' - '.shellcheckrc' - '.github/workflows/ci.yml' - '.github/scripts/**' @@ -14,7 +14,7 @@ on: paths: - 'plugins/**' - 'plugin-tests/**' - - 'templates/**' + - '.mcp-sdk.lock' - '.shellcheckrc' - '.github/workflows/ci.yml' - '.github/scripts/**' @@ -46,6 +46,9 @@ jobs: \( -name '*.sh' -o -name '*.bats' -o -name '*.bash' \) \ -exec shellcheck --shell=bash --format=gcc {} + + - name: Vendored SDK matches the pinned release + run: .github/scripts/vendor-mcp-sdk.sh --check + - name: Setup BATS uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0 with: diff --git a/AGENTS.md b/AGENTS.md index 48eae78..f5b809c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,14 @@ the SDK repository and arrive as a lock bump plus a refreshed copy of the file. tests for its own surface (argument validation, logging); `plugin-tests/` covers this plugin's tools and schemas. +```bash +.github/scripts/vendor-mcp-sdk.sh # re-vendor at the pinned release +.github/scripts/vendor-mcp-sdk.sh --check # compare only; what CI runs +``` + +After Renovate bumps `.mcp-sdk.lock`, run the script without `--check` and commit the refreshed +file in the same PR — the lock and the vendored copy have to move together or CI fails. + ## Commit Messages Use conventional-commit format. The `commit-message-writer:writing-commit-messages` skill is @@ -172,8 +180,9 @@ codex plugin add github-mcp@github-agent-tools Tests live in `plugin-tests//` mirroring the plugin structure and load the shared helper at `plugin-tests/test_helper/common_setup.bash` (it resolves the repo root by walking up to `.bats/`). -CI (`.github/workflows/ci.yml`) runs ShellCheck over `plugins plugin-tests .github/scripts` and -BATS over `plugin-tests/`; a separate `validate.yml` checks the issue-template dropdowns. +CI (`.github/workflows/ci.yml`) runs ShellCheck over `plugins plugin-tests .github/scripts`, +`vendor-mcp-sdk.sh --check` for the vendored SDK copy, and BATS over `plugin-tests/`; a separate +`validate.yml` checks the issue-template dropdowns. ### Pre-release checklist @@ -183,6 +192,7 @@ BATS over `plugin-tests/`; a separate `validate.yml` checks the issue-template d - [ ] BATS green (`.bats/bats-core/bin/bats -r plugin-tests/`) - [ ] ShellCheck clean - [ ] Issue-template dropdowns up to date (`.github/scripts/validate-issue-templates.sh`) +- [ ] Vendored SDK matches its lock (`.github/scripts/vendor-mcp-sdk.sh --check`) - [ ] Docs updated (`plugins/github-mcp/README.md`, `REFERENCE.md`) ## Distribution From a45c7c1685ff4d3c5ebc643cc1285644b29cba08 Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Thu, 3 Sep 2026 00:33:28 +0200 Subject: [PATCH 3/3] fix(github-mcp): honor a boolean false for paginate and failed_only jq's // treats a JSON false as absent, so `.paginate // true` and `.failed_only // true` yielded true whenever a caller explicitly disabled them. Sending the string "false" worked around that until argument validation began enforcing the declared boolean type, which left no value a client could send to turn either option off. Both reads now use the has() idiom already established for `release_list.latest`. The vendoring script authenticated its download by shebang alone, so a payload swapped at the mutable v1.0.0 tag would have been vendored as trusted code and then certified by `--check`. `.mcp-sdk.lock` gains a sha256 line, `download_sdk` compares against it on both paths, and a Renovate version bump now fails the check until someone re-vendors. curl also gains connect and overall timeouts, since `--retry` only covers a transfer that completed and failed, not one that stalled. The changelog claimed enum validation was already applied before this change, that `project_view` checks its number through `_gh_validate_number`, and that union-typed identifiers are validated. The vendored SDK brings enum validation with it, `project_view` only checks that the number is non-empty, and v1.0.0 skips array-valued `type` entirely. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/vendor-mcp-sdk.sh | 84 +++++++++++++++++++-- .mcp-sdk.lock | 1 + plugin-tests/github-mcp/mcp_tool_gh.bats | 26 ++++++- plugin-tests/github-mcp/tool_schemas.bats | 18 +++++ plugins/github-mcp/AGENTS.md | 1 + plugins/github-mcp/CHANGELOG.md | 7 +- plugins/github-mcp/mcp-server-gh/lib/pr.sh | 4 +- plugins/github-mcp/mcp-server-gh/lib/run.sh | 4 +- 8 files changed, 131 insertions(+), 14 deletions(-) diff --git a/.github/scripts/vendor-mcp-sdk.sh b/.github/scripts/vendor-mcp-sdk.sh index f1b8e8c..d518496 100755 --- a/.github/scripts/vendor-mcp-sdk.sh +++ b/.github/scripts/vendor-mcp-sdk.sh @@ -65,6 +65,55 @@ read_pinned_version() { printf '%s\n' "$version" } +####################################### +# Read the pinned SDK file hash from the lock file. +# Globals: +# LOCK_FILE +# Outputs: +# The lowercase SHA-256 hash on stdout; diagnostics on stderr. +# Returns: +# 0 on success, 1 when the lock file is missing or its hash is malformed. +####################################### +read_pinned_sha256() { + if [[ ! -f "$LOCK_FILE" ]]; then + printf 'Lock file not found: %s\n' "$LOCK_FILE" >&2 + return 1 + fi + + local sha256 + sha256=$(grep -m 1 -oE '^sha256=[0-9a-f]{64}$' "$LOCK_FILE" 2>/dev/null | cut -d= -f2) || true + + if [[ -z "$sha256" ]]; then + printf 'No sha256=<64 lowercase hex characters> line in %s\n' "$LOCK_FILE" >&2 + return 1 + fi + + printf '%s\n' "$sha256" +} + +####################################### +# Calculate the SHA-256 hash of a file. +# Arguments: +# Path to the file to hash. +# Outputs: +# The lowercase SHA-256 hash on stdout; diagnostics on stderr. +# Returns: +# 0 on success, non-zero when hashing fails or no supported utility is available. +####################################### +sha256_file() { + local file + file="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -- "$file" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 -- "$file" | awk '{print $1}' + else + printf 'Cannot calculate SHA-256: neither sha256sum nor shasum is available.\n' >&2 + return 1 + fi +} + ####################################### # Download the SDK file at a tag into a local path. # Globals: @@ -72,17 +121,20 @@ read_pinned_version() { # Arguments: # Release tag, e.g. v1.0.0. # Destination path. +# Expected SHA-256 hash. # Outputs: # Diagnostics on stderr. # Returns: -# 0 on success, 1 when the download fails or returns something that is not -# the SDK file. +# 0 on success, 1 when downloading, file validation, or hash validation fails. ####################################### download_sdk() { - local version="$1" destination="$2" - local url="https://raw.githubusercontent.com/${SDK_REPO}/${version}/${SDK_FILE}" + local version destination expected_sha256 url actual_sha256 + version="$1" + destination="$2" + expected_sha256="$3" + url="https://raw.githubusercontent.com/${SDK_REPO}/${version}/${SDK_FILE}" - if ! curl -fsSL --retry 3 --retry-delay 2 -o "$destination" -- "$url"; then + if ! curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 10 --max-time 60 -o "$destination" -- "$url"; then printf 'Download failed: %s\n' "$url" >&2 return 1 fi @@ -93,6 +145,13 @@ download_sdk() { printf 'Downloaded file is not a shell script: %s\n' "$url" >&2 return 1 fi + + actual_sha256=$(sha256_file "$destination") || return 1 + if [[ "$actual_sha256" != "$expected_sha256" ]]; then + printf 'Downloaded file hash mismatch: expected %s, got %s: %s\n' \ + "$expected_sha256" "$actual_sha256" "$url" >&2 + return 1 + fi } main() { @@ -104,12 +163,13 @@ main() { exit 2 fi - local version + local version expected_sha256 version=$(read_pinned_version) || exit 1 + expected_sha256=$(read_pinned_sha256) || exit 1 TMPFILE=$(mktemp "${TMPDIR:-/tmp}/mcpserver_core.XXXXXX") - download_sdk "$version" "$TMPFILE" || exit 1 + download_sdk "$version" "$TMPFILE" "$expected_sha256" || exit 1 printf '%s %s at %s\n' \ "$([[ "$check_only" == true ]] && printf 'Checking' || printf 'Vendoring')" \ @@ -139,6 +199,16 @@ main() { printf ' wrote %s\n' "$target" done + if [[ "$check_only" != true ]]; then + local lock_tmpfile + lock_tmpfile=$(mktemp "${LOCK_FILE}.XXXXXX") + awk -v sha256="$expected_sha256" ' + /^sha256=/ { print "sha256=" sha256; next } + { print } + ' "$LOCK_FILE" > "$lock_tmpfile" + mv -- "$lock_tmpfile" "$LOCK_FILE" + fi + if [[ "$check_only" == true && $drifted -gt 0 ]]; then printf '\nVendored copies out of date: %d (pinned release %s).\n' \ "$drifted" "$version" >&2 diff --git a/.mcp-sdk.lock b/.mcp-sdk.lock index c7d8b25..44c9e08 100644 --- a/.mcp-sdk.lock +++ b/.mcp-sdk.lock @@ -1 +1,2 @@ version=v1.0.0 +sha256=8f683045f3ae724ce781fa40bcfe97bf2ee74f1b44aaf38afef38854e365ee90 diff --git a/plugin-tests/github-mcp/mcp_tool_gh.bats b/plugin-tests/github-mcp/mcp_tool_gh.bats index 62b4f53..a232412 100644 --- a/plugin-tests/github-mcp/mcp_tool_gh.bats +++ b/plugin-tests/github-mcp/mcp_tool_gh.bats @@ -1537,8 +1537,7 @@ diff --git a/src/Third.php b/src/Third.php echo "$*" > "${BATS_TEST_TMPDIR}/captured_cmd" echo '[]' } - # Note: must use string "false" — jq's // treats boolean false as null - run tool_pr_comments '{"number":"42","paginate":"false"}' + run tool_pr_comments '{"number":"42","paginate":false}' assert_success local captured_cmd captured_cmd=$(cat "${BATS_TEST_TMPDIR}/captured_cmd") @@ -1548,6 +1547,29 @@ diff --git a/src/Third.php b/src/Third.php } } +# ============================================================================= +# run_logs — tool-specific tests +# ============================================================================= + +@test "run_logs: failed_only false uses --log" { + gh() { + echo "$*" > "${BATS_TEST_TMPDIR}/captured_cmd" + echo 'logs' + } + run tool_run_logs '{"run_id":"42","failed_only":false}' + assert_success + local captured_cmd + captured_cmd=$(cat "${BATS_TEST_TMPDIR}/captured_cmd") + [[ "${captured_cmd}" == *"--log"* ]] || { + echo "Expected --log in command: ${captured_cmd}" + return 1 + } + [[ "${captured_cmd}" != *"--log-failed"* ]] || { + echo "Unexpected --log-failed in command: ${captured_cmd}" + return 1 + } +} + # ============================================================================= # pr_reviews — tool-specific tests # ============================================================================= diff --git a/plugin-tests/github-mcp/tool_schemas.bats b/plugin-tests/github-mcp/tool_schemas.bats index f60bc67..d53a6fc 100644 --- a/plugin-tests/github-mcp/tool_schemas.bats +++ b/plugin-tests/github-mcp/tool_schemas.bats @@ -104,6 +104,15 @@ load_validator_for() { assert_output "" } +@test "a read tool accepts a project number sent as an integer" { + load_validator_for "$READ_TOOLS" + + run validate_tool_arguments "project_view" '{"number": 12, "owner": "shopwareLabs"}' + + assert_success + assert_output "" +} + @test "a write tool accepts sub-issue numbers sent as strings" { load_validator_for "$WRITE_TOOLS" @@ -113,6 +122,15 @@ load_validator_for() { assert_output "" } +@test "a write tool accepts sub-issue numbers sent as integers" { + load_validator_for "$WRITE_TOOLS" + + run validate_tool_arguments "sub_issue_add" '{"issue_number": 339, "sub_issue_number": 340}' + + assert_success + assert_output "" +} + @test "a paging limit sent as a string is refused, naming the parameter" { # The counterpart to the identifier union: limit and max_lines are # integer-only on purpose, so the string form must fail rather than reach diff --git a/plugins/github-mcp/AGENTS.md b/plugins/github-mcp/AGENTS.md index 1343ad7..811813f 100644 --- a/plugins/github-mcp/AGENTS.md +++ b/plugins/github-mcp/AGENTS.md @@ -186,6 +186,7 @@ BATS tests for hook scripts and MCP tool functions are in `plugin-tests/github-m | `session_start.bats` | Shared SessionStart context and host-specific config discovery | | `write_server_gating.bats` | Write-server gating and active-host config priority | | `mcp_tool_gh.bats` | MCP tool shared parameters (_gh_validate_jq_filter, _gh_post_process, suppress_errors, fallback) | +| `tool_schemas.bats` | Shipped tool schemas against the vendored validator: identifier unions, required fields, defaults, and validation round-trips | The vendored SDK's own surface — argument validation and logging — is tested upstream in `shopwareLabs/bash-mcp-sdk`, not here. diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index 3ee924c..2ee48eb 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - `shared/mcpserver_core.sh` is now vendored from [shopwareLabs/bash-mcp-sdk](https://github.com/shopwareLabs/bash-mcp-sdk) `v1.0.0` instead of being maintained in this repository. The file is byte-identical to `lib/mcpserver_core.sh` at that tag; `.mcp-sdk.lock` records the release and `renovate.json` opens a PR when a new one is published. Protocol changes now go to the SDK repository and arrive here as a lock bump — a local edit is overwritten by the next update. -- Tool-call argument validation now enforces a declared `type`, a declared `pattern` on string values, and `items.type` / `items.enum` on every element of an array, alongside the `required`, `additionalProperties` and `enum` checks that were already applied. Diagnostics report the most fundamental defect first, in the order missing, unknown, type, pattern, items, enum. **Breaking for callers:** an argument of the wrong type that was previously accepted and passed through to `gh` now returns an `isError` result naming the parameter, its expected type and the value received. This affects the integer-typed paging and output parameters — `limit`, `max_lines`, `tail_lines`, `grep_context_before`, `grep_context_after`, `line_start`, `line_end` — where a quoted number such as `"20"` is now refused. Identifier parameters are unaffected: they declare `["integer", "string"]` and accept both forms. -- `project_view.number`, and `issue_number` / `sub_issue_number` on `sub_issue_add` and `sub_issue_remove`, now declare `["integer", "string"]`. They were the only numeric identifiers still declared integer-only, so the string form a client may send would have been refused by the stricter validation above. The tool functions already read both forms through `jq -r` and check the result with `_gh_validate_number`; only the schemas were narrower. +- Tool-call argument validation now enforces a declared `type`, a declared `pattern` on string values, `items.type` / `items.enum` on every element of an array, and `enum`. The `required` and `additionalProperties` checks were already applied. Diagnostics report the most fundamental defect first, in the order missing, unknown, type, pattern, items, enum. **Breaking for callers:** an argument of the wrong single type that was previously accepted and passed through to `gh` now returns an `isError` result naming the parameter, its expected type and the value received. This affects the integer-typed paging and output parameters — `limit`, `max_lines`, `tail_lines`, `grep_context_before`, `grep_context_after`, `line_start`, `line_end` — where a quoted number such as `"20"` is now refused. Identifier parameters declare `["integer", "string"]`; the pinned SDK `v1.0.0` does not enforce array-valued types, so it does not validate that their values belong to either member. +- `project_view.number`, and `issue_number` / `sub_issue_number` on `sub_issue_add` and `sub_issue_remove`, now declare `["integer", "string"]`. They were the only numeric identifiers still declared integer-only, so the string form a client may send would have been refused by the stricter validation above. The tool functions read both forms through `jq -r`; `sub_issue_add` and `sub_issue_remove` check the result with `_gh_validate_number`, while `project_view` checks that it is non-empty. Only the schemas were narrower. ### Fixed -- Argument validation no longer skips every check when `arguments` is present but is not a JSON object. A `null` or `false` value made the validator's jq pipeline fail, the failure was masked by a trailing `|| true`, and the call was dispatched with `required`, `additionalProperties` and `enum` all unenforced. A non-object is now rejected by name and type, and a validator that cannot evaluate its input reports that instead of returning success. +- Argument validation no longer skips every check when `arguments` is present but is not a JSON object. A `null` or `false` value made the validator's jq pipeline fail, the failure was masked by a trailing `|| true`, and the call was dispatched with `required` and `additionalProperties` unenforced. A non-object is now rejected by name and type, and a validator that cannot evaluate its input reports that instead of returning success. +- `pr_comments.paginate` and `run_logs.failed_only` now honor a boolean `false`. jq had treated `false` as an absent value and selected the `true` default, so both options could not be disabled. ### Removed - `plugin-tests/mcp-shared/mcp_argument_validation.bats` and `plugin-tests/github-mcp/extra_log_file.bats`. Both covered functions that belong to the vendored SDK (`validate_tool_arguments`, `handle_tools_call`, `log`, `_configure_extra_log_file`), which tests them in its own suite. `plugin-tests/github-mcp/tool_schemas.bats` replaces them with what only this repository can check: that the shipped tool schemas describe the calls clients make. diff --git a/plugins/github-mcp/mcp-server-gh/lib/pr.sh b/plugins/github-mcp/mcp-server-gh/lib/pr.sh index f21ee94..f3234fa 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/pr.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/pr.sh @@ -218,7 +218,9 @@ tool_pr_comments() { local number paginate jq_filter suppress_errors fallback max_lines number=$(echo "${args}" | jq -r '.number // empty') - paginate=$(echo "${args}" | jq -r '.paginate // true') + # jq's // treats false as a fallthrough, so '.paginate // true' would wrongly + # yield true when the caller passes false; use has() to read it verbatim. + paginate=$(echo "${args}" | jq -r 'if has("paginate") then .paginate else true end') jq_filter=$(echo "${args}" | jq -r '.jq_filter // empty') suppress_errors=$(echo "${args}" | jq -r '.suppress_errors // false') fallback=$(echo "${args}" | jq -r '.fallback // empty') diff --git a/plugins/github-mcp/mcp-server-gh/lib/run.sh b/plugins/github-mcp/mcp-server-gh/lib/run.sh index 8dec01b..5754302 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/run.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/run.sh @@ -115,7 +115,9 @@ tool_run_logs() { local grep_pattern grep_context_before grep_context_after grep_ignore_case grep_invert run_id=$(echo "${args}" | jq -r '.run_id // empty') repo=$(echo "${args}" | jq -r '.repo // empty') - failed_only=$(echo "${args}" | jq -r '.failed_only // true') + # jq's // treats false as a fallthrough, so '.failed_only // true' would wrongly + # yield true when the caller passes false; use has() to read it verbatim. + failed_only=$(echo "${args}" | jq -r 'if has("failed_only") then .failed_only else true end') max_lines=$(echo "${args}" | jq -r '.max_lines // empty') tail_lines=$(echo "${args}" | jq -r '.tail_lines // empty') suppress_errors=$(echo "${args}" | jq -r '.suppress_errors // false')