Skip to content

feat(github-mcp): read and write GitHub issue types and issue fields - #6

Merged
Martin Bens (SpiGAndromeda) merged 6 commits into
mainfrom
feat/issue-types-and-fields
Sep 3, 2026
Merged

feat(github-mcp): read and write GitHub issue types and issue fields#6
Martin Bens (SpiGAndromeda) merged 6 commits into
mainfrom
feat/issue-types-and-fields

Conversation

@SpiGAndromeda

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

Copy link
Copy Markdown
Collaborator

Adds three tools for GitHub's issue types and issue fields: one read tool to discover what an organization offers, and two write tools to set them on an issue. issue_view gains the read that pairs with the writes.

The tools

Tool Server Purpose
issue_schema read An organization's issue types and issue fields in one call, with each single-select field's options
issue_view read with_field_values returns one issue's own type and field values, keyed by field name
issue_type_set write Set an issue's type by name, or clear it with null
issue_field_set write Replace an issue's field values
issue_schema {"org":"shopware","type":"Bug","jq_filter":"{types:[.types[].name],fields:[.fields[].name]}"}
→ {"types":["Bug"],"fields":["Priority","Start date","Target date","Effort"]}

issue_view {"number":19952,"with_field_values":true}
→ {"type":"Bug","field_values":{"Priority":"Low","Effort":"Low"}}

Backed by orgs/{org}/issue-types, orgs/{org}/issue-fields, GET|PATCH repos/{owner}/{repo}/issues/{n}, and PUT repos/{owner}/{repo}/issues/{n}/issue-field-values. No GraphQL.

Two write tools rather than three

PUT on the field-values endpoint is replace-all, so clearing one field means leaving it out and {} clears them all. That makes a separate clear tool redundant, and both tools are idempotent: sending the same call twice leaves the issue in the same state.

The cost is that changing one field is a read-then-write — a caller who passes only Priority on an issue that also had Effort clears Effort. issue_view with with_field_values is that read: gh issue view exposes neither the field values nor, by default, the type, so the read had to be built alongside the writes rather than assumed. Its field_values object is issue_field_set's values parameter, so a caller reads it, changes an entry, and passes the whole object back. A merge mode that folds a change into the current values would drop the read step entirely and stays idempotent; left out deliberately, easy to add later.

Names, not IDs

Reads return a single-select option's id ("value": 12296) while writes take its name ("value": "High"), so feeding a read straight back into a write fails. Both tools take names on both sides and resolve them against the organization's schema before the request, so a wrong name fails locally with the valid ones listed:

issue_field_set {"Prioriti":"High"}  → issue field Prioriti not found. Available fields: Priority, Start date, Target date, Effort
issue_field_set {"Priority":"Urgent"} → option Urgent not found for issue field Priority. Available options: Critical, High, Low
issue_type_set  {"type":"Bogus"}      → issue type 'Bogus' not found in 'shopware'. Available types: Task, Bug, Improvement, Story, Epic

That matters most for fields: GitHub reports an unknown field id as "option with name x does not exist", blaming the value rather than the field. Values are checked against their field's data type as well, and every rejected entry is reported in one message rather than one round trip each.

issue_view reads the option names off the response's option objects rather than passing the raw ids through, and issue_field_set echoes the resulting values in the same keyed-by-name shape, so every point of a read-edit-write agrees. A response holding two values for one field is an error rather than a merge: 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.

Types and fields are not 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, confirmed against the API. So issue_schema reports the two lists side by side; nesting fields under types would imply a constraint the API does not enforce.

Issue types and issue fields apply to issues only. A pull request reads back type: null and an empty field list, matching GitHub's own statement.

Schema constraints

The last commit declares pattern on the repository and organization parameters, matching what the tool functions already validate. With the vendored SDK the schema is the first gate and the tool function the second:

max_lines: "PATH[$(id)0]"          → Invalid type(s): max_lines expected integer, got string
org: "../../repos/victim/private"  → does not match pattern ^[A-Za-z0-9][A-Za-z0-9-]*$
repo: "widgets"                    → does not match pattern ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$
values: null                       → expected object, got null

issue_schema.repo is deliberately left unconstrained: it also accepts a bare repository name alongside owner, and a pattern requiring a slash would reject that split form. issue_view builds its REST path by concatenation rather than handing a repository to gh, so it validates the resolved owner/repo before the call: letting a malformed one through would turn an input error into a fallback success.

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@SpiGAndromeda Martin Bens (SpiGAndromeda) changed the title Feat/issue types and fields feat(github-mcp): read and write GitHub issue types and issue fields Sep 2, 2026
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) <noreply@anthropic.com>
@SpiGAndromeda
Martin Bens (SpiGAndromeda) merged commit f41d875 into main Sep 3, 2026
2 checks passed
@SpiGAndromeda
Martin Bens (SpiGAndromeda) deleted the feat/issue-types-and-fields branch September 3, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant