Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions pkg/github/__toolsnaps__/set_issue_fields.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,13 @@
"openWorldHint": true,
"title": "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.",
"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.",
"inputSchema": {
"properties": {
"fields": {
"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.",
"items": {
"properties": {
"confidence": {
"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": [
"low",
"medium",
"high"
],
"type": "string"
},
"date_value": {
"description": "The value to set for a date field (ISO 8601 date string)",
"type": "string"
Expand Down
8 changes: 8 additions & 0 deletions pkg/github/feature_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ const FeatureFlagIFCLabels = "ifc_labels"
// and field_values enrichment in list_issues / search_issues output.
const FeatureFlagIssueFields = "remote_mcp_issue_fields"

// FeatureFlagIssueConfidence is the feature flag name for exposing the
// per-field `confidence` input on the set_issue_fields GraphQL mutation. The
// GitHub GraphQL API does not yet accept the confidence input, so the schema
// hides the parameter and the handler drops it from the mutation payload
// unless this flag is enabled.
const FeatureFlagIssueConfidence = "update_issue_confidence"

// AllowedFeatureFlags is the allowlist of feature flags that can be enabled
// by users via --features CLI flag or X-MCP-Features HTTP header.
// Only flags in this list are accepted; unknown flags are silently ignored.
Expand All @@ -25,6 +32,7 @@ var AllowedFeatureFlags = []string{
FeatureFlagCSVOutput,
FeatureFlagIFCLabels,
FeatureFlagIssueFields,
FeatureFlagIssueConfidence,
FeatureFlagIssuesGranular,
FeatureFlagPullRequestsGranular,
}
Expand Down
205 changes: 203 additions & 2 deletions pkg/github/granular_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (

"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/http/headers"
transportpkg "github.com/github/github-mcp-server/pkg/http/transport"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
gogithub "github.com/google/go-github/v87/github"
Expand Down Expand Up @@ -1666,7 +1668,12 @@ func TestGranularSetIssueFields(t *testing.T) {
}

gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...))
deps := BaseDeps{GQLClient: gqlClient}
deps := BaseDeps{
GQLClient: gqlClient,
featureChecker: func(_ context.Context, flag string) (bool, error) {
return flag == FeatureFlagIssueConfidence, nil
},
}
serverTool := GranularSetIssueFields(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)

Expand All @@ -1688,7 +1695,11 @@ func TestGranularSetIssueFields(t *testing.T) {
})

t.Run("invalid confidence value returns error", func(t *testing.T) {
deps := BaseDeps{}
deps := BaseDeps{
featureChecker: func(_ context.Context, flag string) (bool, error) {
return flag == FeatureFlagIssueConfidence, nil
},
}
serverTool := GranularSetIssueFields(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)

Expand All @@ -1710,6 +1721,99 @@ func TestGranularSetIssueFields(t *testing.T) {
assert.Contains(t, textContent.Text, "confidence must be one of: low, medium, high")
})

t.Run("confidence is dropped when feature flag is disabled", func(t *testing.T) {
matchers := []githubv4mock.Matcher{
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(5),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{"id": "ISSUE_123"},
},
}),
),
// Expect the mutation input WITHOUT Confidence, proving the handler
// dropped the user-supplied value because the feature flag is off.
githubv4mock.NewMutationMatcher(
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)"`
}{},
SetIssueFieldValueInput{
IssueID: githubv4.ID("ISSUE_123"),
IssueFields: []IssueFieldCreateOrUpdateInput{
{
FieldID: githubv4.ID("FIELD_1"),
TextValue: githubv4.NewString(githubv4.String("hello")),
},
},
},
nil,
githubv4mock.DataResponse(map[string]any{
"setIssueFieldValue": map[string]any{
"issue": map[string]any{
"id": "ISSUE_123",
"number": 5,
"url": "https://github.com/owner/repo/issues/5",
},
},
}),
),
}

gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...))
deps := BaseDeps{GQLClient: gqlClient}
serverTool := GranularSetIssueFields(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)

request := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(5),
"fields": []any{
map[string]any{
"field_id": "FIELD_1",
"text_value": "hello",
// Confidence is supplied but should be silently dropped
// because FeatureFlagIssueConfidence is off.
"confidence": "high",
},
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
assert.False(t, result.IsError, getTextResult(t, result).Text)
})

t.Run("successful set with suggest flag", func(t *testing.T) {
suggestTrue := githubv4.Boolean(true)
matchers := []githubv4mock.Matcher{
Expand Down Expand Up @@ -1802,4 +1906,101 @@ func TestGranularSetIssueFields(t *testing.T) {
require.NoError(t, err)
assert.False(t, result.IsError)
})

t.Run("sends GraphQL-Features: update_issue_suggestions header on mutation", func(t *testing.T) {
matchers := []githubv4mock.Matcher{
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(5),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{"id": "ISSUE_123"},
},
}),
),
githubv4mock.NewMutationMatcher(
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)"`
}{},
SetIssueFieldValueInput{
IssueID: githubv4.ID("ISSUE_123"),
IssueFields: []IssueFieldCreateOrUpdateInput{
{
FieldID: githubv4.ID("FIELD_1"),
TextValue: githubv4.NewString(githubv4.String("hello")),
},
},
},
nil,
githubv4mock.DataResponse(map[string]any{
"setIssueFieldValue": map[string]any{
"issue": map[string]any{
"id": "ISSUE_123",
"number": 5,
"url": "https://github.com/owner/repo/issues/5",
},
},
}),
),
}

// Build a transport chain matching production: GraphQLFeaturesTransport
// wraps a header-capturing spy, which forwards to the mock's RoundTripper.
// This verifies the mutation request sets the update_issue_suggestions
// feature flag so the rationale/suggest input fields are accepted.
mockClient := githubv4mock.NewMockedHTTPClient(matchers...)
spy := &headerCaptureTransport{inner: mockClient.Transport}
httpClient := &http.Client{
Transport: &transportpkg.GraphQLFeaturesTransport{Transport: spy},
}
gqlClient := githubv4.NewClient(httpClient)
deps := BaseDeps{GQLClient: gqlClient}
serverTool := GranularSetIssueFields(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)

request := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(5),
"fields": []any{
map[string]any{"field_id": "FIELD_1", "text_value": "hello"},
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, getTextResult(t, result).Text)
// The last request captured is the mutation; the preceding issue ID
// query does not require the feature flag.
assert.Equal(t, "update_issue_suggestions", spy.captured.Get(headers.GraphQLFeaturesHeader))
})
}
37 changes: 21 additions & 16 deletions pkg/github/issues_granular.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"maps"
"strings"

ghcontext "github.com/github/github-mcp-server/pkg/context"
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"
Expand Down Expand Up @@ -924,7 +925,7 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv
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."),
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."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_SET_ISSUE_FIELDS_USER_TITLE", "Set Issue Fields"),
ReadOnlyHint: false,
Expand Down Expand Up @@ -984,11 +985,6 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv
"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. " +
Expand Down Expand Up @@ -1106,15 +1102,21 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv
}
}

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
// The `confidence` input is gated behind FeatureFlagIssueConfidence
// because the GitHub GraphQL API does not yet accept it. When the
// flag is off the schema hides the field and the handler drops
// any value supplied by older callers from the mutation payload.
if deps.IsFeatureEnabled(ctx, FeatureFlagIssueConfidence) {
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")
Expand Down Expand Up @@ -1170,7 +1172,10 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv
IssueFields: issueFields,
}

if err := gqlClient.Mutate(ctx, &mutation, mutationInput, nil); err != nil {
// The rationale and suggest input fields on IssueFieldCreateOrUpdateInput
// are gated behind the update_issue_suggestions GraphQL feature flag.
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "update_issue_suggestions")
if err := gqlClient.Mutate(ctxWithFeatures, &mutation, mutationInput, nil); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to set issue field values", err), nil, nil
}

Expand Down
Loading