Files
github--github-mcp-server/pkg/github/issues_granular.go
Alon Dahari 457f59932a
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Docker / build (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
GoReleaser Release / release (push) Has been cancelled
MCP Server Diff / mcp-diff (push) Has been cancelled
MCP Server Diff / mcp-diff-http (push) Has been cancelled
Publish to MCP Registry / publish (push) Has been cancelled
Add confidence parameter to issue mutation MCP tools (#2605)
Add an optional confidence integer parameter (0–100) to update_issue_type,
update_issue_labels, and set_issue_fields MCP tools. The confidence score
is passed through to the REST/GraphQL API on mutation calls.

- Rename structs to WithIntent (labelWithIntent, issueTypeWithIntent)
- Add confidence schema property (integer, min 0, max 100) with prompt
  guidance describing what different confidence levels represent
- Update tool descriptions to encourage including confidence scores
- Pass confidence in the API request body alongside rationale/suggest

Closes github/plan-track-agentic-toolkit#219

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 15:04:22 +01:00

1190 lines
42 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
},
)
}
// labelWithIntent represents the object form of a label entry, allowing a
// rationale, confidence level, and/or suggest flag to be sent alongside the label name.
type labelWithIntent struct {
Name string `json:"name"`
Rationale string `json:"rationale,omitempty"`
Confidence string `json:"confidence,omitempty"`
Suggest bool `json:"suggest,omitempty"`
}
// labelsUpdateRequest is a custom request body for updating an issue's labels
// where individual labels may optionally include a rationale. Each element of
// Labels is either a string (label name) or a labelWithIntent object.
type labelsUpdateRequest struct {
Labels []any `json:"labels"`
}
// 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
}
labelsRaw, ok := args["labels"]
if !ok {
return utils.NewToolResultError("missing required parameter: labels"), nil, nil
}
labelsSlice, ok := labelsRaw.([]any)
if !ok {
// Also accept []string for callers that pre-typed the array.
if strs, ok := labelsRaw.([]string); ok {
labelsSlice = make([]any, len(strs))
for i, s := range strs {
labelsSlice[i] = s
}
} else {
return utils.NewToolResultError("parameter labels must be an array"), nil, nil
}
}
useObjectForm := false
payload := make([]any, 0, len(labelsSlice))
for _, item := range labelsSlice {
switch v := item.(type) {
case string:
payload = append(payload, v)
case map[string]any:
name, err := RequiredParam[string](v, "name")
if err != nil {
return utils.NewToolResultError("each label object must have a 'name' string"), nil, nil
}
rationale, err := OptionalParam[string](v, "rationale")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
rationale = strings.TrimSpace(rationale)
if len([]rune(rationale)) > 280 {
return utils.NewToolResultError("label rationale must be 280 characters or less"), nil, nil
}
confidence, err := OptionalParam[string](v, "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
}
isSuggestion, err := OptionalParam[bool](v, "is_suggestion")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if rationale == "" && !isSuggestion && confidence == "" {
payload = append(payload, name)
} else {
useObjectForm = true
payload = append(payload, labelWithIntent{Name: name, Rationale: rationale, Confidence: confidence, Suggest: isSuggestion})
}
default:
return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale', 'confidence', and/or 'is_suggestion'"), 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.
names := make([]string, len(payload))
for i, p := range payload {
names[i] = p.(string)
}
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
},
)
}
// issueTypeWithIntent represents the object form of the issue type field,
// allowing a rationale, confidence level, and/or suggest flag to be sent alongside the type name.
type issueTypeWithIntent struct {
Value string `json:"value"`
Rationale string `json:"rationale,omitempty"`
Confidence string `json:"confidence,omitempty"`
Suggest bool `json:"suggest,omitempty"`
}
// issueTypeUpdateRequest is a custom request body for updating an issue type
// with optional intent metadata, using the object form that the REST API accepts.
type issueTypeUpdateRequest struct {
Type issueTypeWithIntent `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: 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",
},
"rationale": {
Type: "string",
Description: "One concise sentence explaining what specifically about the issue led you to choose this type. " +
"State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).",
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 issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. " +
"Whether the type is applied or recorded as a proposal is determined by the API.",
},
},
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
}
rationale, err := OptionalParam[string](args, "rationale")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
rationale = strings.TrimSpace(rationale)
if len([]rune(rationale)) > 280 {
return utils.NewToolResultError("parameter rationale must be 280 characters or less"), nil, nil
}
confidence, err := OptionalParam[string](args, "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
}
isSuggestion, err := OptionalParam[bool](args, "is_suggestion")
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 rationale != "" || isSuggestion || confidence != "" {
body = &issueTypeUpdateRequest{
Type: issueTypeWithIntent{
Value: issueType,
Rationale: rationale,
Confidence: confidence,
Suggest: isSuggestion,
},
}
} 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
}