From 4cde7f654911858dc142de2a85f3f906828d18f8 Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Wed, 2 Sep 2026 19:01:45 +0200 Subject: [PATCH 1/6] feat(github-mcp): add issue_schema read tool Setting an issue's type or one of its field values needs the exact names GitHub expects, and the read server exposed none of them. The two endpoints that carry them, orgs/{org}/issue-types and orgs/{org}/issue-fields, were reachable only through the api_read escape hatch, as separate calls the caller then had to merge. issue_schema returns both collections as one document, including the options of every single-select field. The organization resolves from `org`, `owner`, a repository parameter, GH_DEFAULT_REPO, or the current clone. `type` and `field` each match one name case-insensitively and narrow only their own list. A name that matches nothing is an error naming how to list the valid ones, so a typo cannot read back as an organization that has no types. Types and fields stay side by side rather than nested. An organization can pin fields to a type, but that pinning only drives the web UI: an unpinned field can still be set on an issue of any type, so nesting fields under types would imply a constraint the API does not enforce. check-api-tools.sh routes both endpoints to the tool when block_api_tool_read is enabled. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- plugin-tests/github-mcp/check_api_tools.bats | 14 ++ .../github-mcp/read_tools_issue_schema.bats | 125 ++++++++++++ plugins/github-mcp/.claude-plugin/plugin.json | 2 +- plugins/github-mcp/AGENTS.md | 7 +- plugins/github-mcp/CHANGELOG.md | 4 + plugins/github-mcp/README.md | 8 +- plugins/github-mcp/REFERENCE.md | 20 +- .../hooks/prompts/mcp-tool-directives.md | 2 +- .../hooks/scripts/check-api-tools.sh | 5 + .../mcp-server-gh/lib/issue_schema.sh | 188 ++++++++++++++++++ .../github-mcp/mcp-server-gh/server-read.sh | 1 + .../github-mcp/mcp-server-gh/tools-read.json | 52 +++++ 13 files changed, 419 insertions(+), 11 deletions(-) create mode 100644 plugin-tests/github-mcp/read_tools_issue_schema.bats create mode 100644 plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh diff --git a/README.md b/README.md index 69b8b0c..525a289 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The read server (`gh-tooling`) is always active. The write server (`gh-tooling-w | Component | Description | |------------|------------------------------------------------------------------------------------------------------| -| 🔌 MCP | Two servers — `gh-tooling` (30 read tools) and `gh-tooling-write` (23 write tools, gated) | +| 🔌 MCP | Two servers — `gh-tooling` (31 read tools) and `gh-tooling-write` (23 write tools, gated) | | 🪝 Hooks | SessionStart directive + PreToolUse enforcement that redirects `gh` bash calls to the MCP tools | See [plugins/github-mcp/README.md](./plugins/github-mcp/README.md) for full configuration, the complete tool reference, and troubleshooting. See [plugins/github-mcp/REFERENCE.md](./plugins/github-mcp/REFERENCE.md) for per-tool parameter docs. diff --git a/plugin-tests/github-mcp/check_api_tools.bats b/plugin-tests/github-mcp/check_api_tools.bats index ba83a84..7c6f2f2 100644 --- a/plugin-tests/github-mcp/check_api_tools.bats +++ b/plugin-tests/github-mcp/check_api_tools.bats @@ -66,6 +66,20 @@ setup_read_blocking() { assert_output --partial "repo_tree" } +@test "read api: blocks orgs/N/issue-types → suggests issue_schema" { + setup_read_blocking + run_api_hook "$READ_TOOL" "orgs/shopware/issue-types" + assert_failure 2 + assert_output --partial "issue_schema" +} + +@test "read api: blocks orgs/N/issue-fields → suggests issue_schema" { + setup_read_blocking + run_api_hook "$READ_TOOL" "orgs/shopware/issue-fields" + assert_failure 2 + assert_output --partial "issue_schema" +} + @test "read api: allows unknown endpoint" { setup_read_blocking run_api_hook "$READ_TOOL" "repos/shopware/shopware/actions/runs/123/jobs" diff --git a/plugin-tests/github-mcp/read_tools_issue_schema.bats b/plugin-tests/github-mcp/read_tools_issue_schema.bats new file mode 100644 index 0000000..b0967d4 --- /dev/null +++ b/plugin-tests/github-mcp/read_tools_issue_schema.bats @@ -0,0 +1,125 @@ +#!/usr/bin/env bats +# bats file_tags=github-mcp,read-tools +# Tests for the issue_schema read tool +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +TYPES_JSON='[{"id":125714,"name":"Bug","description":"Something broke","color":"red","is_enabled":true},{"id":25328944,"name":"Improvement","description":"Better now","color":"green","is_enabled":true}]' +FIELDS_JSON='[{"id":8847,"name":"Priority","description":"How urgent","data_type":"single_select","visibility":"all","options":[{"id":12296,"name":"High","color":"red"},{"id":12298,"name":"Low","color":"green"}]},{"id":8848,"name":"Start date","description":"When work begins","data_type":"date","visibility":"organization_members_only"}]' + +setup() { + log() { :; } + GH_DEFAULT_REPO="shopware/shopware" + GH_TOOLING_CONFIG_FILE="" + source "${GH_LIB_DIR}/common.sh" + source "${GH_LIB_DIR}/issue_schema.sh" + + GH_ARGS_FILE="${BATS_TEST_TMPDIR}/gh_args" + + # Stub responds per endpoint: the tool makes two gh api calls per run. + gh() { + printf '%s\n' "$@" >> "${GH_ARGS_FILE}" + case "$*" in + *issue-types*) + [[ -n "${GH_STUB_TYPES_EXIT:-}" ]] && return "${GH_STUB_TYPES_EXIT}" + printf '%s\n' "${GH_STUB_TYPES}" + ;; + *issue-fields*) + [[ -n "${GH_STUB_FIELDS_EXIT:-}" ]] && return "${GH_STUB_FIELDS_EXIT}" + printf '%s\n' "${GH_STUB_FIELDS}" + ;; + *) + printf '%s\n' "${GH_STUB_REPO_VIEW:-}" + ;; + esac + return 0 + } + GH_STUB_TYPES="${TYPES_JSON}" + GH_STUB_FIELDS="${FIELDS_JSON}" + GH_STUB_TYPES_EXIT="" + GH_STUB_FIELDS_EXIT="" +} + +@test "issue_schema returns types and fields for the default repo owner" { + run tool_issue_schema '{}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.org')" "shopware" + assert_equal "$(printf '%s' "${output}" | jq -r '[.types[].name] | join(",")')" "Bug,Improvement" + assert_equal "$(printf '%s' "${output}" | jq -r '[.fields[].name] | join(",")')" "Priority,Start date" +} + +@test "issue_schema queries the organization endpoints" { + run tool_issue_schema '{"org": "shopware"}' + assert_success + run grep -x -- 'orgs/shopware/issue-types' "${GH_ARGS_FILE}" + assert_success + run grep -x -- 'orgs/shopware/issue-fields' "${GH_ARGS_FILE}" + assert_success +} + +@test "issue_schema keeps single-select options and omits them for other data types" { + run tool_issue_schema '{"field": "Priority"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '[.fields[0].options[].name] | join(",")')" "High,Low" + + run tool_issue_schema '{"field": "Start date"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.fields[0] | has("options")')" "false" +} + +@test "issue_schema type filter matches case-insensitively and narrows only types" { + run tool_issue_schema '{"type": "bug"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '[.types[].name] | join(",")')" "Bug" + assert_equal "$(printf '%s' "${output}" | jq -r '.fields | length')" "2" +} + +@test "issue_schema derives the org from a repo parameter" { + run tool_issue_schema '{"repo": "some-other-org/some-repo"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.org')" "some-other-org" +} + +@test "issue_schema errors on an unknown type instead of returning an empty list" { + run tool_issue_schema '{"type": "Nope"}' + assert_failure + assert_output --partial "issue type 'Nope' not found" +} + +@test "issue_schema errors on an unknown field instead of returning an empty list" { + run tool_issue_schema '{"field": "Nope"}' + assert_failure + assert_output --partial "issue field 'Nope' not found" +} + +@test "issue_schema propagates a failing types call" { + GH_STUB_TYPES_EXIT=1 + run tool_issue_schema '{"org": "cli"}' + assert_failure +} + +@test "issue_schema propagates a failing fields call" { + GH_STUB_FIELDS_EXIT=1 + run tool_issue_schema '{"org": "cli"}' + assert_failure +} + +@test "issue_schema returns the fallback when a call fails" { + GH_STUB_TYPES_EXIT=1 + run tool_issue_schema '{"org": "cli", "fallback": "no schema"}' + assert_success + assert_output "no schema" +} + +@test "issue_schema applies jq_filter to the merged document" { + run tool_issue_schema '{"jq_filter": "[.fields[].name]"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r 'join(",")')" "Priority,Start date" +} + +@test "issue_schema rejects an invalid jq_filter" { + run tool_issue_schema '{"jq_filter": "[.fields["}' + assert_failure + assert_output --partial "Invalid jq_filter" +} diff --git a/plugins/github-mcp/.claude-plugin/plugin.json b/plugins/github-mcp/.claude-plugin/plugin.json index 5d62403..d382e92 100644 --- a/plugins/github-mcp/.claude-plugin/plugin.json +++ b/plugins/github-mcp/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "github-mcp", "version": "3.5.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 30 tools and write server (opt-in) with 23 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.", + "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 23 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" }, diff --git a/plugins/github-mcp/AGENTS.md b/plugins/github-mcp/AGENTS.md index 429aa69..da0eec8 100644 --- a/plugins/github-mcp/AGENTS.md +++ b/plugins/github-mcp/AGENTS.md @@ -5,7 +5,7 @@ ``` plugins/github-mcp/ ├── README.md # User documentation (usage, configuration, troubleshooting) -├── REFERENCE.md # Full tool parameter docs and examples (30 read + 23 write tools) +├── REFERENCE.md # Full tool parameter docs and examples (31 read + 23 write tools) ├── AGENTS.md # LLM navigation guide (this file) ├── CHANGELOG.md # Version history │ @@ -32,7 +32,7 @@ plugins/github-mcp/ ├── server-write.sh # Write server entry point - gated by enable_write_server config ├── config-read.json # Read server metadata (name="gh-tooling") ├── config-write.json # Write server metadata (name="gh-tooling-write") - ├── tools-read.json # 30 read tools (PR, issue, CI, commit, search, repo, release, label, project, api_read) + ├── tools-read.json # 31 read tools (PR, issue, CI, commit, search, repo, release, label, project, api_read) ├── tools-write.json # 23 write tools (PR lifecycle, reviews, issues, labels, assignees, sub-issues, projects, api) ├── mcp-gh-tooling.schema.json # JSON Schema for .mcp-gh-tooling.json └── lib/ @@ -40,6 +40,7 @@ plugins/github-mcp/ ├── pr.sh # tool_pr_view/diff/list/checks/comments/reviews/files/commits() ├── pr_write.sh # tool_pr_create/edit/ready/merge/close/reopen() ├── issue.sh # tool_issue_view(), tool_issue_list() + ├── issue_schema.sh # tool_issue_schema() (org issue types + issue fields, name filters) ├── issue_write.sh # tool_issue_create/edit/close/reopen/comment() ├── review_write.sh # tool_pr_review_submit(), tool_pr_comment(), tool_pr_review_reply() ├── run.sh # tool_run_view(), tool_run_list(), tool_run_logs(), tool_workflow_jobs() @@ -60,7 +61,7 @@ plugins/github-mcp/ This plugin provides: - **Two MCP Servers** via `.mcp.json` in Claude Code and inline `mcpServers` in `.codex-plugin/plugin.json` in Codex: - - `gh-tooling` (read) - 30 read-only GitHub tools (PRs, issues, CI, commits, search, repo, releases, labels, projects, read-only API) + - `gh-tooling` (read) - 31 read-only GitHub tools (PRs, issues, CI, commits, search, repo, releases, labels, projects, read-only API) - `gh-tooling-write` (write) - 23 write tools (PR lifecycle, reviews, issues, labels, assignees, sub-issues, projects, full API). Gated by `enable_write_server` config flag. - **SessionStart Hook** via the shared `hooks/hooks.json`: - Assembles MCP tool directives dynamically from template with conditional write and label sections diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index 1fad637..582871f 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `issue_schema` read tool. Returns an organization's issue types and issue fields in one call, with the options of every single-select field, so an agent can look up the exact names before setting an issue's type or field values. The organization is resolved from `org`, `owner`, a repository parameter, the configured default repo, or the current clone. `type` and `field` each match one name case-insensitively and narrow only their own list; a name that matches nothing is an error rather than an empty list. Backed by `orgs/{org}/issue-types` and `orgs/{org}/issue-fields`. +- Dedicated-tool enforcement for `orgs/{org}/issue-types` and `orgs/{org}/issue-fields` in `check-api-tools.sh`, active when `block_api_tool_read` is enabled. + ### Changed - `shared/mcpserver_core.sh` is now vendored from [shopwareLabs/bash-mcp-sdk](https://github.com/shopwareLabs/bash-mcp-sdk) `v2.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, `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. A `type` declared as a list of alternatives is enforced the same way: identifier parameters declare `["integer", "string"]` and accept `339` and `"339"` alike, while a value of neither type is refused naming both — `number expected integer or string, got boolean`. diff --git a/plugins/github-mcp/README.md b/plugins/github-mcp/README.md index a0c1baf..c609e40 100644 --- a/plugins/github-mcp/README.md +++ b/plugins/github-mcp/README.md @@ -7,7 +7,7 @@ GitHub CLI tools via MCP (Model Context Protocol). Wraps the `gh` CLI for pull r ### Read Server (gh-tooling) - **PR inspection** via `pr_view`, `pr_diff`, `pr_list`, `pr_checks` - **PR review data** via `pr_comments`, `pr_reviews`, `pr_files`, `pr_commits` -- **Issue operations** via `issue_view`, `issue_list` +- **Issue operations** via `issue_view`, `issue_list`, `issue_schema` - **GitHub Actions CI** via `run_view`, `run_list`, `run_logs`, `workflow_jobs` - **Job-level CI debugging** via `job_view`, `job_logs`, `job_annotations` - **Commit PR lookup** via `commit_pulls` @@ -151,15 +151,15 @@ Configuration is loaded in the following priority order: ## Tools Reference -30 read tools + 23 write tools organized by category. See [REFERENCE.md](./REFERENCE.md) for full parameter docs and examples. +31 read tools + 23 write tools organized by category. See [REFERENCE.md](./REFERENCE.md) for full parameter docs and examples. -### Read Server (gh-tooling) -- 30 tools +### Read Server (gh-tooling) -- 31 tools | Category | Tools | |----------------|---------------------------------------------------------------------------------| | PR inspection | `pr_view`, `pr_diff`, `pr_list`, `pr_checks` | | PR review data | `pr_comments`, `pr_reviews`, `pr_files`, `pr_commits` | -| Issues | `issue_view`, `issue_list` | +| Issues | `issue_view`, `issue_list`, `issue_schema` | | CI runs | `run_view`, `run_list`, `run_logs`, `workflow_jobs` | | CI jobs | `job_view`, `job_logs`, `job_annotations` | | Commits | `commit_pulls` | diff --git a/plugins/github-mcp/REFERENCE.md b/plugins/github-mcp/REFERENCE.md index 435c70c..87ae057 100644 --- a/plugins/github-mcp/REFERENCE.md +++ b/plugins/github-mcp/REFERENCE.md @@ -2,7 +2,7 @@ ## Read Server (gh-tooling) -30 tools available via the `gh-tooling` MCP server. Requires `gh` CLI installed and authenticated. +31 tools available via the `gh-tooling` MCP server. Requires `gh` CLI installed and authenticated. ### Shared Tool Parameters @@ -152,6 +152,24 @@ List issues with filters. Use gh-tooling issue_list with search "TODO label:component/core" and limit 20 ``` +### `issue_schema` + +List an organization's issue types and issue fields, including each single-select field's options. +The organization comes from `org`, `owner`, a repository parameter, or the configured default repo. + +Types and fields are independent. GitHub lets an organization pin fields to a type, but that pinning +only drives the web UI: any organization field can be set on an issue of any type, so this tool +reports the two lists side by side instead of nesting fields under types. + +`type` and `field` match one name exactly, case-insensitively, and each narrows only its own list. A +name that matches nothing is an error rather than an empty list. + +``` +Use gh-tooling issue_schema with repo "shopware/shopware" +Use gh-tooling issue_schema with org "shopware" and type "Bug" +Use gh-tooling issue_schema with field "Priority" and jq_filter "[.fields[0].options[].name]" +``` + ### `run_view` View the status of a GitHub Actions workflow run. diff --git a/plugins/github-mcp/hooks/prompts/mcp-tool-directives.md b/plugins/github-mcp/hooks/prompts/mcp-tool-directives.md index 8c06d62..1c43135 100644 --- a/plugins/github-mcp/hooks/prompts/mcp-tool-directives.md +++ b/plugins/github-mcp/hooks/prompts/mcp-tool-directives.md @@ -8,7 +8,7 @@ Repository selection (PR / issue / search / commit / repo tools): pass `repo` (o ## Read (gh-tooling) PRs: pr_view, pr_diff, pr_list, pr_checks, pr_comments, pr_reviews, pr_files, pr_commits -Issues: issue_view, issue_list +Issues: issue_view, issue_list, issue_schema CI: run_view, run_list, run_logs, workflow_jobs, job_view, job_logs, job_annotations Commits: commit_pulls Search: search, search_code, search_repos, search_commits, search_discussions diff --git a/plugins/github-mcp/hooks/scripts/check-api-tools.sh b/plugins/github-mcp/hooks/scripts/check-api-tools.sh index 7d92d6f..9315368 100755 --- a/plugins/github-mcp/hooks/scripts/check-api-tools.sh +++ b/plugins/github-mcp/hooks/scripts/check-api-tools.sh @@ -107,6 +107,11 @@ if echo "$ENDPOINT" | grep -qE 'labels(\?|$)'; then block_tool "label_list" "Use label_list with optional repo and filter parameters." fi +# Organization issue types and issue fields +if echo "$ENDPOINT" | grep -qE 'orgs/[^/]+/issue-(types|fields)'; then + block_tool "issue_schema" "Use issue_schema with optional org and type/field name filters. It returns both collections in one call." +fi + fi # end GET-only read endpoint mapping # ============================================================================ diff --git a/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh b/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh new file mode 100644 index 0000000..27d3ae8 --- /dev/null +++ b/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# Issue type and issue field schema tools for gh-tooling MCP server +# Read: issue_schema + +####################################### +# Resolve the organization that owns issue types and issue fields. +# Priority: org > owner > repo-shaped args > GH_DEFAULT_REPO > git remote. +# Globals: +# GH_DEFAULT_REPO, _GH_OWNER +# Arguments: +# JSON args string. +# Outputs: +# Organization login on stdout, or an error message on stdout. +# Returns: +# 0 when an organization was resolved, 1 otherwise. +####################################### +_gh_resolve_issue_schema_org() { + local args="$1" + + local org owner + org=$(printf '%s\n' "${args}" | jq -r '.org // empty') + owner=$(printf '%s\n' "${args}" | jq -r '.owner // empty') + + if [[ -n "${org}" ]]; then + printf '%s\n' "${org}" + return 0 + fi + if [[ -n "${owner}" ]]; then + printf '%s\n' "${owner}" + return 0 + fi + + # Not run in a command substitution: the resolver reports through globals, + # which a subshell would discard. Its own error text goes to our stdout. + if ! _gh_resolve_owner_repo_optional "${args}"; then + return 1 + fi + if [[ -n "${_GH_OWNER}" ]]; then + printf '%s\n' "${_GH_OWNER}" + return 0 + fi + + local name_with_owner + name_with_owner=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) || true + if [[ -n "${name_with_owner}" ]]; then + printf '%s\n' "${name_with_owner%%/*}" + return 0 + fi + + printf '%s\n' "Error: org is required for issue_schema. Pass 'org', 'owner', or a repository ('repository', 'repo', or 'owner'+'repo'), or set 'repo' in .mcp-gh-tooling.json" + return 1 +} + +####################################### +# List an organization's issue types and issue fields as one JSON document. +# Types and fields are independent: GitHub pins fields to types for the web UI +# only, and any org field can be set on an issue of any type, so this tool +# reports the two lists side by side rather than nesting fields under types. +# Maps to: gh api orgs//issue-types and gh api orgs//issue-fields +# Arguments: +# JSON args string. +# Outputs: +# Merged JSON on stdout; gh's error text on stdout when a call fails. +# Returns: +# 0 on success, non-zero on validation, resolution, or gh failure. +####################################### +tool_issue_schema() { + local args="$1" + + local type field jq_filter max_lines suppress_errors fallback + type=$(printf '%s\n' "${args}" | jq -r '.type // empty') + field=$(printf '%s\n' "${args}" | jq -r '.field // empty') + jq_filter=$(printf '%s\n' "${args}" | jq -r '.jq_filter // empty') + max_lines=$(printf '%s\n' "${args}" | jq -r '.max_lines // empty') + suppress_errors=$(printf '%s\n' "${args}" | jq -r '.suppress_errors // false') + fallback=$(printf '%s\n' "${args}" | jq -r '.fallback // empty') + + _gh_validate_jq_filter "${jq_filter}" || return 1 + + local org + org=$(_gh_resolve_issue_schema_org "${args}") || { + [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } + printf '%s\n' "${org}" + return 1 + } + + local types_json fields_json + types_json=$(_gh_issue_schema_fetch "${org}" "issue-types" "${suppress_errors}") || { + [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } + printf '%s\n' "${types_json}" + return 1 + } + fields_json=$(_gh_issue_schema_fetch "${org}" "issue-fields" "${suppress_errors}") || { + [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } + printf '%s\n' "${fields_json}" + return 1 + } + + local merged + merged=$(jq -n \ + --arg org "${org}" \ + --arg type "${type}" \ + --arg field "${field}" \ + --argjson types "${types_json}" \ + --argjson fields "${fields_json}" ' + def matches($wanted): $wanted == "" or (.name | ascii_downcase) == ($wanted | ascii_downcase); + { + org: $org, + types: [$types[] | select(matches($type)) | { + id, name, description, color, is_enabled + }], + fields: [$fields[] | select(matches($field)) | { + id, name, description, data_type, visibility + } + (if has("options") then {options: [.options[] | {id, name, color}]} else {} end)] + }') || { + [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } + printf '%s\n' "Error: could not merge issue types and issue fields for '${org}'" + return 1 + } + + local unmatched + unmatched=$(_gh_issue_schema_unmatched "${merged}" "${type}" "${field}") + if [[ -n "${unmatched}" ]]; then + [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } + printf '%s\n' "${unmatched}" + return 1 + fi + + _gh_post_process "${merged}" "${jq_filter}" "" 0 0 false false "${max_lines}" "" || return $? +} + +####################################### +# Fetch one organization-level issue schema collection. +# Arguments: +# $1 organization login, $2 endpoint segment, $3 suppress_errors flag. +# Outputs: +# The endpoint's JSON array on stdout, or gh's error text on stdout. +# Returns: +# gh's exit status. +####################################### +_gh_issue_schema_fetch() { + local org="$1" endpoint="$2" suppress_errors="$3" + + local -a cmd=("gh" "api" "orgs/${org}/${endpoint}") + + log "INFO" "issue_schema: ${cmd[*]}" + local __raw __exit=0 + if [[ "${suppress_errors}" == "true" ]]; then + __raw=$("${cmd[@]}" 2>/dev/null) || __exit=$? + else + __raw=$("${cmd[@]}" 2>&1) || __exit=$? + fi + if [[ ${__exit} -ne 0 ]]; then + printf '%s\n' "${__raw}" + return ${__exit} + fi + printf '%s\n' "${__raw}" +} + +####################################### +# Report filters that matched nothing, so an empty list never reads as an +# organization that simply has no types or fields. +# Arguments: +# $1 merged JSON, $2 requested type name, $3 requested field name. +# Outputs: +# An error message on stdout when a requested name is absent, nothing +# otherwise. +####################################### +_gh_issue_schema_unmatched() { + local merged="$1" type="$2" field="$3" + + if [[ -n "${type}" ]]; then + local type_count + type_count=$(printf '%s\n' "${merged}" | jq '.types | length') + if [[ "${type_count}" -eq 0 ]]; then + printf '%s\n' "Error: issue type '${type}' not found. Call issue_schema without 'type' to list the available types." + return 0 + fi + fi + + if [[ -n "${field}" ]]; then + local field_count + field_count=$(printf '%s\n' "${merged}" | jq '.fields | length') + if [[ "${field_count}" -eq 0 ]]; then + printf '%s\n' "Error: issue field '${field}' not found. Call issue_schema without 'field' to list the available fields." + fi + fi +} diff --git a/plugins/github-mcp/mcp-server-gh/server-read.sh b/plugins/github-mcp/mcp-server-gh/server-read.sh index 309d23f..d1c63fc 100755 --- a/plugins/github-mcp/mcp-server-gh/server-read.sh +++ b/plugins/github-mcp/mcp-server-gh/server-read.sh @@ -91,6 +91,7 @@ export GH_DEFAULT_REPO GH_TOOLING_CONFIG_FILE source "${SCRIPT_DIR}/lib/common.sh" source "${SCRIPT_DIR}/lib/pr.sh" source "${SCRIPT_DIR}/lib/issue.sh" +source "${SCRIPT_DIR}/lib/issue_schema.sh" source "${SCRIPT_DIR}/lib/run.sh" source "${SCRIPT_DIR}/lib/job.sh" source "${SCRIPT_DIR}/lib/commit.sh" diff --git a/plugins/github-mcp/mcp-server-gh/tools-read.json b/plugins/github-mcp/mcp-server-gh/tools-read.json index fa87a88..b073b5e 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-read.json +++ b/plugins/github-mcp/mcp-server-gh/tools-read.json @@ -1821,6 +1821,58 @@ }, "additionalProperties": false } + }, + { + "name": "issue_schema", + "description": "List the issue types and issue fields available for an organization, including each single-select field's options. Use before setting an issue type or a field value to discover the exact names the write tools expect. Types and fields are independent: any organization field can be set on an issue of any type.", + "inputSchema": { + "type": "object", + "properties": { + "org": { + "type": "string", + "description": "Organization login. Defaults to the owner of the resolved repository." + }, + "owner": { + "type": "string", + "description": "Repository owner, used as the organization when 'org' is not set." + }, + "repo": { + "type": "string", + "description": "GitHub repository in 'owner/repo' format, used to derive the organization." + }, + "repository": { + "type": "string", + "description": "GitHub repository in 'owner/repo' format. Alias of `repo`." + }, + "type": { + "type": "string", + "description": "Return only the issue type with this exact name (case-insensitive). Errors when no type matches." + }, + "field": { + "type": "string", + "description": "Return only the issue field with this exact name (case-insensitive). Errors when no field matches." + }, + "jq_filter": { + "type": "string", + "description": "jq expression to filter/transform the JSON output." + }, + "max_lines": { + "type": "integer", + "description": "Return only the first N lines of output.", + "minimum": 1 + }, + "suppress_errors": { + "type": "boolean", + "description": "Discard stderr output.", + "default": false + }, + "fallback": { + "type": "string", + "description": "Text to return if the gh command fails." + } + }, + "additionalProperties": false + } } ] } From 020e7565ccf09bda1c63349acd4a47c19db047ee Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Wed, 2 Sep 2026 19:26:38 +0200 Subject: [PATCH 2/6] feat(github-mcp): add issue_type_set and issue_field_set write tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tools take names rather than IDs and resolve them against the organization's schema before writing, so a wrong name fails with the valid ones listed. This matters most for fields: GitHub reports an unknown field ID as "option with name x does not exist", blaming the value instead of the field. Values are checked against their field's data type as well — option name, YYYY-MM-DD, number, string — and every rejected entry is reported in one message rather than one per round trip. issue_field_set sends PUT, which the API treats as replace-all, so the values object it takes is the issue's complete set: a field left out is cleared and {} clears them all. That makes a separate clear tool unnecessary, and it makes changing one field a read-then-write — the tool description, the SessionStart prompt, and REFERENCE.md all say so, because the API shape gives no way to enforce it. Reads return option IDs while writes take option names, so both tools take names on both sides and never hand back a value that cannot be passed in again. _gh_resolve_org moves from issue_schema.sh to common.sh so both servers can share it. Sourcing the read library into the write server would have exposed issue_schema there, since dispatch resolves any tool_ function that exists whether or not it is advertised. check-api-tools.sh routes issues/{n}/issue-field-values and PATCH repos/{owner}/{repo}/issues/{n} to the new tools when block_api_tool_write is enabled, and the blocked `gh issue edit` message now names issue_type_set. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- plugin-tests/github-mcp/check_api_tools.bats | 21 ++ .../github-mcp/write_tools_issue_schema.bats | 165 +++++++++++ plugins/github-mcp/AGENTS.md | 7 +- plugins/github-mcp/CHANGELOG.md | 3 + plugins/github-mcp/README.md | 5 +- plugins/github-mcp/REFERENCE.md | 42 ++- .../hooks/prompts/write-operations-enabled.md | 1 + .../hooks/scripts/check-api-tools.sh | 10 + .../hooks/scripts/check-gh-tools.sh | 2 +- .../github-mcp/mcp-server-gh/lib/common.sh | 49 ++++ .../mcp-server-gh/lib/issue_schema.sh | 51 +--- .../mcp-server-gh/lib/issue_schema_write.sh | 274 ++++++++++++++++++ .../github-mcp/mcp-server-gh/server-write.sh | 1 + .../github-mcp/mcp-server-gh/tools-write.json | 32 ++ 15 files changed, 607 insertions(+), 58 deletions(-) create mode 100644 plugin-tests/github-mcp/write_tools_issue_schema.bats create mode 100644 plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh diff --git a/README.md b/README.md index 525a289..ce25a41 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The read server (`gh-tooling`) is always active. The write server (`gh-tooling-w | Component | Description | |------------|------------------------------------------------------------------------------------------------------| -| 🔌 MCP | Two servers — `gh-tooling` (31 read tools) and `gh-tooling-write` (23 write tools, gated) | +| 🔌 MCP | Two servers — `gh-tooling` (31 read tools) and `gh-tooling-write` (25 write tools, gated) | | 🪝 Hooks | SessionStart directive + PreToolUse enforcement that redirects `gh` bash calls to the MCP tools | See [plugins/github-mcp/README.md](./plugins/github-mcp/README.md) for full configuration, the complete tool reference, and troubleshooting. See [plugins/github-mcp/REFERENCE.md](./plugins/github-mcp/REFERENCE.md) for per-tool parameter docs. diff --git a/plugin-tests/github-mcp/check_api_tools.bats b/plugin-tests/github-mcp/check_api_tools.bats index 7c6f2f2..604fef3 100644 --- a/plugin-tests/github-mcp/check_api_tools.bats +++ b/plugin-tests/github-mcp/check_api_tools.bats @@ -147,6 +147,27 @@ setup_write_blocking() { assert_output --partial "issue_comment" } +@test "write api: blocks PUT issues/N/issue-field-values → suggests issue_field_set" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/issues/123/issue-field-values" "PUT" + assert_failure 2 + assert_output --partial "issue_field_set" +} + +@test "write api: blocks DELETE issues/N/issue-field-values/M → suggests issue_field_set" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/issues/123/issue-field-values/8847" "DELETE" + assert_failure 2 + assert_output --partial "issue_field_set" +} + +@test "write api: blocks PATCH issues/N → suggests issue_edit or issue_type_set" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/issues/123" "PATCH" + assert_failure 2 + assert_output --partial "issue_type_set" +} + @test "write api: blocks POST pulls/N/reviews → suggests pr_review_submit" { setup_write_blocking run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/pulls/123/reviews" "POST" diff --git a/plugin-tests/github-mcp/write_tools_issue_schema.bats b/plugin-tests/github-mcp/write_tools_issue_schema.bats new file mode 100644 index 0000000..6cade69 --- /dev/null +++ b/plugin-tests/github-mcp/write_tools_issue_schema.bats @@ -0,0 +1,165 @@ +#!/usr/bin/env bats +# bats file_tags=github-mcp,write-tools +# Tests for the issue_type_set and issue_field_set write tools +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +TYPES_JSON='[{"id":125714,"name":"Bug"},{"id":25328944,"name":"Improvement"}]' +FIELDS_JSON='[{"id":8847,"name":"Priority","data_type":"single_select","options":[{"id":12296,"name":"High"},{"id":12298,"name":"Low"}]},{"id":8848,"name":"Start date","data_type":"date"},{"id":8851,"name":"Points","data_type":"number"},{"id":8852,"name":"Owner","data_type":"text"}]' + +setup() { + log() { :; } + GH_DEFAULT_REPO="shopware/shopware" + GH_TOOLING_CONFIG_FILE="" + source "${GH_LIB_DIR}/common.sh" + source "${GH_LIB_DIR}/issue_schema_write.sh" + + GH_ARGS_FILE="${BATS_TEST_TMPDIR}/gh_args" + GH_BODY_FILE="${BATS_TEST_TMPDIR}/gh_body" + + # Stub answers the org lookups from fixtures and records the write request. + gh() { + case "$*" in + *orgs/*/issue-types*) printf '%s\n' "${TYPES_JSON}"; return 0 ;; + *orgs/*/issue-fields*) printf '%s\n' "${FIELDS_JSON}"; return 0 ;; + esac + printf '%s\n' "$@" > "${GH_ARGS_FILE}" + cat > "${GH_BODY_FILE}" + [[ -n "${GH_STUB_EXIT:-}" ]] && return "${GH_STUB_EXIT}" + printf '%s\n' "${GH_STUB_OUTPUT}" + return 0 + } + GH_STUB_OUTPUT='{"number":19952,"type":{"name":"Bug"}}' + GH_STUB_EXIT="" +} + +body() { jq -c "$1" "${GH_BODY_FILE}"; } + +# ============================================================================ +# issue_type_set +# ============================================================================ + +@test "issue_type_set PATCHes the issue with the canonical type name" { + run tool_issue_type_set '{"number": 19952, "type": "bug"}' + assert_success + run grep -x -- 'PATCH' "${GH_ARGS_FILE}" + assert_success + run grep -x -- 'repos/shopware/shopware/issues/19952' "${GH_ARGS_FILE}" + assert_success + assert_equal "$(body '.type')" '"Bug"' +} + +@test "issue_type_set sends a null type to clear it" { + GH_STUB_OUTPUT='{"number":19952,"type":null}' + run tool_issue_type_set '{"number": 19952, "type": null}' + assert_success + assert_equal "$(body '.type')" 'null' + assert_equal "$(printf '%s' "${output}" | jq -r '.type')" 'null' +} + +@test "issue_type_set rejects an unknown type before calling the API" { + run tool_issue_type_set '{"number": 19952, "type": "Bogus"}' + assert_failure + assert_output --partial "Available types: Bug, Improvement" + [ ! -f "${GH_ARGS_FILE}" ] +} + +@test "issue_type_set requires the type key" { + run tool_issue_type_set '{"number": 19952}' + assert_failure + assert_output --partial "type is required" +} + +@test "issue_type_set requires a number" { + run tool_issue_type_set '{"type": "Bug"}' + assert_failure + assert_output --partial "number is required" +} + +@test "issue_type_set returns the fallback when the API call fails" { + GH_STUB_EXIT=1 + run tool_issue_type_set '{"number": 19952, "type": "Bug", "fallback": "unchanged"}' + assert_success + assert_output "unchanged" +} + +# ============================================================================ +# issue_field_set +# ============================================================================ + +@test "issue_field_set PUTs the whole set with resolved field ids" { + GH_STUB_OUTPUT='[{"issue_field_name":"Priority","single_select_option":{"name":"High"}}]' + run tool_issue_field_set '{"number": 19952, "values": {"Priority": "high"}}' + assert_success + run grep -x -- 'PUT' "${GH_ARGS_FILE}" + assert_success + run grep -x -- 'repos/shopware/shopware/issues/19952/issue-field-values' "${GH_ARGS_FILE}" + assert_success + assert_equal "$(body '.issue_field_values')" '[{"field_id":8847,"value":"High"}]' +} + +@test "issue_field_set sends an empty array when values is empty" { + GH_STUB_OUTPUT='[]' + run tool_issue_field_set '{"number": 19952, "values": {}}' + assert_success + assert_equal "$(body '.issue_field_values')" '[]' +} + +@test "issue_field_set passes date, number, and text values through unchanged" { + GH_STUB_OUTPUT='[]' + run tool_issue_field_set '{"number": 19952, "values": {"Start date": "2026-09-30", "Points": 5, "Owner": "core"}}' + assert_success + assert_equal "$(body '[.issue_field_values[].value]')" '["2026-09-30",5,"core"]' +} + +@test "issue_field_set rejects an unknown field before calling the API" { + run tool_issue_field_set '{"number": 19952, "values": {"Prioriti": "High"}}' + assert_failure + assert_output --partial "Available fields: Priority, Start date, Points, Owner" + [ ! -f "${GH_ARGS_FILE}" ] +} + +@test "issue_field_set rejects an unknown single-select option" { + run tool_issue_field_set '{"number": 19952, "values": {"Priority": "Urgent"}}' + assert_failure + assert_output --partial "Available options: High, Low" +} + +@test "issue_field_set rejects a malformed date" { + run tool_issue_field_set '{"number": 19952, "values": {"Start date": "30.09.2026"}}' + assert_failure + assert_output --partial "takes a date as YYYY-MM-DD" +} + +@test "issue_field_set rejects a non-numeric value for a number field" { + run tool_issue_field_set '{"number": 19952, "values": {"Points": "five"}}' + assert_failure + assert_output --partial "takes a number" +} + +@test "issue_field_set reports every rejected entry at once" { + run tool_issue_field_set '{"number": 19952, "values": {"Prioriti": "High", "Points": "five"}}' + assert_failure + assert_output --partial "Prioriti" + assert_output --partial "takes a number" +} + +@test "issue_field_set requires the values key" { + run tool_issue_field_set '{"number": 19952}' + assert_failure + assert_output --partial "values is required" +} + +@test "issue_field_set requires a number" { + run tool_issue_field_set '{"values": {}}' + assert_failure + assert_output --partial "number is required" +} + +@test "issue_field_set returns the fallback when the API call fails" { + GH_STUB_EXIT=1 + run tool_issue_field_set '{"number": 19952, "values": {}, "fallback": "unchanged"}' + assert_success + assert_output "unchanged" +} diff --git a/plugins/github-mcp/AGENTS.md b/plugins/github-mcp/AGENTS.md index da0eec8..28ddb22 100644 --- a/plugins/github-mcp/AGENTS.md +++ b/plugins/github-mcp/AGENTS.md @@ -5,7 +5,7 @@ ``` plugins/github-mcp/ ├── README.md # User documentation (usage, configuration, troubleshooting) -├── REFERENCE.md # Full tool parameter docs and examples (31 read + 23 write tools) +├── REFERENCE.md # Full tool parameter docs and examples (31 read + 25 write tools) ├── AGENTS.md # LLM navigation guide (this file) ├── CHANGELOG.md # Version history │ @@ -33,7 +33,7 @@ plugins/github-mcp/ ├── config-read.json # Read server metadata (name="gh-tooling") ├── config-write.json # Write server metadata (name="gh-tooling-write") ├── tools-read.json # 31 read tools (PR, issue, CI, commit, search, repo, release, label, project, api_read) - ├── tools-write.json # 23 write tools (PR lifecycle, reviews, issues, labels, assignees, sub-issues, projects, api) + ├── tools-write.json # 25 write tools (PR lifecycle, reviews, issues, issue types/fields, labels, assignees, sub-issues, projects, api) ├── mcp-gh-tooling.schema.json # JSON Schema for .mcp-gh-tooling.json └── lib/ ├── common.sh # _gh_validate_number/repo/sha(), _gh_resolve_repo(), _gh_validate_jq_filter(), _gh_post_process(), _gh_parse_github_url(), _gh_validate_path(), _gh_download_file(), _gh_resolve_owner_repo() @@ -42,6 +42,7 @@ plugins/github-mcp/ ├── issue.sh # tool_issue_view(), tool_issue_list() ├── issue_schema.sh # tool_issue_schema() (org issue types + issue fields, name filters) ├── issue_write.sh # tool_issue_create/edit/close/reopen/comment() + ├── issue_schema_write.sh # tool_issue_type_set(), tool_issue_field_set() (name-to-ID resolution, PUT replace) ├── review_write.sh # tool_pr_review_submit(), tool_pr_comment(), tool_pr_review_reply() ├── run.sh # tool_run_view(), tool_run_list(), tool_run_logs(), tool_workflow_jobs() ├── job.sh # tool_job_view(), tool_job_logs(), tool_job_annotations() @@ -62,7 +63,7 @@ This plugin provides: - **Two MCP Servers** via `.mcp.json` in Claude Code and inline `mcpServers` in `.codex-plugin/plugin.json` in Codex: - `gh-tooling` (read) - 31 read-only GitHub tools (PRs, issues, CI, commits, search, repo, releases, labels, projects, read-only API) - - `gh-tooling-write` (write) - 23 write tools (PR lifecycle, reviews, issues, labels, assignees, sub-issues, projects, full API). Gated by `enable_write_server` config flag. + - `gh-tooling-write` (write) - 25 write tools (PR lifecycle, reviews, issues, issue types/fields, labels, assignees, sub-issues, projects, full API). Gated by `enable_write_server` config flag. - **SessionStart Hook** via the shared `hooks/hooks.json`: - Assembles MCP tool directives dynamically from template with conditional write and label sections - Prompt template maintained in `hooks/prompts/mcp-tool-directives.md` diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index 582871f..081ae92 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `issue_schema` read tool. Returns an organization's issue types and issue fields in one call, with the options of every single-select field, so an agent can look up the exact names before setting an issue's type or field values. The organization is resolved from `org`, `owner`, a repository parameter, the configured default repo, or the current clone. `type` and `field` each match one name case-insensitively and narrow only their own list; a name that matches nothing is an error rather than an empty list. Backed by `orgs/{org}/issue-types` and `orgs/{org}/issue-fields`. - Dedicated-tool enforcement for `orgs/{org}/issue-types` and `orgs/{org}/issue-fields` in `check-api-tools.sh`, active when `block_api_tool_read` is enabled. +- `issue_type_set` write tool. Sets an issue's type by name, or clears it with `null`. The name is matched case-insensitively against the organization's types and sent in its canonical spelling; an unknown name fails before the write, listing the available types. +- `issue_field_set` write tool. Replaces an issue's field values with the given `values` object, keyed by field name — a field left out is cleared, and `{}` clears them all. Field and option names resolve to IDs against the organization's schema, and each value is checked against its field's data type (option name, `YYYY-MM-DD`, number, string) before the request, because GitHub reports an unknown field ID as a wrong option name. +- Dedicated-tool enforcement for `issues/{n}/issue-field-values` and `PATCH repos/{owner}/{repo}/issues/{n}` in `check-api-tools.sh`, active when `block_api_tool_write` is enabled. ### Changed - `shared/mcpserver_core.sh` is now vendored from [shopwareLabs/bash-mcp-sdk](https://github.com/shopwareLabs/bash-mcp-sdk) `v2.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. diff --git a/plugins/github-mcp/README.md b/plugins/github-mcp/README.md index c609e40..a966b81 100644 --- a/plugins/github-mcp/README.md +++ b/plugins/github-mcp/README.md @@ -151,7 +151,7 @@ Configuration is loaded in the following priority order: ## Tools Reference -31 read tools + 23 write tools organized by category. See [REFERENCE.md](./REFERENCE.md) for full parameter docs and examples. +31 read tools + 25 write tools organized by category. See [REFERENCE.md](./REFERENCE.md) for full parameter docs and examples. ### Read Server (gh-tooling) -- 31 tools @@ -170,13 +170,14 @@ Configuration is loaded in the following priority order: | Projects | `project_list`, `project_view` | | Raw API | `api_read` (GET only) | -### Write Server (gh-tooling-write) -- 23 tools +### Write Server (gh-tooling-write) -- 25 tools | Category | Tools | |--------------|------------------------------------------------------------------------------| | PR lifecycle | `pr_create`, `pr_edit`, `pr_ready`, `pr_merge`, `pr_close`, `pr_reopen` | | Reviews | `pr_review_submit`, `pr_comment`, `pr_review_reply` | | Issues | `issue_create`, `issue_edit`, `issue_close`, `issue_reopen`, `issue_comment` | +| Issue schema | `issue_type_set`, `issue_field_set` | | Labels | `label_add`, `label_remove` | | Assignees | `assignee_add`, `assignee_remove` | | Sub-issues | `sub_issue_add`, `sub_issue_remove` | diff --git a/plugins/github-mcp/REFERENCE.md b/plugins/github-mcp/REFERENCE.md index 87ae057..10e48e9 100644 --- a/plugins/github-mcp/REFERENCE.md +++ b/plugins/github-mcp/REFERENCE.md @@ -532,7 +532,7 @@ Use gh-tooling api_read with endpoint "search/issues" and jq_filter ".items[] | ## Write Server (gh-tooling-write) -23 tools available via the `gh-tooling-write` MCP server. Requires `enable_write_server: true` in `.mcp-gh-tooling.json`. +25 tools available via the `gh-tooling-write` MCP server. Requires `enable_write_server: true` in `.mcp-gh-tooling.json`. ### Shared Tool Parameters @@ -772,6 +772,46 @@ Use gh-tooling-write issue_comment with number 8498 and body "This has been fixe - `body` (string, required): Comment text to post. - `repo` (string, optional): Repository in `owner/repo` format. +### Issue Type and Field Write Tools + +Both tools take names, not IDs, and resolve them against the organization's schema before calling the +API, so an unknown name fails with the valid ones listed. Use `issue_schema` to see what is available. +Issue types and issue fields apply to issues only, not pull requests. + +#### `issue_type_set` + +Set or clear an issue's type. The name replaces whatever the issue carried before, and `null` clears +it, so repeating the same call leaves the issue in the same state. + +``` +Use gh-tooling-write issue_type_set with number 19952 and type "Bug" +Use gh-tooling-write issue_type_set with number 19952 and type null +``` + +**Parameters:** +- `number` (integer, required): Issue number. +- `type` (string or null, required): Issue type name, matched case-insensitively, or `null` to clear. +- `repo` (string, optional): Repository in `owner/repo` format. + +#### `issue_field_set` + +Replace an issue's field values. The `values` object becomes the issue's **complete** set: a field +left out is cleared, and `{}` clears them all. To change one field without dropping the others, read +the current values with `issue_view` first and pass them back alongside the change. + +Values are typed by the field: a single-select takes an option name, a date takes `YYYY-MM-DD`, a +number takes a number, and a text field takes a string. + +``` +Use gh-tooling-write issue_field_set with number 19952 and values {"Priority": "High", "Effort": "Low"} +Use gh-tooling-write issue_field_set with number 19952 and values {} +``` + +**Parameters:** +- `number` (integer, required): Issue number. +- `values` (object, required): Complete set of field values keyed by field name. `{}` clears every value. +- `repo` (string, optional): Repository in `owner/repo` format. + ### Label Write Tools #### `label_add` diff --git a/plugins/github-mcp/hooks/prompts/write-operations-enabled.md b/plugins/github-mcp/hooks/prompts/write-operations-enabled.md index a9bb80b..5a616bc 100644 --- a/plugins/github-mcp/hooks/prompts/write-operations-enabled.md +++ b/plugins/github-mcp/hooks/prompts/write-operations-enabled.md @@ -2,6 +2,7 @@ PRs: pr_create, pr_edit, pr_ready, pr_merge, pr_close, pr_reopen Reviews: pr_review_submit, pr_comment, pr_review_reply Issues: issue_create, issue_edit, issue_close, issue_reopen, issue_comment +Issue type and fields: issue_type_set, issue_field_set (issue_field_set replaces the issue's whole set of field values; read the current ones first) Labels: label_add, label_remove Assignees: assignee_add, assignee_remove Sub-issues: sub_issue_add, sub_issue_remove diff --git a/plugins/github-mcp/hooks/scripts/check-api-tools.sh b/plugins/github-mcp/hooks/scripts/check-api-tools.sh index 9315368..b8629d7 100755 --- a/plugins/github-mcp/hooks/scripts/check-api-tools.sh +++ b/plugins/github-mcp/hooks/scripts/check-api-tools.sh @@ -141,6 +141,16 @@ if [[ "$IS_WRITE" == "true" ]]; then block_tool "issue_comment" "Use issue_comment with number and body." fi + # Issue field values (POST/PUT/DELETE) + if echo "$ENDPOINT" | grep -qE 'issues/[0-9]+/issue-field-values'; then + block_tool "issue_field_set" "Use issue_field_set with number and a values object keyed by field name. It replaces the issue's whole set of field values." + fi + + # Issue metadata, including the issue type (PATCH) + if [[ "$METHOD" == "PATCH" ]] && echo "$ENDPOINT" | grep -qE 'repos/[^/]+/[^/]+/issues/[0-9]+$'; then + block_tool "issue_edit or issue_type_set" "Use issue_edit for title, body, labels, and assignees, or issue_type_set for the issue type." + fi + # PR review comment thread replies (POST) if [[ "$METHOD" == "POST" ]] && echo "$ENDPOINT" | grep -qE 'pulls/[0-9]+/comments/[0-9]+/replies$'; then block_tool "pr_review_reply" "Use pr_review_reply with number, comment_id, and body." diff --git a/plugins/github-mcp/hooks/scripts/check-gh-tools.sh b/plugins/github-mcp/hooks/scripts/check-gh-tools.sh index dcf6d3d..1b6bbb4 100644 --- a/plugins/github-mcp/hooks/scripts/check-gh-tools.sh +++ b/plugins/github-mcp/hooks/scripts/check-gh-tools.sh @@ -159,7 +159,7 @@ fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*gh\s+issue\s+edit(\s|$)'; then block_tool "mcp__gh-tooling-write__issue_edit" \ - "Use issue_edit with number, title, body, labels, and assignees parameters." + "Use issue_edit with number, title, body, labels, and assignees parameters, or issue_type_set to change the issue type." fi if echo "$COMMAND" | grep -qE '(^|;|&&|\|)\s*gh\s+issue\s+close(\s|$)'; then diff --git a/plugins/github-mcp/mcp-server-gh/lib/common.sh b/plugins/github-mcp/mcp-server-gh/lib/common.sh index 7768c83..212345a 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/common.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/common.sh @@ -69,6 +69,55 @@ _gh_require_repo_or_git() { return 1 } +####################################### +# Resolve the organization owning org-level resources (issue types, issue fields). +# Priority: org > owner > repo-shaped args > GH_DEFAULT_REPO > git remote. +# Globals: +# GH_DEFAULT_REPO, _GH_OWNER +# Arguments: +# $1 JSON args string, $2 tool name for the error message. +# Outputs: +# Organization login on stdout, or an error message on stdout. +# Returns: +# 0 when an organization was resolved, 1 otherwise. +####################################### +_gh_resolve_org() { + local args="$1" tool="$2" + + local org owner + org=$(printf '%s\n' "${args}" | jq -r '.org // empty') + owner=$(printf '%s\n' "${args}" | jq -r '.owner // empty') + + if [[ -n "${org}" ]]; then + printf '%s\n' "${org}" + return 0 + fi + if [[ -n "${owner}" ]]; then + printf '%s\n' "${owner}" + return 0 + fi + + # Not run in a command substitution: the resolver reports through globals, + # which a subshell would discard. Its own error text goes to our stdout. + if ! _gh_resolve_owner_repo_optional "${args}"; then + return 1 + fi + if [[ -n "${_GH_OWNER}" ]]; then + printf '%s\n' "${_GH_OWNER}" + return 0 + fi + + local name_with_owner + name_with_owner=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) || true + if [[ -n "${name_with_owner}" ]]; then + printf '%s\n' "${name_with_owner%%/*}" + return 0 + fi + + printf '%s\n' "Error: org is required for ${tool}. Pass 'org', 'owner', or a repository ('repository', 'repo', or 'owner'+'repo'), or set 'repo' in .mcp-gh-tooling.json" + return 1 +} + # Read a value from the gh-tooling config file # Args: $1 = jq path (e.g. '.repo'), $2 = default value _gh_config_value() { diff --git a/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh b/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh index 27d3ae8..fec3d0a 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh @@ -2,55 +2,6 @@ # Issue type and issue field schema tools for gh-tooling MCP server # Read: issue_schema -####################################### -# Resolve the organization that owns issue types and issue fields. -# Priority: org > owner > repo-shaped args > GH_DEFAULT_REPO > git remote. -# Globals: -# GH_DEFAULT_REPO, _GH_OWNER -# Arguments: -# JSON args string. -# Outputs: -# Organization login on stdout, or an error message on stdout. -# Returns: -# 0 when an organization was resolved, 1 otherwise. -####################################### -_gh_resolve_issue_schema_org() { - local args="$1" - - local org owner - org=$(printf '%s\n' "${args}" | jq -r '.org // empty') - owner=$(printf '%s\n' "${args}" | jq -r '.owner // empty') - - if [[ -n "${org}" ]]; then - printf '%s\n' "${org}" - return 0 - fi - if [[ -n "${owner}" ]]; then - printf '%s\n' "${owner}" - return 0 - fi - - # Not run in a command substitution: the resolver reports through globals, - # which a subshell would discard. Its own error text goes to our stdout. - if ! _gh_resolve_owner_repo_optional "${args}"; then - return 1 - fi - if [[ -n "${_GH_OWNER}" ]]; then - printf '%s\n' "${_GH_OWNER}" - return 0 - fi - - local name_with_owner - name_with_owner=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) || true - if [[ -n "${name_with_owner}" ]]; then - printf '%s\n' "${name_with_owner%%/*}" - return 0 - fi - - printf '%s\n' "Error: org is required for issue_schema. Pass 'org', 'owner', or a repository ('repository', 'repo', or 'owner'+'repo'), or set 'repo' in .mcp-gh-tooling.json" - return 1 -} - ####################################### # List an organization's issue types and issue fields as one JSON document. # Types and fields are independent: GitHub pins fields to types for the web UI @@ -78,7 +29,7 @@ tool_issue_schema() { _gh_validate_jq_filter "${jq_filter}" || return 1 local org - org=$(_gh_resolve_issue_schema_org "${args}") || { + org=$(_gh_resolve_org "${args}" "issue_schema") || { [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } printf '%s\n' "${org}" return 1 diff --git a/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh b/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh new file mode 100644 index 0000000..99f869d --- /dev/null +++ b/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# Issue type and issue field write tools for gh-tooling-write MCP server +# Write: issue_type_set, issue_field_set + +####################################### +# Fetch an organization's issue types or issue fields. +# Arguments: +# $1 organization login, $2 endpoint segment ("issue-types" or "issue-fields"). +# Outputs: +# The endpoint's JSON array on stdout, or gh's error text on stdout. +# Returns: +# gh's exit status. +####################################### +_gh_issue_org_collection() { + local org="$1" endpoint="$2" + + local __raw __exit=0 + __raw=$(gh api "orgs/${org}/${endpoint}" 2>&1) || __exit=$? + printf '%s\n' "${__raw}" + return ${__exit} +} + +####################################### +# Resolve an issue type name to the organization's canonical spelling. +# Arguments: +# $1 organization login, $2 requested type name. +# Outputs: +# The canonical type name on stdout, or an error listing the available types. +# Returns: +# 0 when the name resolved, 1 otherwise. +####################################### +_gh_resolve_issue_type() { + local org="$1" wanted="$2" + + local types_json + types_json=$(_gh_issue_org_collection "${org}" "issue-types") || { + printf '%s\n' "Error: could not list issue types for '${org}': ${types_json}" + return 1 + } + + local resolved + resolved=$(printf '%s\n' "${types_json}" | jq -r --arg wanted "${wanted}" ' + [.[] | select((.name | ascii_downcase) == ($wanted | ascii_downcase)) | .name][0] // empty' 2>/dev/null) + + if [[ -z "${resolved}" ]]; then + local available + available=$(printf '%s\n' "${types_json}" | jq -r '[.[].name] | join(", ")' 2>/dev/null) + printf '%s\n' "Error: issue type '${wanted}' not found in '${org}'. Available types: ${available:-}" + return 1 + fi + + printf '%s\n' "${resolved}" +} + +####################################### +# Turn a name-keyed values object into the API's issue_field_values array. +# Resolves each field name to its numeric id and checks each value against the +# field's data type, so a bad name fails here naming the valid options rather +# than reaching GitHub, which reports a wrong option name for an unknown field. +# Arguments: +# $1 organization login, $2 values object keyed by field name. +# Outputs: +# The issue_field_values JSON array on stdout, or an error message. +# Returns: +# 0 when every entry resolved, 1 otherwise. +####################################### +_gh_resolve_issue_field_values() { + local org="$1" values="$2" + + local fields_json + fields_json=$(_gh_issue_org_collection "${org}" "issue-fields") || { + printf '%s\n' "Error: could not list issue fields for '${org}': ${fields_json}" + return 1 + } + + local resolved + resolved=$(jq -n \ + --argjson fields "${fields_json}" \ + --argjson values "${values}" ' + def find($name): [$fields[] | select((.name | ascii_downcase) == ($name | ascii_downcase))][0]; + def option($field; $value): + [$field.options[] | select((.name | ascii_downcase) == ($value | ascii_downcase)) | .name][0]; + def check($name; $value): + find($name) as $field + | if $field == null + then {error: "issue field \($name) not found. Available fields: \([$fields[].name] | join(", "))"} + elif $field.data_type == "single_select" then + (if ($value | type) != "string" then {error: "issue field \($name) takes an option name as a string, got \($value | type)"} + else option($field; $value) as $match + | if $match == null + then {error: "option \($value) not found for issue field \($name). Available options: \([$field.options[].name] | join(", "))"} + else {field_id: $field.id, value: $match} end + end) + elif $field.data_type == "multi_select" then + (if ($value | type) != "array" then {error: "issue field \($name) takes an array of option names, got \($value | type)"} + else ([$value[] | option($field; .)] | if any(. == null) then null else . end) as $matches + | if $matches == null + then {error: "one or more options not found for issue field \($name). Available options: \([$field.options[].name] | join(", "))"} + else {field_id: $field.id, value: $matches} end + end) + elif $field.data_type == "date" then + (if ($value | type) == "string" and ($value | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) + then {field_id: $field.id, value: $value} + else {error: "issue field \($name) takes a date as YYYY-MM-DD, got \($value | tostring)"} end) + elif $field.data_type == "number" then + (if ($value | type) == "number" + then {field_id: $field.id, value: $value} + else {error: "issue field \($name) takes a number, got \($value | type)"} end) + else + (if ($value | type) == "string" + then {field_id: $field.id, value: $value} + else {error: "issue field \($name) takes a string, got \($value | type)"} end) + end; + [$values | to_entries[] | check(.key; .value)] as $entries + | [$entries[] | select(has("error")) | .error] as $errors + | if ($errors | length) > 0 then {errors: $errors} else {values: $entries} end') || { + printf '%s\n' "Error: could not read the 'values' object" + return 1 + } + + local errors + errors=$(printf '%s\n' "${resolved}" | jq -r '.errors // [] | join("; ")') + if [[ -n "${errors}" ]]; then + printf '%s\n' "Error: ${errors}" + return 1 + fi + + printf '%s\n' "${resolved}" | jq -c '.values' +} + +####################################### +# Set or clear an issue's type. +# Setting the same type twice is the same call: the type name replaces whatever +# the issue carried before, and null clears it. +# Maps to: gh api -X PATCH repos//issues/ with a type body +# Arguments: +# JSON args string. +# Outputs: +# The issue's number and resulting type as JSON, or an error message. +# Returns: +# 0 on success, non-zero on validation or gh failure. +####################################### +tool_issue_type_set() { + local args="$1" + + local number has_type type_is_null type repo suppress_errors fallback + number=$(printf '%s\n' "${args}" | jq -r '.number // empty') + has_type=$(printf '%s\n' "${args}" | jq -r 'if has("type") then "true" else "false" end') + type_is_null=$(printf '%s\n' "${args}" | jq -r 'if .type == null then "true" else "false" end') + type=$(printf '%s\n' "${args}" | jq -r '.type // empty') + repo=$(printf '%s\n' "${args}" | jq -r '.repo // empty') + suppress_errors=$(printf '%s\n' "${args}" | jq -r '.suppress_errors // false') + fallback=$(printf '%s\n' "${args}" | jq -r '.fallback // empty') + + if [[ -z "${number}" ]]; then printf '%s\n' "Error: number is required for issue_type_set"; return 1; fi + if [[ "${has_type}" != "true" ]]; then + printf '%s\n' "Error: type is required for issue_type_set. Pass an issue type name, or null to clear the type." + return 1 + fi + _gh_validate_number "${number}" "number" || return 1 + + local effective_repo + effective_repo=$(_gh_resolve_repo "${repo}") + if [[ -z "${effective_repo}" ]]; then + printf '%s\n' "Error: repo is required for issue_type_set" + return 1 + fi + + local body + if [[ "${type_is_null}" == "true" ]]; then + body='{"type":null}' + else + if [[ -z "${type}" ]]; then + printf '%s\n' "Error: type is required for issue_type_set. Pass an issue type name, or null to clear the type." + return 1 + fi + + local canonical + canonical=$(_gh_resolve_issue_type "${effective_repo%%/*}" "${type}") || { + [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } + printf '%s\n' "${canonical}" + return 1 + } + body=$(jq -nc --arg type "${canonical}" '{type: $type}') + fi + + _gh_issue_schema_write "PATCH" "repos/${effective_repo}/issues/${number}" "${body}" \ + '{number: .number, type: (.type.name // null)}' "issue_type_set" "${suppress_errors}" "${fallback}" +} + +####################################### +# Replace an issue's field values with the given set. +# The object passed becomes the issue's complete set of field values: a field +# left out is cleared, and an empty object clears every value. Sending the same +# object twice leaves the issue in the same state. +# Maps to: gh api -X PUT repos//issues//issue-field-values +# Arguments: +# JSON args string. +# Outputs: +# The resulting field values as JSON, or an error message. +# Returns: +# 0 on success, non-zero on validation or gh failure. +####################################### +tool_issue_field_set() { + local args="$1" + + local number has_values values repo suppress_errors fallback + number=$(printf '%s\n' "${args}" | jq -r '.number // empty') + has_values=$(printf '%s\n' "${args}" | jq -r 'if has("values") then "true" else "false" end') + values=$(printf '%s\n' "${args}" | jq -c '.values // {}') + repo=$(printf '%s\n' "${args}" | jq -r '.repo // empty') + suppress_errors=$(printf '%s\n' "${args}" | jq -r '.suppress_errors // false') + fallback=$(printf '%s\n' "${args}" | jq -r '.fallback // empty') + + if [[ -z "${number}" ]]; then printf '%s\n' "Error: number is required for issue_field_set"; return 1; fi + if [[ "${has_values}" != "true" ]]; then + printf '%s\n' "Error: values is required for issue_field_set. Pass the complete set of field values, or {} to clear them all." + return 1 + fi + _gh_validate_number "${number}" "number" || return 1 + + local effective_repo + effective_repo=$(_gh_resolve_repo "${repo}") + if [[ -z "${effective_repo}" ]]; then + printf '%s\n' "Error: repo is required for issue_field_set" + return 1 + fi + + local field_values + field_values=$(_gh_resolve_issue_field_values "${effective_repo%%/*}" "${values}") || { + [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } + printf '%s\n' "${field_values}" + return 1 + } + + local body + body=$(jq -nc --argjson entries "${field_values}" '{issue_field_values: $entries}') + + _gh_issue_schema_write "PUT" "repos/${effective_repo}/issues/${number}/issue-field-values" \ + "${body}" '[.[] | {field: .issue_field_name, value: (.single_select_option.name // .value)}]' \ + "issue_field_set" "${suppress_errors}" "${fallback}" +} + +####################################### +# Send a request body to the GitHub API and shape the response. +# Arguments: +# $1 HTTP method, $2 endpoint, $3 request body JSON, $4 jq filter for the +# response, $5 tool name, $6 suppress_errors, $7 fallback. +# Outputs: +# The filtered response on stdout, or gh's error text on stdout. +# Returns: +# 0 on success, gh's exit status on failure. +####################################### +_gh_issue_schema_write() { + local method="$1" endpoint="$2" body="$3" response_filter="$4" + local tool="$5" suppress_errors="$6" fallback="$7" + + local -a cmd=("gh" "api" "-X" "${method}" "${endpoint}" "--input" "-") + + log "INFO" "${tool}: ${cmd[*]} ${body}" + local __raw __exit=0 + if [[ "${suppress_errors}" == "true" ]]; then + __raw=$(printf '%s' "${body}" | "${cmd[@]}" 2>/dev/null) || __exit=$? + else + __raw=$(printf '%s' "${body}" | "${cmd[@]}" 2>&1) || __exit=$? + fi + if [[ ${__exit} -ne 0 ]]; then + [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } + printf '%s\n' "${__raw}" + return ${__exit} + fi + + printf '%s\n' "${__raw}" | jq "${response_filter}" 2>/dev/null || printf '%s\n' "${__raw}" +} diff --git a/plugins/github-mcp/mcp-server-gh/server-write.sh b/plugins/github-mcp/mcp-server-gh/server-write.sh index 35daa1f..2b7d5a3 100755 --- a/plugins/github-mcp/mcp-server-gh/server-write.sh +++ b/plugins/github-mcp/mcp-server-gh/server-write.sh @@ -108,6 +108,7 @@ source "${SCRIPT_DIR}/lib/common.sh" source "${SCRIPT_DIR}/lib/api.sh" source "${SCRIPT_DIR}/lib/pr_write.sh" source "${SCRIPT_DIR}/lib/issue_write.sh" +source "${SCRIPT_DIR}/lib/issue_schema_write.sh" source "${SCRIPT_DIR}/lib/review_write.sh" source "${SCRIPT_DIR}/lib/label.sh" source "${SCRIPT_DIR}/lib/assignee_write.sh" diff --git a/plugins/github-mcp/mcp-server-gh/tools-write.json b/plugins/github-mcp/mcp-server-gh/tools-write.json index 8031722..8a2da32 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-write.json +++ b/plugins/github-mcp/mcp-server-gh/tools-write.json @@ -766,6 +766,38 @@ "fallback": { "type": "string" } } } + }, + { + "name": "issue_type_set", + "description": "Set or clear an issue's type (for example 'Bug' or 'Task'). The type name replaces whatever the issue carried before; pass null to clear it. Names are matched case-insensitively against the organization's types and resolved to their canonical spelling. Use issue_schema to list the available types. Issue types apply to issues only, not pull requests.", + "inputSchema": { + "type": "object", + "required": ["number", "type"], + "properties": { + "number": { "type": ["integer", "string"], "description": "Issue number." }, + "type": { "type": ["string", "null"], "description": "Issue type name, or null to clear the issue's type." }, + "repo": { "type": "string", "description": "GitHub repository in 'owner/repo' format. Defaults to the repo configured in .mcp-gh-tooling.json." }, + "suppress_errors": { "type": "boolean", "description": "Discard stderr output. When true, gh errors are silenced.", "default": false }, + "fallback": { "type": "string", "description": "Text to return if the gh command fails (non-zero exit)." } + }, + "additionalProperties": false + } + }, + { + "name": "issue_field_set", + "description": "Replace an issue's field values with the given set. The 'values' object becomes the issue's complete set of field values: a field left out is cleared, and {} clears them all. Read the current values with issue_view before setting one field, or the others are dropped. Field and option names are matched case-insensitively; use issue_schema to list the available fields and options. Issue fields apply to issues only, not pull requests.", + "inputSchema": { + "type": "object", + "required": ["number", "values"], + "properties": { + "number": { "type": ["integer", "string"], "description": "Issue number." }, + "values": { "type": "object", "description": "The complete set of field values, keyed by field name: single-select takes an option name, date takes 'YYYY-MM-DD', number takes a number, text takes a string. Pass {} to clear every field value." }, + "repo": { "type": "string", "description": "GitHub repository in 'owner/repo' format. Defaults to the repo configured in .mcp-gh-tooling.json." }, + "suppress_errors": { "type": "boolean", "description": "Discard stderr output. When true, gh errors are silenced.", "default": false }, + "fallback": { "type": "string", "description": "Text to return if the gh command fails (non-zero exit)." } + }, + "additionalProperties": false + } } ] } From 6ebc1db838120762a2925d60d19c9f2b11d78aed Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Wed, 2 Sep 2026 19:39:08 +0200 Subject: [PATCH 3/6] fix(github-mcp): harden issue type and field tools after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A null or empty-array `values` reached `jq -c '.values // {}'` and became an empty object, so `issue_field_set` sent PUT with an empty array and wiped every field value on the issue. `values` must now be an object; anything else is rejected before the request. An issue field whose `options` key is present but null broke the whole `issue_schema` merge with "Cannot iterate over null", leaving the tool unable to list even the types. The check is now on the value's type rather than the key's presence. `fallback` no longer covers input errors — an unresolvable org, an unknown issue type, an unknown field or option name. Those are caller mistakes, and returning the fallback text with exit 0 made a typo look like a completed write. `fallback` still covers a failed API call, which is what it is for. Both write tools now call `_gh_validate_repo` like every other write tool, so `repo: "widgets"` fails instead of becoming `repos/widgets/issues/7/...`. A resolved org is checked against GitHub's login charset for the same reason. The multi_select branch checked that the value was an array but not what was in it, so `[1, 2]` leaked a raw jq error. Element types are checked like every other branch. check-api-tools.sh blocked a GET of `issues/{n}/issue-field-values` and pointed at `issue_field_set`, which cannot serve a read; a GET now routes to `issue_view`, which carries the values inline. The PATCH rule missed endpoints with a query string, unlike the labels rule beside it. Docs: the plugin manifest still claimed 23 write tools, REFERENCE.md omitted the current-clone fallback in org resolution, and multi_select was implemented but undocumented in three places. Co-Authored-By: Claude Opus 5 (1M context) --- plugin-tests/github-mcp/check_api_tools.bats | 22 ++++++ .../github-mcp/read_tools_issue_schema.bats | 40 ++++++++++ .../github-mcp/write_tools_issue_schema.bats | 77 ++++++++++++++++++- plugins/github-mcp/.claude-plugin/plugin.json | 2 +- plugins/github-mcp/AGENTS.md | 2 + plugins/github-mcp/CHANGELOG.md | 4 +- plugins/github-mcp/REFERENCE.md | 7 +- .../hooks/scripts/check-api-tools.sh | 11 ++- .../github-mcp/mcp-server-gh/lib/common.sh | 22 ++++++ .../mcp-server-gh/lib/issue_schema.sh | 4 +- .../mcp-server-gh/lib/issue_schema_write.sh | 22 ++++-- .../github-mcp/mcp-server-gh/tools-write.json | 2 +- 12 files changed, 194 insertions(+), 21 deletions(-) diff --git a/plugin-tests/github-mcp/check_api_tools.bats b/plugin-tests/github-mcp/check_api_tools.bats index 604fef3..d39e4de 100644 --- a/plugin-tests/github-mcp/check_api_tools.bats +++ b/plugin-tests/github-mcp/check_api_tools.bats @@ -66,6 +66,13 @@ setup_read_blocking() { assert_output --partial "repo_tree" } +@test "read api: blocks GET issues/N/issue-field-values → suggests issue_view" { + setup_read_blocking + run_api_hook "$READ_TOOL" "repos/shopware/shopware/issues/123/issue-field-values" + assert_failure 2 + assert_output --partial "issue_view" +} + @test "read api: blocks orgs/N/issue-types → suggests issue_schema" { setup_read_blocking run_api_hook "$READ_TOOL" "orgs/shopware/issue-types" @@ -168,6 +175,21 @@ setup_write_blocking() { assert_output --partial "issue_type_set" } +@test "write api: blocks PATCH issues/N with a query string" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/issues/123?foo=bar" "PATCH" + assert_failure 2 + assert_output --partial "issue_type_set" +} + +@test "write api: a GET of issue-field-values goes to issue_view, not issue_field_set" { + setup_write_blocking + run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/issues/123/issue-field-values" "GET" + assert_failure 2 + assert_output --partial "issue_view" + refute_output --partial "issue_field_set" +} + @test "write api: blocks POST pulls/N/reviews → suggests pr_review_submit" { setup_write_blocking run_api_hook "$WRITE_TOOL" "repos/shopware/shopware/pulls/123/reviews" "POST" diff --git a/plugin-tests/github-mcp/read_tools_issue_schema.bats b/plugin-tests/github-mcp/read_tools_issue_schema.bats index b0967d4..d9a5efa 100644 --- a/plugin-tests/github-mcp/read_tools_issue_schema.bats +++ b/plugin-tests/github-mcp/read_tools_issue_schema.bats @@ -123,3 +123,43 @@ setup() { assert_failure assert_output --partial "Invalid jq_filter" } + +@test "issue_schema prefers org over owner and a repo parameter" { + run tool_issue_schema '{"org": "from-org", "owner": "from-owner", "repo": "from-repo/x"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.org')" "from-org" +} + +@test "issue_schema prefers owner over a repo parameter" { + run tool_issue_schema '{"owner": "from-owner", "repo": "from-repo/x"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.org')" "from-owner" +} + +@test "issue_schema with suppress_errors returns no error text" { + GH_STUB_TYPES_EXIT=1 + run tool_issue_schema '{"org": "cli", "suppress_errors": true}' + assert_failure + assert_output "" +} + +@test "issue_schema keeps working when a field carries a null options key" { + GH_STUB_FIELDS='[{"id":1,"name":"Priority","data_type":"single_select","options":null}]' + run tool_issue_schema '{"org": "acme"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.fields[0] | has("options")')" "false" +} + +@test "issue_schema rejects an org that is not a login" { + run tool_issue_schema '{"org": "../../repos/victim/private"}' + assert_failure + assert_output --partial "invalid organization" + [ ! -f "${GH_ARGS_FILE}" ] +} + +@test "fallback does not mask an unresolvable org" { + GH_DEFAULT_REPO="" + run tool_issue_schema '{"org": "!!", "fallback": "ok"}' + assert_failure + assert_output --partial "invalid organization" +} diff --git a/plugin-tests/github-mcp/write_tools_issue_schema.bats b/plugin-tests/github-mcp/write_tools_issue_schema.bats index 6cade69..6967df9 100644 --- a/plugin-tests/github-mcp/write_tools_issue_schema.bats +++ b/plugin-tests/github-mcp/write_tools_issue_schema.bats @@ -6,7 +6,7 @@ bats_require_minimum_version 1.11.0 load 'test_helper/common_setup' TYPES_JSON='[{"id":125714,"name":"Bug"},{"id":25328944,"name":"Improvement"}]' -FIELDS_JSON='[{"id":8847,"name":"Priority","data_type":"single_select","options":[{"id":12296,"name":"High"},{"id":12298,"name":"Low"}]},{"id":8848,"name":"Start date","data_type":"date"},{"id":8851,"name":"Points","data_type":"number"},{"id":8852,"name":"Owner","data_type":"text"}]' +FIELDS_JSON='[{"id":8847,"name":"Priority","data_type":"single_select","options":[{"id":12296,"name":"High"},{"id":12298,"name":"Low"}]},{"id":8848,"name":"Start date","data_type":"date"},{"id":8851,"name":"Points","data_type":"number"},{"id":8852,"name":"Owner","data_type":"text"},{"id":8853,"name":"Teams","data_type":"multi_select","options":[{"id":1,"name":"Core"},{"id":2,"name":"Storefront"}]}]' setup() { log() { :; } @@ -163,3 +163,78 @@ body() { jq -c "$1" "${GH_BODY_FILE}"; } assert_success assert_output "unchanged" } + +@test "issue_field_set shapes the response into field/value pairs" { + GH_STUB_OUTPUT='[{"issue_field_name":"Priority","value":12296,"single_select_option":{"id":12296,"name":"High"}},{"issue_field_name":"Start date","value":"2026-09-30"}]' + run tool_issue_field_set '{"number": 19952, "values": {"Priority": "High"}}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -c '.')" '[{"field":"Priority","value":"High"},{"field":"Start date","value":"2026-09-30"}]' +} + +@test "issue_field_set resolves a multi-select array to canonical option names" { + GH_STUB_OUTPUT='[]' + run tool_issue_field_set '{"number": 19952, "values": {"Teams": ["core", "Storefront"]}}' + assert_success + assert_equal "$(body '.issue_field_values')" '[{"field_id":8853,"value":["Core","Storefront"]}]' +} + +@test "issue_field_set rejects a non-array value for a multi-select field" { + run tool_issue_field_set '{"number": 19952, "values": {"Teams": "Core"}}' + assert_failure + assert_output --partial "takes an array of option names" +} + +@test "issue_field_set rejects an unknown option inside a multi-select array" { + run tool_issue_field_set '{"number": 19952, "values": {"Teams": ["Core", "Nope"]}}' + assert_failure + assert_output --partial "Available options: Core, Storefront" +} + +@test "issue_field_set with suppress_errors returns no error text" { + GH_STUB_EXIT=1 + run tool_issue_field_set '{"number": 19952, "values": {}, "suppress_errors": true}' + assert_failure + assert_output "" +} + +@test "issue_field_set rejects a null or array values argument instead of clearing everything" { + run tool_issue_field_set '{"number": 19952, "values": null}' + assert_failure + assert_output --partial "must be an object" + [ ! -f "${GH_ARGS_FILE}" ] + + run tool_issue_field_set '{"number": 19952, "values": []}' + assert_failure + assert_output --partial "must be an object" + [ ! -f "${GH_ARGS_FILE}" ] +} + +@test "issue_field_set rejects non-string elements in a multi-select array" { + run tool_issue_field_set '{"number": 19952, "values": {"Teams": [1, 2]}}' + assert_failure + assert_output --partial "array of option names as strings" +} + +@test "issue_field_set rejects a malformed repo" { + run tool_issue_field_set '{"number": 19952, "values": {}, "repo": "widgets"}' + assert_failure + assert_output --partial "owner/repo" +} + +@test "issue_type_set rejects a malformed repo" { + run tool_issue_type_set '{"number": 19952, "type": "Bug", "repo": "widgets"}' + assert_failure + assert_output --partial "owner/repo" +} + +@test "fallback does not mask an unknown field name" { + run tool_issue_field_set '{"number": 19952, "values": {"Ghost": "x"}, "fallback": "ok"}' + assert_failure + assert_output --partial "not found" +} + +@test "fallback does not mask an unknown issue type" { + run tool_issue_type_set '{"number": 19952, "type": "Bogus", "fallback": "ok"}' + assert_failure + assert_output --partial "Available types" +} diff --git a/plugins/github-mcp/.claude-plugin/plugin.json b/plugins/github-mcp/.claude-plugin/plugin.json index d382e92..c975b73 100644 --- a/plugins/github-mcp/.claude-plugin/plugin.json +++ b/plugins/github-mcp/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "github-mcp", "version": "3.5.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 23 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.", + "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" }, diff --git a/plugins/github-mcp/AGENTS.md b/plugins/github-mcp/AGENTS.md index 28ddb22..a4fe6ab 100644 --- a/plugins/github-mcp/AGENTS.md +++ b/plugins/github-mcp/AGENTS.md @@ -187,6 +187,8 @@ BATS tests for hook scripts and MCP tool functions are in `plugin-tests/github-m | `check_api_tools.bats` | Dedicated API-tool enforcement for Claude Code and Codex tool namespaces | | `session_start.bats` | Shared SessionStart context and host-specific config discovery | | `write_server_gating.bats` | Write-server gating and active-host config priority | +| `read_tools_issue_schema.bats` | `issue_schema` org resolution, name filters, and merge output | +| `write_tools_issue_schema.bats` | `issue_type_set` and `issue_field_set` name resolution and value checks | | `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 | diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index 081ae92..c7e33d5 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -11,8 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `issue_schema` read tool. Returns an organization's issue types and issue fields in one call, with the options of every single-select field, so an agent can look up the exact names before setting an issue's type or field values. The organization is resolved from `org`, `owner`, a repository parameter, the configured default repo, or the current clone. `type` and `field` each match one name case-insensitively and narrow only their own list; a name that matches nothing is an error rather than an empty list. Backed by `orgs/{org}/issue-types` and `orgs/{org}/issue-fields`. - Dedicated-tool enforcement for `orgs/{org}/issue-types` and `orgs/{org}/issue-fields` in `check-api-tools.sh`, active when `block_api_tool_read` is enabled. - `issue_type_set` write tool. Sets an issue's type by name, or clears it with `null`. The name is matched case-insensitively against the organization's types and sent in its canonical spelling; an unknown name fails before the write, listing the available types. -- `issue_field_set` write tool. Replaces an issue's field values with the given `values` object, keyed by field name — a field left out is cleared, and `{}` clears them all. Field and option names resolve to IDs against the organization's schema, and each value is checked against its field's data type (option name, `YYYY-MM-DD`, number, string) before the request, because GitHub reports an unknown field ID as a wrong option name. -- Dedicated-tool enforcement for `issues/{n}/issue-field-values` and `PATCH repos/{owner}/{repo}/issues/{n}` in `check-api-tools.sh`, active when `block_api_tool_write` is enabled. +- `issue_field_set` write tool. Replaces an issue's field values with the given `values` object, keyed by field name — a field left out is cleared, and `{}` clears them all. Field and option names resolve to IDs against the organization's schema, and each value is checked against its field's data type (option name, array of option names for multi-select, `YYYY-MM-DD`, number, string) before the request, because GitHub reports an unknown field ID as a wrong option name. +- Dedicated-tool enforcement for `issues/{n}/issue-field-values` and `PATCH repos/{owner}/{repo}/issues/{n}` in `check-api-tools.sh`. A write of an issue's field values routes to `issue_field_set` under `block_api_tool_write`; a `GET` of the same path routes to `issue_view`, which carries the values inline, under `block_api_tool_read`. ### Changed - `shared/mcpserver_core.sh` is now vendored from [shopwareLabs/bash-mcp-sdk](https://github.com/shopwareLabs/bash-mcp-sdk) `v2.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. diff --git a/plugins/github-mcp/REFERENCE.md b/plugins/github-mcp/REFERENCE.md index 10e48e9..993f948 100644 --- a/plugins/github-mcp/REFERENCE.md +++ b/plugins/github-mcp/REFERENCE.md @@ -155,7 +155,8 @@ Use gh-tooling issue_list with search "TODO label:component/core" and limit 20 ### `issue_schema` List an organization's issue types and issue fields, including each single-select field's options. -The organization comes from `org`, `owner`, a repository parameter, or the configured default repo. +The organization comes from `org`, `owner`, a repository parameter, the configured default repo, or the +current clone's remote. Types and fields are independent. GitHub lets an organization pin fields to a type, but that pinning only drives the web UI: any organization field can be set on an issue of any type, so this tool @@ -799,8 +800,8 @@ Replace an issue's field values. The `values` object becomes the issue's **compl left out is cleared, and `{}` clears them all. To change one field without dropping the others, read the current values with `issue_view` first and pass them back alongside the change. -Values are typed by the field: a single-select takes an option name, a date takes `YYYY-MM-DD`, a -number takes a number, and a text field takes a string. +Values are typed by the field: a single-select takes an option name, a multi-select takes an array of +option names, a date takes `YYYY-MM-DD`, a number takes a number, and a text field takes a string. ``` Use gh-tooling-write issue_field_set with number 19952 and values {"Priority": "High", "Effort": "Low"} diff --git a/plugins/github-mcp/hooks/scripts/check-api-tools.sh b/plugins/github-mcp/hooks/scripts/check-api-tools.sh index b8629d7..cb5f720 100755 --- a/plugins/github-mcp/hooks/scripts/check-api-tools.sh +++ b/plugins/github-mcp/hooks/scripts/check-api-tools.sh @@ -107,6 +107,11 @@ if echo "$ENDPOINT" | grep -qE 'labels(\?|$)'; then block_tool "label_list" "Use label_list with optional repo and filter parameters." fi +# Issue field values on one issue — issue_view returns them inline +if echo "$ENDPOINT" | grep -qE 'issues/[0-9]+/issue-field-values'; then + block_tool "issue_view" "Use issue_view with number. The response carries the issue's field values under issue_field_values." +fi + # Organization issue types and issue fields if echo "$ENDPOINT" | grep -qE 'orgs/[^/]+/issue-(types|fields)'; then block_tool "issue_schema" "Use issue_schema with optional org and type/field name filters. It returns both collections in one call." @@ -141,13 +146,13 @@ if [[ "$IS_WRITE" == "true" ]]; then block_tool "issue_comment" "Use issue_comment with number and body." fi - # Issue field values (POST/PUT/DELETE) - if echo "$ENDPOINT" | grep -qE 'issues/[0-9]+/issue-field-values'; then + # Issue field values (POST/PUT/DELETE; a GET is handled by the read section) + if [[ "$METHOD" != "GET" ]] && echo "$ENDPOINT" | grep -qE 'issues/[0-9]+/issue-field-values'; then block_tool "issue_field_set" "Use issue_field_set with number and a values object keyed by field name. It replaces the issue's whole set of field values." fi # Issue metadata, including the issue type (PATCH) - if [[ "$METHOD" == "PATCH" ]] && echo "$ENDPOINT" | grep -qE 'repos/[^/]+/[^/]+/issues/[0-9]+$'; then + if [[ "$METHOD" == "PATCH" ]] && echo "$ENDPOINT" | grep -qE 'repos/[^/]+/[^/]+/issues/[0-9]+(\?|$)'; then block_tool "issue_edit or issue_type_set" "Use issue_edit for title, body, labels, and assignees, or issue_type_set for the issue type." fi diff --git a/plugins/github-mcp/mcp-server-gh/lib/common.sh b/plugins/github-mcp/mcp-server-gh/lib/common.sh index 212345a..2c30d60 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/common.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/common.sh @@ -69,6 +69,24 @@ _gh_require_repo_or_git() { return 1 } +####################################### +# Reject an organization login that is not one path segment of GitHub's login +# charset, so it cannot steer the request to a different API path. +# Arguments: +# $1 candidate login, $2 tool name for the error message. +# Outputs: +# An error message on stdout when the login is malformed. +# Returns: +# 0 when the login is usable, 1 otherwise. +####################################### +_gh_validate_org() { + local org="$1" tool="$2" + if [[ ! "${org}" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]]; then + printf '%s\n' "Error: invalid organization '${org}' for ${tool}. Expected a GitHub organization login." + return 1 + fi +} + ####################################### # Resolve the organization owning org-level resources (issue types, issue fields). # Priority: org > owner > repo-shaped args > GH_DEFAULT_REPO > git remote. @@ -89,10 +107,12 @@ _gh_resolve_org() { owner=$(printf '%s\n' "${args}" | jq -r '.owner // empty') if [[ -n "${org}" ]]; then + _gh_validate_org "${org}" "${tool}" || return 1 printf '%s\n' "${org}" return 0 fi if [[ -n "${owner}" ]]; then + _gh_validate_org "${owner}" "${tool}" || return 1 printf '%s\n' "${owner}" return 0 fi @@ -103,6 +123,7 @@ _gh_resolve_org() { return 1 fi if [[ -n "${_GH_OWNER}" ]]; then + _gh_validate_org "${_GH_OWNER}" "${tool}" || return 1 printf '%s\n' "${_GH_OWNER}" return 0 fi @@ -110,6 +131,7 @@ _gh_resolve_org() { local name_with_owner name_with_owner=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) || true if [[ -n "${name_with_owner}" ]]; then + _gh_validate_org "${name_with_owner%%/*}" "${tool}" || return 1 printf '%s\n' "${name_with_owner%%/*}" return 0 fi diff --git a/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh b/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh index fec3d0a..5734501 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/issue_schema.sh @@ -30,7 +30,6 @@ tool_issue_schema() { local org org=$(_gh_resolve_org "${args}" "issue_schema") || { - [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } printf '%s\n' "${org}" return 1 } @@ -62,7 +61,7 @@ tool_issue_schema() { }], fields: [$fields[] | select(matches($field)) | { id, name, description, data_type, visibility - } + (if has("options") then {options: [.options[] | {id, name, color}]} else {} end)] + } + (if (.options | type) == "array" then {options: [.options[] | {id, name, color}]} else {} end)] }') || { [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } printf '%s\n' "Error: could not merge issue types and issue fields for '${org}'" @@ -72,7 +71,6 @@ tool_issue_schema() { local unmatched unmatched=$(_gh_issue_schema_unmatched "${merged}" "${type}" "${field}") if [[ -n "${unmatched}" ]]; then - [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } printf '%s\n' "${unmatched}" return 1 fi diff --git a/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh b/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh index 99f869d..e5b0349 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh @@ -93,6 +93,7 @@ _gh_resolve_issue_field_values() { end) elif $field.data_type == "multi_select" then (if ($value | type) != "array" then {error: "issue field \($name) takes an array of option names, got \($value | type)"} + elif ([$value[] | type] | any(. != "string")) then {error: "issue field \($name) takes an array of option names as strings"} else ([$value[] | option($field; .)] | if any(. == null) then null else . end) as $matches | if $matches == null then {error: "one or more options not found for issue field \($name). Available options: \([$field.options[].name] | join(", "))"} @@ -165,6 +166,7 @@ tool_issue_type_set() { printf '%s\n' "Error: repo is required for issue_type_set" return 1 fi + _gh_validate_repo "${effective_repo}" || return 1 local body if [[ "${type_is_null}" == "true" ]]; then @@ -177,7 +179,6 @@ tool_issue_type_set() { local canonical canonical=$(_gh_resolve_issue_type "${effective_repo%%/*}" "${type}") || { - [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } printf '%s\n' "${canonical}" return 1 } @@ -204,19 +205,23 @@ tool_issue_type_set() { tool_issue_field_set() { local args="$1" - local number has_values values repo suppress_errors fallback + local number values_type values repo suppress_errors fallback number=$(printf '%s\n' "${args}" | jq -r '.number // empty') - has_values=$(printf '%s\n' "${args}" | jq -r 'if has("values") then "true" else "false" end') - values=$(printf '%s\n' "${args}" | jq -c '.values // {}') + values_type=$(printf '%s\n' "${args}" | jq -r 'if has("values") then (.values | type) else "absent" end') + values=$(printf '%s\n' "${args}" | jq -c '.values') repo=$(printf '%s\n' "${args}" | jq -r '.repo // empty') suppress_errors=$(printf '%s\n' "${args}" | jq -r '.suppress_errors // false') fallback=$(printf '%s\n' "${args}" | jq -r '.fallback // empty') if [[ -z "${number}" ]]; then printf '%s\n' "Error: number is required for issue_field_set"; return 1; fi - if [[ "${has_values}" != "true" ]]; then + if [[ "${values_type}" == "absent" ]]; then printf '%s\n' "Error: values is required for issue_field_set. Pass the complete set of field values, or {} to clear them all." return 1 fi + if [[ "${values_type}" != "object" ]]; then + printf '%s\n' "Error: values must be an object keyed by field name, got ${values_type}. Pass {} to clear every field value." + return 1 + fi _gh_validate_number "${number}" "number" || return 1 local effective_repo @@ -225,16 +230,19 @@ tool_issue_field_set() { printf '%s\n' "Error: repo is required for issue_field_set" return 1 fi + _gh_validate_repo "${effective_repo}" || return 1 local field_values field_values=$(_gh_resolve_issue_field_values "${effective_repo%%/*}" "${values}") || { - [[ -n "${fallback}" ]] && { printf '%s\n' "${fallback}"; return 0; } printf '%s\n' "${field_values}" return 1 } local body - body=$(jq -nc --argjson entries "${field_values}" '{issue_field_values: $entries}') + body=$(jq -nc --argjson entries "${field_values}" '{issue_field_values: $entries}') || { + printf '%s\n' "Error: could not build the request body for issue_field_set" + return 1 + } _gh_issue_schema_write "PUT" "repos/${effective_repo}/issues/${number}/issue-field-values" \ "${body}" '[.[] | {field: .issue_field_name, value: (.single_select_option.name // .value)}]' \ diff --git a/plugins/github-mcp/mcp-server-gh/tools-write.json b/plugins/github-mcp/mcp-server-gh/tools-write.json index 8a2da32..9f2e80b 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-write.json +++ b/plugins/github-mcp/mcp-server-gh/tools-write.json @@ -791,7 +791,7 @@ "required": ["number", "values"], "properties": { "number": { "type": ["integer", "string"], "description": "Issue number." }, - "values": { "type": "object", "description": "The complete set of field values, keyed by field name: single-select takes an option name, date takes 'YYYY-MM-DD', number takes a number, text takes a string. Pass {} to clear every field value." }, + "values": { "type": "object", "description": "The complete set of field values, keyed by field name: single-select takes an option name, multi-select takes an array of option names, date takes 'YYYY-MM-DD', number takes a number, text takes a string. Pass {} to clear every field value." }, "repo": { "type": "string", "description": "GitHub repository in 'owner/repo' format. Defaults to the repo configured in .mcp-gh-tooling.json." }, "suppress_errors": { "type": "boolean", "description": "Discard stderr output. When true, gh errors are silenced.", "default": false }, "fallback": { "type": "string", "description": "Text to return if the gh command fails (non-zero exit)." } From e5b40612c863000f08d5ec28e7726d265a3e3022 Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Wed, 2 Sep 2026 20:28:21 +0200 Subject: [PATCH 4/6] fix(github-mcp): tighten issue_field_set value checking A date value was checked with a regex anchored by `$`, which matches before a trailing newline, so "2026-09-30\n" was accepted and sent. The check now also requires the exact length, and the month and day ranges reject "2026-99-99", which the old pattern let through. A field whose data_type is none of the five the tool understands fell to a catch-all branch and was written as text. An unrecognised data type is now rejected by name, so a field GitHub adds later fails loudly instead of being written wrong. Field names resolve case-insensitively, so {"Priority": "High", "priority": "Low"} produced two entries for one field_id and left the result to the API. Keys that name the same field are now rejected before the request. Found by a codex review of the branch. Co-Authored-By: Claude Opus 5 (1M context) --- .../github-mcp/write_tools_issue_schema.bats | 25 ++++++++++++++++++- .../mcp-server-gh/lib/issue_schema_write.sh | 13 +++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/plugin-tests/github-mcp/write_tools_issue_schema.bats b/plugin-tests/github-mcp/write_tools_issue_schema.bats index 6967df9..b44ab06 100644 --- a/plugin-tests/github-mcp/write_tools_issue_schema.bats +++ b/plugin-tests/github-mcp/write_tools_issue_schema.bats @@ -6,7 +6,7 @@ bats_require_minimum_version 1.11.0 load 'test_helper/common_setup' TYPES_JSON='[{"id":125714,"name":"Bug"},{"id":25328944,"name":"Improvement"}]' -FIELDS_JSON='[{"id":8847,"name":"Priority","data_type":"single_select","options":[{"id":12296,"name":"High"},{"id":12298,"name":"Low"}]},{"id":8848,"name":"Start date","data_type":"date"},{"id":8851,"name":"Points","data_type":"number"},{"id":8852,"name":"Owner","data_type":"text"},{"id":8853,"name":"Teams","data_type":"multi_select","options":[{"id":1,"name":"Core"},{"id":2,"name":"Storefront"}]}]' +FIELDS_JSON='[{"id":8847,"name":"Priority","data_type":"single_select","options":[{"id":12296,"name":"High"},{"id":12298,"name":"Low"}]},{"id":8848,"name":"Start date","data_type":"date"},{"id":8851,"name":"Points","data_type":"number"},{"id":8852,"name":"Owner","data_type":"text"},{"id":8853,"name":"Teams","data_type":"multi_select","options":[{"id":1,"name":"Core"},{"id":2,"name":"Storefront"}]},{"id":8854,"name":"Flag","data_type":"boolean"}]' setup() { log() { :; } @@ -238,3 +238,26 @@ body() { jq -c "$1" "${GH_BODY_FILE}"; } assert_failure assert_output --partial "Available types" } + +@test "issue_field_set rejects a date with trailing whitespace or an impossible month" { + run tool_issue_field_set '{"number": 19952, "values": {"Start date": "2026-09-30\n"}}' + assert_failure + assert_output --partial "YYYY-MM-DD" + + run tool_issue_field_set '{"number": 19952, "values": {"Start date": "2026-99-99"}}' + assert_failure + assert_output --partial "YYYY-MM-DD" +} + +@test "issue_field_set rejects a field whose data type it does not support" { + run tool_issue_field_set '{"number": 19952, "values": {"Flag": "yes"}}' + assert_failure + assert_output --partial "unsupported data type boolean" +} + +@test "issue_field_set rejects two keys that name the same field" { + run tool_issue_field_set '{"number": 19952, "values": {"Priority": "High", "priority": "Low"}}' + assert_failure + assert_output --partial "name the same issue field" + [ ! -f "${GH_ARGS_FILE}" ] +} diff --git a/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh b/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh index e5b0349..a459ad1 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh @@ -100,21 +100,28 @@ _gh_resolve_issue_field_values() { else {field_id: $field.id, value: $matches} end end) elif $field.data_type == "date" then - (if ($value | type) == "string" and ($value | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) + (if ($value | type) == "string" and ($value | length) == 10 + and ($value | test("^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$")) then {field_id: $field.id, value: $value} else {error: "issue field \($name) takes a date as YYYY-MM-DD, got \($value | tostring)"} end) elif $field.data_type == "number" then (if ($value | type) == "number" then {field_id: $field.id, value: $value} else {error: "issue field \($name) takes a number, got \($value | type)"} end) - else + elif $field.data_type == "text" then (if ($value | type) == "string" then {field_id: $field.id, value: $value} else {error: "issue field \($name) takes a string, got \($value | type)"} end) + else + {error: "issue field \($name) has unsupported data type \($field.data_type)"} end; [$values | to_entries[] | check(.key; .value)] as $entries | [$entries[] | select(has("error")) | .error] as $errors - | if ($errors | length) > 0 then {errors: $errors} else {values: $entries} end') || { + | [$entries[] | select(has("field_id")) | .field_id] as $ids + | ([$ids[] | tostring] | group_by(.) | map(select(length > 1)) | length) as $dupes + | if ($errors | length) > 0 then {errors: $errors} + elif $dupes > 0 then {errors: ["two or more keys in values name the same issue field; pass each field once"]} + else {values: $entries} end') || { printf '%s\n' "Error: could not read the 'values' object" return 1 } From 3d43b7d231f6dc7e7f7e1b182ea4b6fbc3d2dafe Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Wed, 2 Sep 2026 22:50:02 +0200 Subject: [PATCH 5/6] feat(github-mcp): declare repo and org patterns on issue schema tools The vendored MCP SDK checks a property's declared `pattern` before dispatch. These three tools already validate the same two shapes in bash, so declaring them makes the schema the first gate and leaves the tool functions as the second: `issue_type_set.repo` and `issue_field_set.repo` take the `owner/repo` shape from `_gh_validate_repo`, and `issue_schema.org` and `issue_schema.owner` take the login shape from `_gh_validate_org`. `issue_schema.repo` is deliberately left without a pattern. It also accepts a bare repository name when `owner` is passed alongside it, and a pattern requiring a slash would reject that split form. Checked against the running servers: the four accepted repository shapes still pass, and a traversal value such as "acme/widgets/../../orgs/other" is now rejected before the tool runs, naming the parameter and the pattern. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/github-mcp/CHANGELOG.md | 1 + plugins/github-mcp/mcp-server-gh/tools-read.json | 2 ++ plugins/github-mcp/mcp-server-gh/tools-write.json | 4 ++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index c7e33d5..388ca3c 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dedicated-tool enforcement for `orgs/{org}/issue-types` and `orgs/{org}/issue-fields` in `check-api-tools.sh`, active when `block_api_tool_read` is enabled. - `issue_type_set` write tool. Sets an issue's type by name, or clears it with `null`. The name is matched case-insensitively against the organization's types and sent in its canonical spelling; an unknown name fails before the write, listing the available types. - `issue_field_set` write tool. Replaces an issue's field values with the given `values` object, keyed by field name — a field left out is cleared, and `{}` clears them all. Field and option names resolve to IDs against the organization's schema, and each value is checked against its field's data type (option name, array of option names for multi-select, `YYYY-MM-DD`, number, string) before the request, because GitHub reports an unknown field ID as a wrong option name. +- `pattern` constraints on the repository and organization parameters of the three new tools: `issue_type_set.repo` and `issue_field_set.repo` carry the `owner/repo` shape that `_gh_validate_repo` enforces, and `issue_schema.org` and `issue_schema.owner` carry the login shape that `_gh_validate_org` enforces. The vendored SDK enforces `pattern` on string values, so a malformed repository or organization is now rejected before dispatch; the tool functions validate these values as well, which is what still holds for a call that reaches them another way. `issue_schema.repo` is deliberately left unconstrained because it also accepts a bare repository name alongside `owner`. - Dedicated-tool enforcement for `issues/{n}/issue-field-values` and `PATCH repos/{owner}/{repo}/issues/{n}` in `check-api-tools.sh`. A write of an issue's field values routes to `issue_field_set` under `block_api_tool_write`; a `GET` of the same path routes to `issue_view`, which carries the values inline, under `block_api_tool_read`. ### Changed diff --git a/plugins/github-mcp/mcp-server-gh/tools-read.json b/plugins/github-mcp/mcp-server-gh/tools-read.json index b073b5e..ea4ba29 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-read.json +++ b/plugins/github-mcp/mcp-server-gh/tools-read.json @@ -1830,10 +1830,12 @@ "properties": { "org": { "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]*$", "description": "Organization login. Defaults to the owner of the resolved repository." }, "owner": { "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]*$", "description": "Repository owner, used as the organization when 'org' is not set." }, "repo": { diff --git a/plugins/github-mcp/mcp-server-gh/tools-write.json b/plugins/github-mcp/mcp-server-gh/tools-write.json index 9f2e80b..d2ac626 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-write.json +++ b/plugins/github-mcp/mcp-server-gh/tools-write.json @@ -776,7 +776,7 @@ "properties": { "number": { "type": ["integer", "string"], "description": "Issue number." }, "type": { "type": ["string", "null"], "description": "Issue type name, or null to clear the issue's type." }, - "repo": { "type": "string", "description": "GitHub repository in 'owner/repo' format. Defaults to the repo configured in .mcp-gh-tooling.json." }, + "repo": { "type": "string", "pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", "description": "GitHub repository in 'owner/repo' format. Defaults to the repo configured in .mcp-gh-tooling.json." }, "suppress_errors": { "type": "boolean", "description": "Discard stderr output. When true, gh errors are silenced.", "default": false }, "fallback": { "type": "string", "description": "Text to return if the gh command fails (non-zero exit)." } }, @@ -792,7 +792,7 @@ "properties": { "number": { "type": ["integer", "string"], "description": "Issue number." }, "values": { "type": "object", "description": "The complete set of field values, keyed by field name: single-select takes an option name, multi-select takes an array of option names, date takes 'YYYY-MM-DD', number takes a number, text takes a string. Pass {} to clear every field value." }, - "repo": { "type": "string", "description": "GitHub repository in 'owner/repo' format. Defaults to the repo configured in .mcp-gh-tooling.json." }, + "repo": { "type": "string", "pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", "description": "GitHub repository in 'owner/repo' format. Defaults to the repo configured in .mcp-gh-tooling.json." }, "suppress_errors": { "type": "boolean", "description": "Discard stderr output. When true, gh errors are silenced.", "default": false }, "fallback": { "type": "string", "description": "Text to return if the gh command fails (non-zero exit)." } }, From 1be08e7f7d5e1d1df022e40f6e52d6d1184c6454 Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Thu, 3 Sep 2026 02:23:59 +0200 Subject: [PATCH 6/6] feat(github-mcp): read an issue's type and field values with issue_view issue_field_set replaces an issue's whole set of field values, and its description told callers to read the current ones with issue_view first. issue_view could not supply them: it wraps gh issue view, whose --json accepts issueType but offers no field values, and whose text output shows neither. The advice routed callers into the footgun it warned about, and the hook rule for a GET of issues/{n}/issue-field-values pointed at the same tool. with_field_values reads the REST issue instead and returns the type name together with the field values keyed by field name, reporting select options by name where the API reports option ids. That object is issue_field_set's values parameter, so a caller reads it, changes an entry, and passes the whole object back. issue_field_set's own response now carries the same shape. Output is JSON whenever the option is set: fields merges gh's own fields into the same document, and with_comments is refused alongside it because that output is text. The repository is validated before the path is built, since the REST path is concatenated here rather than parsed by gh, and letting a malformed one reach gh would turn an input error into a fallback success. Two values naming one field are an error rather than a merge, because keying by name is what makes the result writable and keeping only the last would return a subset that clears the rest on the next write. Co-Authored-By: Claude Opus 5 (1M context) --- .../read_tools_issue_view_fields.bats | 165 ++++++++++++++++++ .../github-mcp/write_tools_issue_schema.bats | 6 +- plugins/github-mcp/CHANGELOG.md | 5 +- plugins/github-mcp/REFERENCE.md | 16 +- .../hooks/prompts/write-operations-enabled.md | 2 +- .../hooks/scripts/check-api-tools.sh | 4 +- plugins/github-mcp/mcp-server-gh/lib/issue.sh | 137 +++++++++++++-- .../mcp-server-gh/lib/issue_schema_write.sh | 8 +- .../github-mcp/mcp-server-gh/tools-read.json | 7 +- .../github-mcp/mcp-server-gh/tools-write.json | 2 +- 10 files changed, 327 insertions(+), 25 deletions(-) create mode 100644 plugin-tests/github-mcp/read_tools_issue_view_fields.bats diff --git a/plugin-tests/github-mcp/read_tools_issue_view_fields.bats b/plugin-tests/github-mcp/read_tools_issue_view_fields.bats new file mode 100644 index 0000000..96c1a44 --- /dev/null +++ b/plugin-tests/github-mcp/read_tools_issue_view_fields.bats @@ -0,0 +1,165 @@ +#!/usr/bin/env bats +# bats file_tags=github-mcp,read-tools +# Tests for issue_view's with_field_values output +bats_require_minimum_version 1.11.0 + +load 'test_helper/common_setup' + +REST_ISSUE='{"number":19952,"type":{"id":125714,"name":"Bug"},"issue_field_values":[{"issue_field_id":8847,"issue_field_name":"Priority","data_type":"single_select","value":12298,"single_select_option":{"id":12298,"name":"Low","color":"green"}},{"issue_field_id":8849,"issue_field_name":"Target date","data_type":"date","value":"2026-03-01"},{"issue_field_id":8851,"issue_field_name":"Areas","data_type":"multi_select","multi_select_options":[{"id":13001,"name":"Storefront"},{"id":13002,"name":"Admin"}]}]}' + +setup() { + log() { :; } + GH_DEFAULT_REPO="shopware/shopware" + GH_TOOLING_CONFIG_FILE="" + source "${GH_LIB_DIR}/common.sh" + source "${GH_LIB_DIR}/issue.sh" + + GH_ARGS_FILE="${BATS_TEST_TMPDIR}/gh_args" + + # The tool makes at most two calls per run: gh issue view for the requested + # fields, gh api for the type and field values. + gh() { + printf '%s\n' "$*" >> "${GH_ARGS_FILE}" + case "$*" in + *"issue view"*) + [[ -n "${GH_STUB_VIEW_EXIT:-}" ]] && return "${GH_STUB_VIEW_EXIT}" + printf '%s\n' "${GH_STUB_VIEW}" + ;; + *"api repos/"*) + [[ -n "${GH_STUB_REST_EXIT:-}" ]] && { printf '%s\n' "${GH_STUB_REST}"; return "${GH_STUB_REST_EXIT}"; } + printf '%s\n' "${GH_STUB_REST}" + ;; + *) + printf '%s\n' "" + ;; + esac + return 0 + } + GH_STUB_VIEW='{"title":"Broken thing","state":"OPEN"}' + GH_STUB_REST="${REST_ISSUE}" + GH_STUB_VIEW_EXIT="" + GH_STUB_REST_EXIT="" +} + +@test "issue_view with_field_values returns the type and field values keyed by name" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.type')" "Bug" + assert_equal "$(printf '%s' "${output}" | jq -r '.field_values.Priority')" "Low" + assert_equal "$(printf '%s' "${output}" | jq -r '.field_values["Target date"]')" "2026-03-01" + assert_equal "$(printf '%s' "${output}" | jq -rc '.field_values.Areas')" '["Storefront","Admin"]' +} + +@test "issue_view with_field_values reports a single-select by option name, not id" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true}' + assert_success + refute_output --partial "12298" +} + +@test "issue_view with_field_values skips gh issue view when no fields are requested" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true}' + assert_success + run grep -c -- "issue view" "${GH_ARGS_FILE}" + assert_output "0" +} + +@test "issue_view with_field_values merges into the requested fields" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true, "fields": "title,state"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.title')" "Broken thing" + assert_equal "$(printf '%s' "${output}" | jq -r '.state')" "OPEN" + assert_equal "$(printf '%s' "${output}" | jq -r '.type')" "Bug" + assert_equal "$(printf '%s' "${output}" | jq -r '.field_values.Priority')" "Low" +} + +@test "issue_view with_field_values queries the issue's REST endpoint" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true}' + assert_success + run grep -F -- "api repos/shopware/shopware/issues/19952" "${GH_ARGS_FILE}" + assert_success +} + +@test "issue_view with_field_values reports an empty object for an issue with no values" { + GH_STUB_REST='{"number":42,"type":null}' + run tool_issue_view '{"number": 42, "repo": "shopware/shopware", "with_field_values": true}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.type')" "null" + assert_equal "$(printf '%s' "${output}" | jq -rc '.field_values')" "{}" +} + +@test "issue_view rejects with_field_values together with with_comments" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true, "with_comments": true}' + assert_failure + assert_output --partial "separate calls" +} + +@test "issue_view applies jq_filter to the merged document" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true, "jq_filter": ".field_values.Priority"}' + assert_success + assert_output --partial "Low" +} + +@test "issue_view with_field_values reports a failed REST call" { + GH_STUB_REST="gh: Not Found (HTTP 404)" + GH_STUB_REST_EXIT=1 + run tool_issue_view '{"number": 999999, "repo": "shopware/shopware", "with_field_values": true}' + assert_failure + assert_output --partial "Not Found" +} + +@test "issue_view with_field_values honors fallback when the REST call fails" { + GH_STUB_REST="gh: Not Found (HTTP 404)" + GH_STUB_REST_EXIT=1 + run tool_issue_view '{"number": 999999, "repo": "shopware/shopware", "with_field_values": true, "fallback": "no issue"}' + assert_success + assert_output "no issue" +} + +@test "issue_view with_field_values rejects a malformed repository before calling the API" { + run tool_issue_view '{"number": 19952, "owner": "shopware/extra", "repo": "shopware", "with_field_values": true}' + assert_failure + assert_output --partial "owner/repo" + assert_equal "$(grep -c -- "api repos/" "${GH_ARGS_FILE}" 2>/dev/null || printf '0')" "0" +} + +@test "issue_view with_field_values rejects a malformed repository even when fields are requested" { + run tool_issue_view '{"number": 19952, "owner": "shopware/extra", "repo": "shopware", "with_field_values": true, "fields": "title", "fallback": "unavailable"}' + assert_failure + assert_output --partial "owner/repo" +} + +@test "issue_view with_field_values fails on two values naming the same field" { + GH_STUB_REST='{"number":1,"issue_field_values":[{"issue_field_name":"Priority","value":"High"},{"issue_field_name":"Priority","value":"Low"}]}' + run tool_issue_view '{"number": 1, "repo": "shopware/shopware", "with_field_values": true}' + assert_failure + assert_output --partial "more than one value for Priority" +} + +@test "issue_view with_field_values fails on a value with no field name" { + GH_STUB_REST='{"number":1,"issue_field_values":[{"value":"High"}]}' + run tool_issue_view '{"number": 1, "repo": "shopware/shopware", "with_field_values": true}' + assert_failure + assert_output --partial "no field name" +} + +@test "issue_view with_field_values does not answer an undecodable response with fallback" { + GH_STUB_REST='{"number":1,"issue_field_values":[{"value":"High"}]}' + run tool_issue_view '{"number": 1, "repo": "shopware/shopware", "with_field_values": true, "fallback": "unavailable"}' + assert_failure + refute_output --partial "unavailable" +} + +@test "issue_view with_field_values reports the API call's own exit status" { + GH_STUB_REST="gh: Bad credentials (HTTP 401)" + GH_STUB_REST_EXIT=4 + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "with_field_values": true}' + assert_equal "${status}" 4 +} + +@test "issue_view without with_field_values makes no REST call" { + run tool_issue_view '{"number": 19952, "repo": "shopware/shopware", "fields": "title"}' + assert_success + assert_equal "$(printf '%s' "${output}" | jq -r '.title')" "Broken thing" + run grep -c -- "api repos/" "${GH_ARGS_FILE}" + assert_output "0" +} diff --git a/plugin-tests/github-mcp/write_tools_issue_schema.bats b/plugin-tests/github-mcp/write_tools_issue_schema.bats index b44ab06..053fe36 100644 --- a/plugin-tests/github-mcp/write_tools_issue_schema.bats +++ b/plugin-tests/github-mcp/write_tools_issue_schema.bats @@ -164,11 +164,11 @@ body() { jq -c "$1" "${GH_BODY_FILE}"; } assert_output "unchanged" } -@test "issue_field_set shapes the response into field/value pairs" { - GH_STUB_OUTPUT='[{"issue_field_name":"Priority","value":12296,"single_select_option":{"id":12296,"name":"High"}},{"issue_field_name":"Start date","value":"2026-09-30"}]' +@test "issue_field_set shapes the response like the values it takes" { + GH_STUB_OUTPUT='[{"issue_field_name":"Priority","value":12296,"single_select_option":{"id":12296,"name":"High"}},{"issue_field_name":"Start date","value":"2026-09-30"},{"issue_field_name":"Areas","multi_select_options":[{"id":13001,"name":"Storefront"}]}]' run tool_issue_field_set '{"number": 19952, "values": {"Priority": "High"}}' assert_success - assert_equal "$(printf '%s' "${output}" | jq -c '.')" '[{"field":"Priority","value":"High"},{"field":"Start date","value":"2026-09-30"}]' + assert_equal "$(printf '%s' "${output}" | jq -c '.')" '{"Priority":"High","Start date":"2026-09-30","Areas":["Storefront"]}' } @test "issue_field_set resolves a multi-select array to canonical option names" { diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index 388ca3c..772312a 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -11,9 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `issue_schema` read tool. Returns an organization's issue types and issue fields in one call, with the options of every single-select field, so an agent can look up the exact names before setting an issue's type or field values. The organization is resolved from `org`, `owner`, a repository parameter, the configured default repo, or the current clone. `type` and `field` each match one name case-insensitively and narrow only their own list; a name that matches nothing is an error rather than an empty list. Backed by `orgs/{org}/issue-types` and `orgs/{org}/issue-fields`. - Dedicated-tool enforcement for `orgs/{org}/issue-types` and `orgs/{org}/issue-fields` in `check-api-tools.sh`, active when `block_api_tool_read` is enabled. - `issue_type_set` write tool. Sets an issue's type by name, or clears it with `null`. The name is matched case-insensitively against the organization's types and sent in its canonical spelling; an unknown name fails before the write, listing the available types. -- `issue_field_set` write tool. Replaces an issue's field values with the given `values` object, keyed by field name — a field left out is cleared, and `{}` clears them all. Field and option names resolve to IDs against the organization's schema, and each value is checked against its field's data type (option name, array of option names for multi-select, `YYYY-MM-DD`, number, string) before the request, because GitHub reports an unknown field ID as a wrong option name. +- `issue_field_set` write tool. Replaces an issue's field values with the given `values` object, keyed by field name — a field left out is cleared, and `{}` clears them all. Field and option names resolve to IDs against the organization's schema, and each value is checked against its field's data type (option name, array of option names for multi-select, `YYYY-MM-DD`, number, string) before the request, because GitHub reports an unknown field ID as a wrong option name. The response repeats the resulting values in the same shape the parameter takes — keyed by field name, with option names rather than option IDs — so it can be edited and sent back as the next call's `values`. +- `with_field_values` on `issue_view`. Returns the issue's type name and its field values as `{"type": ..., "field_values": {...}}`, keyed by field name and reporting select options by name. `gh issue view` exposes neither the type by default nor the field values at all, and the REST issue reports a single-select as an option ID while writes take the option name, so this is the read that pairs with `issue_field_set`: read the object, change an entry, pass the whole object back. Output is always JSON; `fields` adds gh's own fields to the same document, and `with_comments` is refused alongside it because that output is text. - `pattern` constraints on the repository and organization parameters of the three new tools: `issue_type_set.repo` and `issue_field_set.repo` carry the `owner/repo` shape that `_gh_validate_repo` enforces, and `issue_schema.org` and `issue_schema.owner` carry the login shape that `_gh_validate_org` enforces. The vendored SDK enforces `pattern` on string values, so a malformed repository or organization is now rejected before dispatch; the tool functions validate these values as well, which is what still holds for a call that reaches them another way. `issue_schema.repo` is deliberately left unconstrained because it also accepts a bare repository name alongside `owner`. -- Dedicated-tool enforcement for `issues/{n}/issue-field-values` and `PATCH repos/{owner}/{repo}/issues/{n}` in `check-api-tools.sh`. A write of an issue's field values routes to `issue_field_set` under `block_api_tool_write`; a `GET` of the same path routes to `issue_view`, which carries the values inline, under `block_api_tool_read`. +- Dedicated-tool enforcement for `issues/{n}/issue-field-values` and `PATCH repos/{owner}/{repo}/issues/{n}` in `check-api-tools.sh`. A write of an issue's field values routes to `issue_field_set` under `block_api_tool_write`; a `GET` of the same path routes to `issue_view` with `with_field_values`, under `block_api_tool_read`. ### Changed - `shared/mcpserver_core.sh` is now vendored from [shopwareLabs/bash-mcp-sdk](https://github.com/shopwareLabs/bash-mcp-sdk) `v2.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. diff --git a/plugins/github-mcp/REFERENCE.md b/plugins/github-mcp/REFERENCE.md index 993f948..f039504 100644 --- a/plugins/github-mcp/REFERENCE.md +++ b/plugins/github-mcp/REFERENCE.md @@ -142,8 +142,20 @@ View a GitHub issue. Use gh-tooling issue_view with number 8498 Use gh-tooling issue_view with number 8498 and with_comments true Use gh-tooling issue_view with number 8498 and fields "title,body,state,labels,comments" +Use gh-tooling issue_view with number 8498 and with_field_values true ``` +`with_field_values` returns the issue's type and its field values keyed by field +name, which `gh issue view` exposes for neither: + +```json +{"type": "Bug", "field_values": {"Priority": "Low", "Effort": "Low"}} +``` + +The `field_values` object is the shape `issue_field_set` takes, so it can be read, +edited, and passed straight back. Output is always JSON; combine it with `fields` +to add gh's own fields to the same document, and request comments separately. + ### `issue_list` List issues with filters. @@ -798,7 +810,9 @@ Use gh-tooling-write issue_type_set with number 19952 and type null Replace an issue's field values. The `values` object becomes the issue's **complete** set: a field left out is cleared, and `{}` clears them all. To change one field without dropping the others, read -the current values with `issue_view` first and pass them back alongside the change. +the current values with `issue_view` and `with_field_values true`, edit the entry you want, and pass +the whole object back — its `field_values` object and this `values` object are the same shape, and so +is this tool's own response. Values are typed by the field: a single-select takes an option name, a multi-select takes an array of option names, a date takes `YYYY-MM-DD`, a number takes a number, and a text field takes a string. diff --git a/plugins/github-mcp/hooks/prompts/write-operations-enabled.md b/plugins/github-mcp/hooks/prompts/write-operations-enabled.md index 5a616bc..b96217b 100644 --- a/plugins/github-mcp/hooks/prompts/write-operations-enabled.md +++ b/plugins/github-mcp/hooks/prompts/write-operations-enabled.md @@ -2,7 +2,7 @@ PRs: pr_create, pr_edit, pr_ready, pr_merge, pr_close, pr_reopen Reviews: pr_review_submit, pr_comment, pr_review_reply Issues: issue_create, issue_edit, issue_close, issue_reopen, issue_comment -Issue type and fields: issue_type_set, issue_field_set (issue_field_set replaces the issue's whole set of field values; read the current ones first) +Issue type and fields: issue_type_set, issue_field_set (issue_field_set replaces the issue's whole set of field values; read them first with issue_view and with_field_values true) Labels: label_add, label_remove Assignees: assignee_add, assignee_remove Sub-issues: sub_issue_add, sub_issue_remove diff --git a/plugins/github-mcp/hooks/scripts/check-api-tools.sh b/plugins/github-mcp/hooks/scripts/check-api-tools.sh index cb5f720..ca3830d 100755 --- a/plugins/github-mcp/hooks/scripts/check-api-tools.sh +++ b/plugins/github-mcp/hooks/scripts/check-api-tools.sh @@ -107,9 +107,9 @@ if echo "$ENDPOINT" | grep -qE 'labels(\?|$)'; then block_tool "label_list" "Use label_list with optional repo and filter parameters." fi -# Issue field values on one issue — issue_view returns them inline +# Issue field values on one issue if echo "$ENDPOINT" | grep -qE 'issues/[0-9]+/issue-field-values'; then - block_tool "issue_view" "Use issue_view with number. The response carries the issue's field values under issue_field_values." + block_tool "issue_view" "Use issue_view with number and with_field_values true. It returns the issue's type and field values keyed by field name." fi # Organization issue types and issue fields diff --git a/plugins/github-mcp/mcp-server-gh/lib/issue.sh b/plugins/github-mcp/mcp-server-gh/lib/issue.sh index 5b9f110..cf9079e 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/issue.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/issue.sh @@ -2,15 +2,17 @@ # Issue tools for gh-tooling MCP server # Tools: issue_view, issue_list -# View a GitHub issue with optional comments. +# View a GitHub issue with optional comments, type, and field values. # Maps to: gh issue view [--repo owner/repo] [--json ] [--comments] +# and gh api repos//issues/ for the type and field values tool_issue_view() { local args="$1" - local number fields with_comments jq_filter suppress_errors fallback max_lines + local number fields with_comments with_field_values jq_filter suppress_errors fallback max_lines number=$(echo "${args}" | jq -r '.number // empty') fields=$(echo "${args}" | jq -r '.fields // empty') with_comments=$(echo "${args}" | jq -r '.with_comments // false') + with_field_values=$(echo "${args}" | jq -r '.with_field_values // false') 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') @@ -20,6 +22,10 @@ tool_issue_view() { echo "Error: number is required for issue_view" return 1 fi + if [[ "${with_field_values}" == "true" && "${with_comments}" == "true" ]]; then + echo "Error: with_field_values returns JSON and with_comments returns text; request them in separate calls" + return 1 + fi _gh_validate_number "${number}" "number" || return 1 _gh_validate_jq_filter "${jq_filter}" || return 1 @@ -28,18 +34,86 @@ tool_issue_view() { [[ -n "${_GH_OWNER}" ]] && effective_repo="${_GH_OWNER}/${_GH_REPO}" _gh_require_repo_or_git "${effective_repo}" || return 1 - local -a cmd=("gh" "issue" "view" "${number}") + # The REST path is built by concatenation below, unlike --repo which gh + # parses itself, so a resolved repo that is not owner/repo is rejected before + # any call: the split form only checks that `repo` has no slash, and letting + # it reach gh would turn an input error into a fallback success. + if [[ "${with_field_values}" == "true" ]]; then + _gh_validate_repo "${effective_repo}" || return 1 + fi - if [[ -n "${effective_repo}" ]]; then - cmd+=("--repo" "${effective_repo}") + local doc="{}" + + # gh issue view has nothing to contribute when the caller asked only for the + # type and field values, which come from the REST issue instead. + if [[ -n "${fields}" || "${with_field_values}" != "true" ]]; then + local -a cmd=("gh" "issue" "view" "${number}") + + if [[ -n "${effective_repo}" ]]; then + cmd+=("--repo" "${effective_repo}") + fi + + if [[ -n "${fields}" ]]; then + cmd+=("--json" "${fields}") + elif [[ "${with_comments}" == "true" ]]; then + cmd+=("--comments") + fi + + log "INFO" "issue_view: ${cmd[*]}" + local __raw __exit=0 + if [[ "${suppress_errors}" == "true" ]]; then + __raw=$("${cmd[@]}" 2>/dev/null) || __exit=$? + else + __raw=$("${cmd[@]}" 2>&1) || __exit=$? + fi + if [[ ${__exit} -ne 0 ]]; then + [[ -n "${fallback}" ]] && { echo "${fallback}"; return 0; } + echo "${__raw}"; return ${__exit} + fi + + if [[ "${with_field_values}" != "true" ]]; then + _gh_post_process "${__raw}" "${jq_filter}" "" 0 0 false false "${max_lines}" "" || return $? + return 0 + fi + doc="${__raw}" fi - if [[ -n "${fields}" ]]; then - cmd+=("--json" "${fields}") - elif [[ "${with_comments}" == "true" ]]; then - cmd+=("--comments") + local rest __fv_exit=0 + rest=$(_gh_issue_rest_issue "${effective_repo}" "${number}" "${suppress_errors}") || __fv_exit=$? + if [[ ${__fv_exit} -ne 0 ]]; then + [[ -n "${fallback}" ]] && { echo "${fallback}"; return 0; } + echo "${rest}"; return ${__fv_exit} fi + # A response this tool cannot decode is its own failure, not the failed API + # call fallback stands in for, so it is reported either way. + local extra + extra=$(_gh_issue_field_values "${rest}") || { + echo "${extra}" + return 1 + } + + # The requested fields go in on stdin: an issue body or comment thread can be + # larger than a command line holds. + local merged + merged=$(printf '%s' "${doc}" | jq --argjson extra "${extra}" '. + $extra') || { + echo "Error: could not merge the issue's field values into the requested fields" + return 1 + } + + _gh_post_process "${merged}" "${jq_filter}" "" 0 0 false false "${max_lines}" "" || return $? +} + +# Fetch an issue's REST representation, which carries the type and field values +# that gh issue view does not expose. +_gh_issue_rest_issue() { + local effective_repo="$1" number="$2" suppress_errors="$3" + + local path="repos/{owner}/{repo}/issues/${number}" + [[ -n "${effective_repo}" ]] && path="repos/${effective_repo}/issues/${number}" + + local -a cmd=("gh" "api" "${path}") + log "INFO" "issue_view: ${cmd[*]}" local __raw __exit=0 if [[ "${suppress_errors}" == "true" ]]; then @@ -48,10 +122,49 @@ tool_issue_view() { __raw=$("${cmd[@]}" 2>&1) || __exit=$? fi if [[ ${__exit} -ne 0 ]]; then - [[ -n "${fallback}" ]] && { echo "${fallback}"; return 0; } - echo "${__raw}"; return ${__exit} + echo "${__raw}" + return ${__exit} + fi + echo "${__raw}" +} + +# Reduce a REST issue to the type name and the field values in the shape +# issue_field_set takes. The REST issue reports a single-select value as an +# option id while writes take the option name, so the names are read off the +# response's option objects rather than the raw value. Two values naming the +# same field are an error rather than a silent collapse: keying by name is what +# makes the result writable, and from_entries would keep only the last. +_gh_issue_field_values() { + local rest="$1" + + local decoded + decoded=$(printf '%s' "${rest}" | jq ' + [(.issue_field_values // [])[] | { + key: .issue_field_name, + value: (if .multi_select_options != null then [.multi_select_options[].name] + elif .single_select_option != null then .single_select_option.name + else .value end) + }] as $entries + | [$entries[].key] as $names + | if ($names | any(. == null)) then + {_error: "the response holds a field value with no field name"} + elif ($names | length) != ($names | unique | length) then + {_error: "the response holds more than one value for \($names | group_by(.) | map(select(length > 1)) | map(.[0]) | join(", "))"} + else + {type: (.type.name // null), field_values: ($entries | from_entries)} + end') || { + echo "Error: could not read the issue's type and field values" + return 1 + } + + local decode_error + decode_error=$(printf '%s' "${decoded}" | jq -r '._error // empty') + if [[ -n "${decode_error}" ]]; then + echo "Error: could not read the issue's field values: ${decode_error}" + return 1 fi - _gh_post_process "${__raw}" "${jq_filter}" "" 0 0 false false "${max_lines}" "" || return $? + + printf '%s\n' "${decoded}" } # List issues with optional filters. diff --git a/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh b/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh index a459ad1..8c4b64e 100644 --- a/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh +++ b/plugins/github-mcp/mcp-server-gh/lib/issue_schema_write.sh @@ -205,7 +205,8 @@ tool_issue_type_set() { # Arguments: # JSON args string. # Outputs: -# The resulting field values as JSON, or an error message. +# The resulting field values as JSON, keyed by field name so the response can +# be passed straight back as the next call's 'values', or an error message. # Returns: # 0 on success, non-zero on validation or gh failure. ####################################### @@ -252,7 +253,10 @@ tool_issue_field_set() { } _gh_issue_schema_write "PUT" "repos/${effective_repo}/issues/${number}/issue-field-values" \ - "${body}" '[.[] | {field: .issue_field_name, value: (.single_select_option.name // .value)}]' \ + "${body}" '[.[] | {key: .issue_field_name, value: ( + if .multi_select_options != null then [.multi_select_options[].name] + elif .single_select_option != null then .single_select_option.name + else .value end)}] | from_entries' \ "issue_field_set" "${suppress_errors}" "${fallback}" } diff --git a/plugins/github-mcp/mcp-server-gh/tools-read.json b/plugins/github-mcp/mcp-server-gh/tools-read.json index ea4ba29..37121ef 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-read.json +++ b/plugins/github-mcp/mcp-server-gh/tools-read.json @@ -454,7 +454,7 @@ }, { "name": "issue_view", - "description": "View a GitHub issue including title, body, state, labels, assignees, and milestone. Use to understand bug reports, feature requests, or task descriptions. Pass 'with_comments' to include discussion thread. Repository: pass `repo`, `repository`, or `owner`+`repo`.", + "description": "View a GitHub issue including title, body, state, labels, assignees, and milestone. Use to understand bug reports, feature requests, or task descriptions. Pass 'with_comments' to include discussion thread, or 'with_field_values' for the issue's type and field values. Repository: pass `repo`, `repository`, or `owner`+`repo`.", "inputSchema": { "type": "object", "required": [ @@ -481,6 +481,11 @@ "description": "Include issue comments in text output. Only applies when 'fields' is not set.", "default": false }, + "with_field_values": { + "type": "boolean", + "description": "Return the issue's type and field values as JSON: {\"type\": \"Bug\", \"field_values\": {\"Priority\": \"Low\"}}. The 'field_values' object is exactly what issue_field_set takes, so read it first, change the entries you want, and pass the whole object back. Output is always JSON; combine with 'fields' to add gh's own fields, and do not combine with 'with_comments'.", + "default": false + }, "jq_filter": { "type": "string", "description": "jq expression to filter/transform the output. Syntax is validated before execution. Useful for shaping JSON responses or reducing response size before the token cap." diff --git a/plugins/github-mcp/mcp-server-gh/tools-write.json b/plugins/github-mcp/mcp-server-gh/tools-write.json index d2ac626..d7ac000 100644 --- a/plugins/github-mcp/mcp-server-gh/tools-write.json +++ b/plugins/github-mcp/mcp-server-gh/tools-write.json @@ -785,7 +785,7 @@ }, { "name": "issue_field_set", - "description": "Replace an issue's field values with the given set. The 'values' object becomes the issue's complete set of field values: a field left out is cleared, and {} clears them all. Read the current values with issue_view before setting one field, or the others are dropped. Field and option names are matched case-insensitively; use issue_schema to list the available fields and options. Issue fields apply to issues only, not pull requests.", + "description": "Replace an issue's field values with the given set. The 'values' object becomes the issue's complete set of field values: a field left out is cleared, and {} clears them all. Read the current values with issue_view and 'with_field_values' before setting one field, or the others are dropped: its 'field_values' object is this parameter's shape, so change the entries you want and pass the whole object back. Field and option names are matched case-insensitively; use issue_schema to list the available fields and options. Issue fields apply to issues only, not pull requests.", "inputSchema": { "type": "object", "required": ["number", "values"],