diff --git a/pkg/github/__toolsnaps__/set_issue_fields.snap b/pkg/github/__toolsnaps__/set_issue_fields.snap index e46febeeda..a29ec51d27 100644 --- a/pkg/github/__toolsnaps__/set_issue_fields.snap +++ b/pkg/github/__toolsnaps__/set_issue_fields.snap @@ -4,7 +4,7 @@ "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": { @@ -12,12 +12,7 @@ "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" - ], + "description": "How confident you are in this field value: low, medium, or high.", "type": "string" }, "date_value": { diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index eb688a0b9f..27e8079f97 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -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" @@ -1710,6 +1712,97 @@ func TestGranularSetIssueFields(t *testing.T) { assert.Contains(t, textContent.Text, "confidence must be one of: low, medium, high") }) + t.Run("confidence is sent when supplied", func(t *testing.T) { + confidence := "high" + 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")), + Confidence: &confidence, + }, + }, + }, + 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": "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{ @@ -1802,4 +1895,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)) + }) } diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index 22d26cc47f..9813b4ff46 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -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" @@ -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, @@ -986,8 +987,7 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv }, "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"}, + Description: "How confident you are in this field value: low, medium, or high.", }, "is_suggestion": { Type: "boolean", @@ -1170,7 +1170,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 }