feat(github-mcp): read and write GitHub issue types and issue fields - #6
Merged
Merged
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_viewgains the read that pairs with the writes.The tools
issue_schemaissue_viewwith_field_valuesreturns one issue's own type and field values, keyed by field nameissue_type_setnullissue_field_setBacked by
orgs/{org}/issue-types,orgs/{org}/issue-fields,GET|PATCH repos/{owner}/{repo}/issues/{n}, andPUT repos/{owner}/{repo}/issues/{n}/issue-field-values. No GraphQL.Two write tools rather than three
PUTon 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
Priorityon an issue that also hadEffortclearsEffort.issue_viewwithwith_field_valuesis that read:gh issue viewexposes neither the field values nor, by default, the type, so the read had to be built alongside the writes rather than assumed. Itsfield_valuesobject isissue_field_set'svaluesparameter, so a caller reads it, changes an entry, and passes the whole object back. Amergemode 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: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_viewreads the option names off the response's option objects rather than passing the raw ids through, andissue_field_setechoes 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_schemareports 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: nulland an empty field list, matching GitHub's own statement.Schema constraints
The last commit declares
patternon 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:issue_schema.repois deliberately left unconstrained: it also accepts a bare repository name alongsideowner, and a pattern requiring a slash would reject that split form.issue_viewbuilds its REST path by concatenation rather than handing a repository togh, so it validates the resolvedowner/repobefore the call: letting a malformed one through would turn an input error into afallbacksuccess.