Files
github--github-mcp-server/pkg/github/issues_granular.go
Iulia B 3d240f4526
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Build and Test Go Project / build (macos-latest) (push) Has been cancelled
Build and Test Go Project / build (ubuntu-latest) (push) Has been cancelled
Build and Test Go Project / build (windows-latest) (push) Has been cancelled
Support type intent (rationale/confidence/suggestion) in issue write tools
2026-06-10 14:39:44 +00:00

1295 lines
46 KiB
Go

package github
import (
"context"
"encoding/json"
"fmt"
"maps"
"strings"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v87/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
)
// issueUpdateTool is a helper to create single-field issue update tools.
func issueUpdateTool(
t translations.TranslationHelperFunc,
name, description, title string,
extraProps map[string]*jsonschema.Schema,
extraRequired []string,
buildRequest func(args map[string]any) (*github.IssueRequest, error),
) inventory.ServerTool {
props := map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"issue_number": {
Type: "number",
Description: "The issue number to update",
Minimum: jsonschema.Ptr(1.0),
},
}
maps.Copy(props, extraProps)
required := append([]string{"owner", "repo", "issue_number"}, extraRequired...)
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: name,
Description: t("TOOL_"+strings.ToUpper(name)+"_DESCRIPTION", description),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_"+strings.ToUpper(name)+"_USER_TITLE", title),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: props,
Required: required,
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueNumber, err := RequiredInt(args, "issue_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueReq, err := buildRequest(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
issue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueReq)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
r, err := json.Marshal(MinimalResponse{
ID: fmt.Sprintf("%d", issue.GetID()),
URL: issue.GetHTMLURL(),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
st.FeatureFlagEnable = FeatureFlagIssuesGranular
return st
}
// GranularCreateIssue creates a tool to create a new issue.
func GranularCreateIssue(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "create_issue",
Description: t("TOOL_CREATE_ISSUE_DESCRIPTION", "Create a new issue in a GitHub repository with a title and optional body."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_CREATE_ISSUE_USER_TITLE", "Create Issue"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"title": {
Type: "string",
Description: "Issue title",
},
"body": {
Type: "string",
Description: "Issue body content (optional)",
},
},
Required: []string{"owner", "repo", "title"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
title, err := RequiredParam[string](args, "title")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
body, _ := OptionalParam[string](args, "body")
issueReq := &github.IssueRequest{
Title: &title,
}
if body != "" {
issueReq.Body = &body
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
issue, resp, err := client.Issues.Create(ctx, owner, repo, issueReq)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create issue", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
r, err := json.Marshal(MinimalResponse{
ID: fmt.Sprintf("%d", issue.GetID()),
URL: issue.GetHTMLURL(),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
st.FeatureFlagEnable = FeatureFlagIssuesGranular
return st
}
// GranularUpdateIssueTitle creates a tool to update an issue's title.
func GranularUpdateIssueTitle(t translations.TranslationHelperFunc) inventory.ServerTool {
return issueUpdateTool(t,
"update_issue_title",
"Update the title of an existing issue.",
"Update Issue Title",
map[string]*jsonschema.Schema{
"title": {Type: "string", Description: "The new title for the issue"},
},
[]string{"title"},
func(args map[string]any) (*github.IssueRequest, error) {
title, err := RequiredParam[string](args, "title")
if err != nil {
return nil, err
}
return &github.IssueRequest{Title: &title}, nil
},
)
}
// GranularUpdateIssueBody creates a tool to update an issue's body.
func GranularUpdateIssueBody(t translations.TranslationHelperFunc) inventory.ServerTool {
return issueUpdateTool(t,
"update_issue_body",
"Update the body content of an existing issue.",
"Update Issue Body",
map[string]*jsonschema.Schema{
"body": {Type: "string", Description: "The new body content for the issue"},
},
[]string{"body"},
func(args map[string]any) (*github.IssueRequest, error) {
body, err := RequiredParam[string](args, "body")
if err != nil {
return nil, err
}
return &github.IssueRequest{Body: &body}, nil
},
)
}
// GranularUpdateIssueAssignees creates a tool to update an issue's assignees.
func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventory.ServerTool {
return issueUpdateTool(t,
"update_issue_assignees",
"Update the assignees of an existing issue. This replaces the current assignees with the provided list.",
"Update Issue Assignees",
map[string]*jsonschema.Schema{
"assignees": {
Type: "array",
Description: "GitHub usernames to assign to this issue",
Items: &jsonschema.Schema{Type: "string"},
},
},
[]string{"assignees"},
func(args map[string]any) (*github.IssueRequest, error) {
if _, ok := args["assignees"]; !ok {
return nil, fmt.Errorf("missing required parameter: assignees")
}
assignees, err := OptionalStringArrayParam(args, "assignees")
if err != nil {
return nil, err
}
return &github.IssueRequest{Assignees: &assignees}, nil
},
)
}
// maxIntentRationaleLength bounds the free-text rationale an agent may attach to
// a value it writes to an issue.
const maxIntentRationaleLength = 280
// valueIntent holds the optional reasoning metadata an agent can attach to a
// value it writes to an issue: a free-text rationale, a confidence level, and a
// flag marking the value as a suggestion rather than an applied value. It is
// embedded into the object form of a written value (today labels; issue types
// and field values in future) so the same metadata travels with each value on
// the wire.
type valueIntent struct {
Rationale string `json:"rationale,omitempty"`
Confidence string `json:"confidence,omitempty"`
Suggest bool `json:"suggest,omitempty"`
}
// HasIntent reports whether any intent metadata was supplied.
func (v valueIntent) HasIntent() bool {
return v.Rationale != "" || v.Confidence != "" || v.Suggest
}
// parseValueIntent extracts the shared intent metadata (rationale, confidence,
// is_suggestion) from the object form of a written value. It trims and
// length-checks the rationale, validates confidence against the allowed levels,
// and reports whether any intent field was present.
func parseValueIntent(obj map[string]any) (valueIntent, bool, error) {
rationale, err := OptionalParam[string](obj, "rationale")
if err != nil {
return valueIntent{}, false, err
}
rationale = strings.TrimSpace(rationale)
if len([]rune(rationale)) > maxIntentRationaleLength {
return valueIntent{}, false, fmt.Errorf("rationale must be %d characters or less", maxIntentRationaleLength)
}
confidence, err := OptionalParam[string](obj, "confidence")
if err != nil {
return valueIntent{}, false, err
}
switch confidence {
case "", "low", "medium", "high":
default:
return valueIntent{}, false, fmt.Errorf("confidence must be one of: low, medium, high")
}
suggest, err := OptionalParam[bool](obj, "is_suggestion")
if err != nil {
return valueIntent{}, false, err
}
intent := valueIntent{Rationale: rationale, Confidence: confidence, Suggest: suggest}
return intent, intent.HasIntent(), nil
}
// intentSchemaProperties returns the shared schema properties (rationale,
// confidence, is_suggestion) describing the reasoning metadata an agent can
// attach to a written value. The same properties are reused across value types
// (today labels; issue types and field values in future).
func intentSchemaProperties() map[string]*jsonschema.Schema {
return map[string]*jsonschema.Schema{
"rationale": {
Type: "string",
Description: "A concise explanation of what specifically about the issue led you to this choice. " +
"State the concrete signal (e.g. 'Reports a crash when saving' → bug).",
MaxLength: jsonschema.Ptr(maxIntentRationaleLength),
},
"confidence": {
Type: "string",
Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.",
Enum: []any{"low", "medium", "high"},
},
"is_suggestion": {
Type: "boolean",
Description: "If true, this value is sent to the API as a suggestion rather than an applied value. " +
"Whether it is applied or recorded as a proposal is determined by the API. Only honored when updating an existing issue.",
},
}
}
// withIntentProperties merges the shared intent schema properties into props and
// returns props, so a value's object schema (e.g. a label's {name}) can offer
// the same rationale/confidence/is_suggestion fields.
func withIntentProperties(props map[string]*jsonschema.Schema) map[string]*jsonschema.Schema {
maps.Copy(props, intentSchemaProperties())
return props
}
// valueWithIntent is the object form of a written value: an identity field whose
// JSON key varies by value type (e.g. "name" for a label) plus optional intent
// metadata. It marshals to a single object merging the identity field with the
// rationale/confidence/suggest fields, so the same structure serves any value
// type that travels as a named object with intent.
type valueWithIntent struct {
identityKey string
identity string
valueIntent
}
// MarshalJSON renders the value as a single object with the identity field under
// its configured key alongside the embedded intent metadata.
func (v valueWithIntent) MarshalJSON() ([]byte, error) {
data, err := json.Marshal(v.valueIntent)
if err != nil {
return nil, err
}
obj := map[string]any{}
if err := json.Unmarshal(data, &obj); err != nil {
return nil, err
}
obj[v.identityKey] = v.identity
return json.Marshal(obj)
}
// labelsUpdateRequest is a custom request body for updating an issue's labels
// where individual labels may optionally include intent metadata. Each element
// of Labels is either a string (label name) or a valueWithIntent object.
type labelsUpdateRequest struct {
Labels []any `json:"labels"`
}
// parseParamWithIntent parses an array parameter whose entries are either plain
// identity strings or objects carrying intent metadata (an identity field named
// by identityField plus optional rationale, confidence, is_suggestion). param is
// the argument key (e.g. "labels") and identityField is the identity key within
// each object (e.g. "name").
//
// It returns the plain identities (intent stripped), the wire payload (a mix of
// plain identity strings and valueWithIntent objects, with intent objects only
// for entries that carry intent), whether any entry carried intent, whether the
// parameter was provided at all, and an error. It is reusable across value types
// that travel as named objects with intent.
func parseParamWithIntent(args map[string]any, param, identityField string) (identities []string, payload []any, hasIntent bool, provided bool, err error) {
raw, ok := args[param]
if !ok || raw == nil {
return []string{}, nil, false, false, nil
}
var entries []any
switch v := raw.(type) {
case []any:
entries = v
case []string:
entries = make([]any, len(v))
for i, s := range v {
entries[i] = s
}
default:
return nil, nil, false, true, fmt.Errorf("%s must be an array", param)
}
identities = make([]string, 0, len(entries))
payload = make([]any, 0, len(entries))
for _, item := range entries {
switch v := item.(type) {
case string:
identities = append(identities, v)
payload = append(payload, v)
case map[string]any:
identity, identityErr := RequiredParam[string](v, identityField)
if identityErr != nil {
return nil, nil, false, true, fmt.Errorf("each %s object must have a '%s' string", param, identityField)
}
identities = append(identities, identity)
intent, itemHasIntent, intentErr := parseValueIntent(v)
if intentErr != nil {
return nil, nil, false, true, intentErr
}
if itemHasIntent {
hasIntent = true
payload = append(payload, valueWithIntent{identityKey: identityField, identity: identity, valueIntent: intent})
} else {
payload = append(payload, identity)
}
default:
return nil, nil, false, true, fmt.Errorf("each %s entry must be a string or an object with '%s' and optional 'rationale', 'confidence', and/or 'is_suggestion'", param, identityField)
}
}
return identities, payload, hasIntent, true, nil
}
// parseValueParamWithIntent parses a single-value parameter that accepts either
// a plain string or an object carrying intent metadata. param is the argument
// key (e.g. "type"); inputField is the identity key read from the object (e.g.
// "name"); wireField is the identity key used in the object-form payload sent to
// the API (e.g. "value"). For most value types inputField and wireField match.
//
// It returns the plain identity (intent stripped), the wire payload (the plain
// identity string, or a valueWithIntent object when intent is present), whether
// the value carried intent, whether the parameter was provided at all, and an
// error. It mirrors parseParamWithIntent for value types that travel as a single
// named object rather than an array.
func parseValueParamWithIntent(args map[string]any, param, inputField, wireField string) (identity string, payload any, hasIntent bool, provided bool, err error) {
raw, ok := args[param]
if !ok || raw == nil {
return "", nil, false, false, nil
}
switch v := raw.(type) {
case string:
return v, v, false, true, nil
case map[string]any:
id, idErr := RequiredParam[string](v, inputField)
if idErr != nil {
return "", nil, false, true, fmt.Errorf("%s object must have a '%s' string", param, inputField)
}
intent, itemHasIntent, intentErr := parseValueIntent(v)
if intentErr != nil {
return "", nil, false, true, intentErr
}
if itemHasIntent {
return id, valueWithIntent{identityKey: wireField, identity: id, valueIntent: intent}, true, true, nil
}
return id, id, false, true, nil
default:
return "", nil, false, true, fmt.Errorf("%s must be a string or an object with '%s' and optional 'rationale', 'confidence', and/or 'is_suggestion'", param, inputField)
}
}
// GranularUpdateIssueLabels creates a tool to update an issue's labels.
func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "update_issue_labels",
Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_UPDATE_ISSUE_LABELS_USER_TITLE", "Update Issue Labels"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"issue_number": {
Type: "number",
Description: "The issue number to update",
Minimum: jsonschema.Ptr(1.0),
},
"labels": {
Type: "array",
Description: "Labels to apply to this issue.",
Items: &jsonschema.Schema{
OneOf: []*jsonschema.Schema{
{Type: "string", Description: "Label name"},
{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"name": {
Type: "string",
Description: "Label name",
},
"rationale": {
Type: "string",
Description: "One concise sentence explaining what specifically about the issue led you to choose this label. " +
"State the concrete signal (e.g. 'Reports a crash when saving' → bug).",
MaxLength: jsonschema.Ptr(280),
},
"confidence": {
Type: "string",
Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.",
Enum: []any{"low", "medium", "high"},
},
"is_suggestion": {
Type: "boolean",
Description: "If true, this label is sent to the API as a suggestion (suggest:true) rather than an applied label. " +
"Whether the label is applied or recorded as a proposal is determined by the API.",
},
},
Required: []string{"name"},
},
},
},
},
},
Required: []string{"owner", "repo", "issue_number", "labels"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueNumber, err := RequiredInt(args, "issue_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
names, payload, useObjectForm, provided, err := parseParamWithIntent(args, "labels", "name")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if !provided {
return utils.NewToolResultError("missing required parameter: labels"), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
var body any
if useObjectForm {
body = &labelsUpdateRequest{Labels: payload}
} else {
// Preserve the standard wire format when no rationale or suggest is supplied.
body = &github.IssueRequest{Labels: &names}
}
apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber)
req, err := client.NewRequest(ctx, "PATCH", apiURL, body)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
}
issue := &github.Issue{}
resp, err := client.Do(req, issue)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
r, err := json.Marshal(MinimalResponse{
ID: fmt.Sprintf("%d", issue.GetID()),
URL: issue.GetHTMLURL(),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
st.FeatureFlagEnable = FeatureFlagIssuesGranular
return st
}
// GranularUpdateIssueMilestone creates a tool to update an issue's milestone.
func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventory.ServerTool {
return issueUpdateTool(t,
"update_issue_milestone",
"Update the milestone of an existing issue.",
"Update Issue Milestone",
map[string]*jsonschema.Schema{
"milestone": {
Type: "integer",
Description: "The milestone number to set on the issue",
Minimum: jsonschema.Ptr(1.0),
},
},
[]string{"milestone"},
func(args map[string]any) (*github.IssueRequest, error) {
milestone, err := RequiredInt(args, "milestone")
if err != nil {
return nil, err
}
return &github.IssueRequest{Milestone: &milestone}, nil
},
)
}
// typeUpdateRequest is a custom request body for updating an issue's type with
// optional intent metadata, using the object form that the REST API accepts:
// {"type": {"value": ..., "rationale": ..., "confidence": ..., "suggest": ...}}.
type typeUpdateRequest struct {
Type valueWithIntent `json:"type"`
}
// GranularUpdateIssueType creates a tool to update an issue's type.
func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "update_issue_type",
Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_UPDATE_ISSUE_TYPE_USER_TITLE", "Update Issue Type"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: withIntentProperties(map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"issue_number": {
Type: "number",
Description: "The issue number to update",
Minimum: jsonschema.Ptr(1.0),
},
"issue_type": {
Type: "string",
Description: "The issue type to set",
},
}),
Required: []string{"owner", "repo", "issue_number", "issue_type"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueNumber, err := RequiredInt(args, "issue_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueType, err := RequiredParam[string](args, "issue_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
intent, hasIntent, err := parseValueIntent(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
var body any
if hasIntent {
body = &typeUpdateRequest{
Type: valueWithIntent{identityKey: "value", identity: issueType, valueIntent: intent},
}
} else {
body = &github.IssueRequest{Type: &issueType}
}
apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber)
req, err := client.NewRequest(ctx, "PATCH", apiURL, body)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
}
issue := &github.Issue{}
resp, err := client.Do(req, issue)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
r, err := json.Marshal(MinimalResponse{
ID: fmt.Sprintf("%d", issue.GetID()),
URL: issue.GetHTMLURL(),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
st.FeatureFlagEnable = FeatureFlagIssuesGranular
return st
}
// GranularUpdateIssueState creates a tool to update an issue's state.
func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.ServerTool {
return issueUpdateTool(t,
"update_issue_state",
"Update the state of an existing issue (open or closed), with an optional state reason.",
"Update Issue State",
map[string]*jsonschema.Schema{
"state": {
Type: "string",
Description: "The new state for the issue",
Enum: []any{"open", "closed"},
},
"state_reason": {
Type: "string",
Description: "The reason for the state change (only for closed state)",
Enum: []any{"completed", "not_planned", "duplicate"},
},
},
[]string{"state"},
func(args map[string]any) (*github.IssueRequest, error) {
state, err := RequiredParam[string](args, "state")
if err != nil {
return nil, err
}
req := &github.IssueRequest{State: &state}
stateReason, _ := OptionalParam[string](args, "state_reason")
if stateReason != "" {
req.StateReason = &stateReason
}
return req, nil
},
)
}
// GranularAddSubIssue creates a tool to add a sub-issue.
func GranularAddSubIssue(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "add_sub_issue",
Description: t("TOOL_ADD_SUB_ISSUE_DESCRIPTION", "Add a sub-issue to a parent issue."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_ADD_SUB_ISSUE_USER_TITLE", "Add Sub-Issue"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"issue_number": {
Type: "number",
Description: "The parent issue number",
Minimum: jsonschema.Ptr(1.0),
},
"sub_issue_id": {
Type: "number",
Description: "The ID of the sub-issue to add. ID is not the same as issue number",
},
"replace_parent": {
Type: "boolean",
Description: "If true, reparent the sub-issue if it already has a parent",
},
},
Required: []string{"owner", "repo", "issue_number", "sub_issue_id"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueNumber, err := RequiredInt(args, "issue_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
subIssueID, err := RequiredInt(args, "sub_issue_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
replaceParent, _ := OptionalParam[bool](args, "replace_parent")
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
result, err := AddSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, replaceParent)
return result, nil, err
},
)
st.FeatureFlagEnable = FeatureFlagIssuesGranular
return st
}
// GranularRemoveSubIssue creates a tool to remove a sub-issue.
func GranularRemoveSubIssue(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "remove_sub_issue",
Description: t("TOOL_REMOVE_SUB_ISSUE_DESCRIPTION", "Remove a sub-issue from a parent issue."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_REMOVE_SUB_ISSUE_USER_TITLE", "Remove Sub-Issue"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(true),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"issue_number": {
Type: "number",
Description: "The parent issue number",
Minimum: jsonschema.Ptr(1.0),
},
"sub_issue_id": {
Type: "number",
Description: "The ID of the sub-issue to remove. ID is not the same as issue number",
},
},
Required: []string{"owner", "repo", "issue_number", "sub_issue_id"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueNumber, err := RequiredInt(args, "issue_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
subIssueID, err := RequiredInt(args, "sub_issue_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
result, err := RemoveSubIssue(ctx, client, owner, repo, issueNumber, subIssueID)
return result, nil, err
},
)
st.FeatureFlagEnable = FeatureFlagIssuesGranular
return st
}
// GranularReprioritizeSubIssue creates a tool to reorder a sub-issue.
func GranularReprioritizeSubIssue(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "reprioritize_sub_issue",
Description: t("TOOL_REPRIORITIZE_SUB_ISSUE_DESCRIPTION", "Reprioritize (reorder) a sub-issue relative to other sub-issues."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_REPRIORITIZE_SUB_ISSUE_USER_TITLE", "Reprioritize Sub-Issue"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"issue_number": {
Type: "number",
Description: "The parent issue number",
Minimum: jsonschema.Ptr(1.0),
},
"sub_issue_id": {
Type: "number",
Description: "The ID of the sub-issue to reorder. ID is not the same as issue number",
},
"after_id": {
Type: "number",
Description: "The ID of the sub-issue to place this after (either after_id OR before_id should be specified)",
},
"before_id": {
Type: "number",
Description: "The ID of the sub-issue to place this before (either after_id OR before_id should be specified)",
},
},
Required: []string{"owner", "repo", "issue_number", "sub_issue_id"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueNumber, err := RequiredInt(args, "issue_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
subIssueID, err := RequiredInt(args, "sub_issue_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
afterID, err := OptionalIntParam(args, "after_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
beforeID, err := OptionalIntParam(args, "before_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
result, err := ReprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, afterID, beforeID)
return result, nil, err
},
)
st.FeatureFlagEnable = FeatureFlagIssuesGranular
return st
}
// SetIssueFieldValueInput represents the input for the setIssueFieldValue GraphQL mutation.
type SetIssueFieldValueInput struct {
IssueID githubv4.ID `json:"issueId"`
IssueFields []IssueFieldCreateOrUpdateInput `json:"issueFields"`
ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"`
}
// IssueFieldCreateOrUpdateInput represents a single field value to set on an issue.
type IssueFieldCreateOrUpdateInput struct {
FieldID githubv4.ID `json:"fieldId"`
TextValue *githubv4.String `json:"textValue,omitempty"`
NumberValue *githubv4.Float `json:"numberValue,omitempty"`
DateValue *githubv4.String `json:"dateValue,omitempty"`
SingleSelectOptionID *githubv4.ID `json:"singleSelectOptionId,omitempty"`
Delete *githubv4.Boolean `json:"delete,omitempty"`
Rationale *githubv4.String `json:"rationale,omitempty"`
Confidence *string `json:"confidence,omitempty"`
Suggest *githubv4.Boolean `json:"suggest,omitempty"`
}
// GranularSetIssueFields creates a tool to set issue field values on an issue using GraphQL.
func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "set_issue_fields",
Description: t("TOOL_SET_ISSUE_FIELDS_DESCRIPTION", "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_SET_ISSUE_FIELDS_USER_TITLE", "Set Issue Fields"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"issue_number": {
Type: "number",
Description: "The issue number to update",
Minimum: jsonschema.Ptr(1.0),
},
"fields": {
Type: "array",
Description: "Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value.",
MinItems: jsonschema.Ptr(1),
Items: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"field_id": {
Type: "string",
Description: "The GraphQL node ID of the issue field",
},
"text_value": {
Type: "string",
Description: "The value to set for a text field",
},
"number_value": {
Type: "number",
Description: "The value to set for a number field",
},
"date_value": {
Type: "string",
Description: "The value to set for a date field (ISO 8601 date string)",
},
"single_select_option_id": {
Type: "string",
Description: "The GraphQL node ID of the option to set for a single select field",
},
"delete": {
Type: "boolean",
Description: "Set to true to delete this field value",
},
"rationale": {
Type: "string",
Description: "One concise sentence explaining what specifically about the issue led you to choose this field value. " +
"State the concrete signal (e.g. 'Reports a crash when saving' → high priority).",
MaxLength: jsonschema.Ptr(280),
},
"confidence": {
Type: "string",
Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.",
Enum: []any{"low", "medium", "high"},
},
"is_suggestion": {
Type: "boolean",
Description: "If true, this field value is sent to the API as a suggestion (suggest:true) rather than an applied value. " +
"Whether the value is applied or recorded as a proposal is determined by the API.",
},
},
Required: []string{"field_id"},
},
},
},
Required: []string{"owner", "repo", "issue_number", "fields"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueNumber, err := RequiredInt(args, "issue_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
fieldsRaw, ok := args["fields"]
if !ok {
return utils.NewToolResultError("missing required parameter: fields"), nil, nil
}
// Accept both []any and []map[string]any input forms
var fieldMaps []map[string]any
switch v := fieldsRaw.(type) {
case []any:
for _, f := range v {
fieldMap, ok := f.(map[string]any)
if !ok {
return utils.NewToolResultError("each field must be an object with 'field_id' and a value"), nil, nil
}
fieldMaps = append(fieldMaps, fieldMap)
}
case []map[string]any:
fieldMaps = v
default:
return utils.NewToolResultError("invalid parameter: fields must be an array"), nil, nil
}
if len(fieldMaps) == 0 {
return utils.NewToolResultError("fields array must not be empty"), nil, nil
}
issueFields := make([]IssueFieldCreateOrUpdateInput, 0, len(fieldMaps))
for _, fieldMap := range fieldMaps {
fieldID, err := RequiredParam[string](fieldMap, "field_id")
if err != nil {
return utils.NewToolResultError("field_id is required and must be a string"), nil, nil
}
input := IssueFieldCreateOrUpdateInput{
FieldID: githubv4.ID(fieldID),
}
// Count how many value keys are present; exactly one is required.
valueCount := 0
if v, err := OptionalParam[string](fieldMap, "text_value"); err == nil && v != "" {
input.TextValue = githubv4.NewString(githubv4.String(v))
valueCount++
}
if v, err := OptionalParam[float64](fieldMap, "number_value"); err == nil {
if _, exists := fieldMap["number_value"]; exists {
gqlFloat := githubv4.Float(v)
input.NumberValue = &gqlFloat
valueCount++
}
}
if v, err := OptionalParam[string](fieldMap, "date_value"); err == nil && v != "" {
input.DateValue = githubv4.NewString(githubv4.String(v))
valueCount++
}
if v, err := OptionalParam[string](fieldMap, "single_select_option_id"); err == nil && v != "" {
optionID := githubv4.ID(v)
input.SingleSelectOptionID = &optionID
valueCount++
}
if _, exists := fieldMap["delete"]; exists {
del, err := OptionalParam[bool](fieldMap, "delete")
if err == nil && del {
deleteVal := githubv4.Boolean(true)
input.Delete = &deleteVal
valueCount++
}
}
if valueCount == 0 {
return utils.NewToolResultError("each field must have a value (text_value, number_value, date_value, single_select_option_id) or delete: true"), nil, nil
}
if valueCount > 1 {
return utils.NewToolResultError("each field must have exactly one value (text_value, number_value, date_value, single_select_option_id) or delete: true, but multiple were provided"), nil, nil
}
if _, exists := fieldMap["rationale"]; exists {
rationale, err := OptionalParam[string](fieldMap, "rationale")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
rationale = strings.TrimSpace(rationale)
if len([]rune(rationale)) > 280 {
return utils.NewToolResultError("field rationale must be 280 characters or less"), nil, nil
}
if rationale != "" {
input.Rationale = githubv4.NewString(githubv4.String(rationale))
}
}
confidence, err := OptionalParam[string](fieldMap, "confidence")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" {
return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil
}
if confidence != "" {
input.Confidence = &confidence
}
isSuggestion, err := OptionalParam[bool](fieldMap, "is_suggestion")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if isSuggestion {
suggestVal := githubv4.Boolean(true)
input.Suggest = &suggestVal
}
issueFields = append(issueFields, input)
}
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
}
// Resolve issue node ID
issueID, _, err := fetchIssueIDs(ctx, gqlClient, owner, repo, issueNumber, 0)
if err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue", err), nil, nil
}
// Execute the setIssueFieldValue mutation
var mutation struct {
SetIssueFieldValue struct {
Issue struct {
ID githubv4.ID
Number githubv4.Int
URL githubv4.String
}
IssueFieldValues []struct {
TextValue struct {
Value string
} `graphql:"... on IssueFieldTextValue"`
SingleSelectValue struct {
Name string
} `graphql:"... on IssueFieldSingleSelectValue"`
DateValue struct {
Value string
} `graphql:"... on IssueFieldDateValue"`
NumberValue struct {
Value float64
} `graphql:"... on IssueFieldNumberValue"`
}
} `graphql:"setIssueFieldValue(input: $input)"`
}
mutationInput := SetIssueFieldValueInput{
IssueID: issueID,
IssueFields: issueFields,
}
if err := gqlClient.Mutate(ctx, &mutation, mutationInput, nil); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to set issue field values", err), nil, nil
}
r, err := json.Marshal(MinimalResponse{
ID: fmt.Sprintf("%v", mutation.SetIssueFieldValue.Issue.ID),
URL: string(mutation.SetIssueFieldValue.Issue.URL),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
st.FeatureFlagEnable = FeatureFlagIssuesGranular
return st
}