Skip to content

Commit fea2c00

Browse files
committed
Tighten issue type removal changes
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea8faa5c-7f26-4e2d-bf9c-6f0b5f173e8c
1 parent 0db4a89 commit fea2c00

8 files changed

Lines changed: 44 additions & 227 deletions

File tree

cmd/github-mcp-server/generate_docs.go

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -301,23 +301,16 @@ func schemaTypeString(schema *jsonschema.Schema) (string, error) {
301301
if schema == nil {
302302
return "", fmt.Errorf("schema is nil")
303303
}
304-
if schema.Type == "array" {
304+
305+
switch {
306+
case schema.Type == "array":
305307
if schema.Items != nil {
306-
itemType, err := schemaTypeString(schema.Items)
307-
if err != nil {
308-
return "", fmt.Errorf("array items: %w", err)
309-
}
310-
if strings.Contains(itemType, " | ") {
311-
itemType = "(" + itemType + ")"
312-
}
313-
return itemType + "[]", nil
308+
return schema.Items.Type + "[]", nil
314309
}
315310
return "array", nil
316-
}
317-
if schema.Type != "" {
311+
case schema.Type != "":
318312
return schema.Type, nil
319-
}
320-
if len(schema.Types) > 0 {
313+
case len(schema.Types) > 0:
321314
return strings.Join(schema.Types, " | "), nil
322315
}
323316

@@ -328,11 +321,12 @@ func schemaTypeString(schema *jsonschema.Schema) (string, error) {
328321
if len(union) == 0 {
329322
return "any", nil
330323
}
324+
331325
types := make([]string, 0, len(union))
332-
for i, member := range union {
326+
for _, member := range union {
333327
memberType, err := schemaTypeString(member)
334328
if err != nil {
335-
return "", fmt.Errorf("union member %d: %w", i, err)
329+
return "", err
336330
}
337331
if !slices.Contains(types, memberType) {
338332
types = append(types, memberType)

cmd/github-mcp-server/main_test.go

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -47,40 +47,15 @@ func TestSchemaTypeString(t *testing.T) {
4747
}{
4848
{name: "type", schema: &jsonschema.Schema{Type: "string"}, want: "string"},
4949
{name: "types", schema: &jsonschema.Schema{Types: []string{"string", "number"}}, want: "string | number"},
50-
{name: "nil", schema: nil, wantError: true},
50+
{name: "nil", wantError: true},
5151
{name: "unconstrained", schema: &jsonschema.Schema{}, want: "any"},
52-
{
53-
name: "anyOf",
54-
schema: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{
55-
{Type: "string"},
56-
{Type: "null"},
57-
}},
58-
want: "string | null",
59-
},
60-
{
61-
name: "oneOf",
62-
schema: &jsonschema.Schema{OneOf: []*jsonschema.Schema{
63-
{Type: "number"},
64-
{Type: "string"},
65-
}},
66-
want: "number | string",
67-
},
52+
{name: "anyOf", schema: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{{Type: "string"}, {Type: "null"}}}, want: "string | null"},
53+
{name: "oneOf", schema: &jsonschema.Schema{OneOf: []*jsonschema.Schema{{Type: "number"}, {Type: "string"}}}, want: "number | string"},
6854
{
6955
name: "array",
7056
schema: &jsonschema.Schema{Type: "array", Items: &jsonschema.Schema{Type: "string"}},
7157
want: "string[]",
7258
},
73-
{
74-
name: "array of union",
75-
schema: &jsonschema.Schema{
76-
Type: "array",
77-
Items: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{
78-
{Type: "string"},
79-
{Type: "object"},
80-
}},
81-
},
82-
want: "(string | object)[]",
83-
},
8459
{name: "untyped array", schema: &jsonschema.Schema{Type: "array"}, want: "array"},
8560
}
8661

docs/feature-flags.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ runtime behavior (such as output formatting) won't appear here.
150150

151151
- **update_issue_assignees** - Update Issue Assignees
152152
- **Required OAuth Scopes**: `repo`
153-
- `assignees`: GitHub usernames to assign to this issue. ((string | object)[], required)
153+
- `assignees`: GitHub usernames to assign to this issue. ([], required)
154154
- `issue_number`: The issue number to update (number, required)
155155
- `owner`: Repository owner (username or organization) (string, required)
156156
- `repo`: Repository name (string, required)
@@ -165,7 +165,7 @@ runtime behavior (such as output formatting) won't appear here.
165165
- **update_issue_labels** - Update Issue Labels
166166
- **Required OAuth Scopes**: `repo`
167167
- `issue_number`: The issue number to update (number, required)
168-
- `labels`: Labels to apply to this issue. ((string | object)[], required)
168+
- `labels`: Labels to apply to this issue. ([], required)
169169
- `owner`: Repository owner (username or organization) (string, required)
170170
- `repo`: Repository name (string, required)
171171

pkg/github/granular_tools_test.go

Lines changed: 14 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"github.com/github/github-mcp-server/pkg/inventory"
1616
"github.com/github/github-mcp-server/pkg/translations"
1717
gogithub "github.com/google/go-github/v89/github"
18-
"github.com/google/jsonschema-go/jsonschema"
1918
"github.com/shurcooL/githubv4"
2019
"github.com/stretchr/testify/assert"
2120
"github.com/stretchr/testify/require"
@@ -756,13 +755,6 @@ func TestGranularUpdateIssueMilestone(t *testing.T) {
756755
}
757756

758757
func TestGranularUpdateIssueType(t *testing.T) {
759-
toolSchema := GranularUpdateIssueType(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema)
760-
issueTypeSchema := toolSchema.Properties["issue_type"]
761-
require.Len(t, issueTypeSchema.AnyOf, 2)
762-
assert.Equal(t, "string", issueTypeSchema.AnyOf[0].Type)
763-
assert.Equal(t, 1, *issueTypeSchema.AnyOf[0].MinLength)
764-
assert.Equal(t, "null", issueTypeSchema.AnyOf[1].Type)
765-
766758
tests := []struct {
767759
name string
768760
requestArgs map[string]any
@@ -828,48 +820,18 @@ func TestGranularUpdateIssueType(t *testing.T) {
828820
}
829821
}
830822

831-
func TestGranularUpdateIssueTypeRejectsEmptyIssueType(t *testing.T) {
832-
deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))}
833-
serverTool := GranularUpdateIssueType(translations.NullTranslationHelper)
834-
handler := serverTool.Handler(deps)
835-
request := createMCPRequest(map[string]any{
836-
"owner": "owner",
837-
"repo": "repo",
838-
"issue_number": float64(1),
839-
"issue_type": "",
840-
})
841-
842-
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
843-
require.NoError(t, err)
844-
errorContent := getErrorResult(t, result)
845-
assert.Contains(t, errorContent.Text, "parameter issue_type must not be empty")
846-
}
847-
848-
func TestGranularUpdateIssueTypeRejectsMissingIssueType(t *testing.T) {
849-
deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))}
850-
serverTool := GranularUpdateIssueType(translations.NullTranslationHelper)
851-
handler := serverTool.Handler(deps)
852-
853-
request := createMCPRequest(map[string]any{
854-
"owner": "owner",
855-
"repo": "repo",
856-
"issue_number": float64(1),
857-
})
858-
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
859-
require.NoError(t, err)
860-
861-
errorContent := getErrorResult(t, result)
862-
assert.Contains(t, errorContent.Text, "missing required parameter: issue_type")
863-
}
864-
865-
func TestGranularUpdateIssueTypeRejectsMetadataWhenRemovingType(t *testing.T) {
823+
func TestGranularUpdateIssueTypeRejectsInvalidInput(t *testing.T) {
866824
tests := []struct {
867-
name string
868-
args map[string]any
825+
name string
826+
args map[string]any
827+
omitType bool
828+
wantError string
869829
}{
870-
{name: "rationale", args: map[string]any{"rationale": "live validation"}},
871-
{name: "confidence", args: map[string]any{"confidence": "HIGH"}},
872-
{name: "suggestion", args: map[string]any{"is_suggestion": true}},
830+
{name: "missing type", omitType: true, wantError: "missing required parameter: issue_type"},
831+
{name: "empty type", args: map[string]any{"issue_type": ""}, wantError: "parameter issue_type must not be empty"},
832+
{name: "null with rationale", args: map[string]any{"rationale": "live validation"}, wantError: "suggestion metadata is not supported"},
833+
{name: "null with confidence", args: map[string]any{"confidence": "HIGH"}, wantError: "suggestion metadata is not supported"},
834+
{name: "null suggestion", args: map[string]any{"is_suggestion": true}, wantError: "suggestion metadata is not supported"},
873835
}
874836

875837
for _, tc := range tests {
@@ -883,13 +845,16 @@ func TestGranularUpdateIssueTypeRejectsMetadataWhenRemovingType(t *testing.T) {
883845
"issue_number": float64(1),
884846
"issue_type": nil,
885847
}
848+
if tc.omitType {
849+
delete(args, "issue_type")
850+
}
886851
maps.Copy(args, tc.args)
887852
request := createMCPRequest(args)
888853

889854
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
890855
require.NoError(t, err)
891856
errorContent := getErrorResult(t, result)
892-
assert.Contains(t, errorContent.Text, "suggestion metadata is not supported when removing an issue type; omit rationale, confidence, and is_suggestion")
857+
assert.Contains(t, errorContent.Text, tc.wantError)
893858
})
894859
}
895860
}

pkg/github/issues.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2228,12 +2228,9 @@ Options are:
22282228
// Hand off to the interactive MCP App form unless this call must
22292229
// execute now (see shouldDeferToForm).
22302230
deferToForm := shouldDeferToForm(ctx, deps, req, args, issueWriteFormParams)
2231-
if method == "update" {
2232-
if issueType, ok := args["type"]; ok && issueType == nil {
2233-
// The form replaces a null type with the current type, so execute
2234-
// directly to preserve the clear and any co-submitted values.
2235-
deferToForm = false
2236-
}
2231+
if issueType, ok := args["type"]; method == "update" && ok && issueType == nil {
2232+
// The form restores the current type for null, so execute all inputs directly.
2233+
deferToForm = false
22372234
}
22382235
if deferToForm {
22392236
issueNumber := 0

pkg/github/issues_test.go

Lines changed: 1 addition & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,11 +1435,6 @@ func Test_CreateIssue(t *testing.T) {
14351435
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels")
14361436
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "milestone")
14371437
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "type")
1438-
issueTypeSchema := tool.InputSchema.(*jsonschema.Schema).Properties["type"]
1439-
require.Len(t, issueTypeSchema.AnyOf, 2)
1440-
assert.Equal(t, "string", issueTypeSchema.AnyOf[0].Type)
1441-
assert.Equal(t, 1, *issueTypeSchema.AnyOf[0].MinLength)
1442-
assert.Equal(t, "null", issueTypeSchema.AnyOf[1].Type)
14431438
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_fields")
14441439
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"})
14451440

@@ -1518,18 +1513,6 @@ func Test_CreateIssue(t *testing.T) {
15181513
State: github.Ptr("open"),
15191514
},
15201515
},
1521-
{
1522-
name: "empty issue type is rejected when creating",
1523-
mockedClient: MockHTTPClientWithHandlers(nil),
1524-
requestArgs: map[string]any{
1525-
"method": "create",
1526-
"owner": "owner",
1527-
"repo": "repo",
1528-
"title": "Issue without a type",
1529-
"type": "",
1530-
},
1531-
expectedErrMsg: "parameter type must not be empty",
1532-
},
15331516
{
15341517
name: "successful issue creation with issue fields reconciled by names",
15351518
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
@@ -2891,83 +2874,7 @@ func TestIssueWriteUpdatesIssueType(t *testing.T) {
28912874
}
28922875
}
28932876

2894-
func TestIssueWriteRejectsEmptyIssueType(t *testing.T) {
2895-
tests := []struct {
2896-
name string
2897-
args map[string]any
2898-
}{
2899-
{
2900-
name: "create",
2901-
args: map[string]any{
2902-
"method": "create",
2903-
"title": "New issue",
2904-
},
2905-
},
2906-
{
2907-
name: "update",
2908-
args: map[string]any{
2909-
"method": "update",
2910-
"issue_number": float64(123),
2911-
},
2912-
},
2913-
}
2914-
2915-
for _, tc := range tests {
2916-
t.Run(tc.name, func(t *testing.T) {
2917-
deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))}
2918-
serverTool := IssueWrite(translations.NullTranslationHelper)
2919-
handler := serverTool.Handler(deps)
2920-
args := map[string]any{
2921-
"owner": "owner",
2922-
"repo": "repo",
2923-
"type": "",
2924-
}
2925-
maps.Copy(args, tc.args)
2926-
request := createMCPRequest(args)
2927-
2928-
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
2929-
require.NoError(t, err)
2930-
errorContent := getErrorResult(t, result)
2931-
assert.Contains(t, errorContent.Text, "parameter type must not be empty")
2932-
})
2933-
}
2934-
}
2935-
2936-
func TestIssueWriteClearTypeBypassesMCPAppForm(t *testing.T) {
2937-
var gotRequestBody []byte
2938-
var readErr error
2939-
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
2940-
PatchReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, r *http.Request) {
2941-
gotRequestBody, readErr = io.ReadAll(r.Body)
2942-
w.WriteHeader(http.StatusOK)
2943-
_, _ = w.Write([]byte(`{"number":123,"html_url":"https://github.com/owner/repo/issues/123"}`))
2944-
},
2945-
}))
2946-
deps := BaseDeps{
2947-
Client: client,
2948-
GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()),
2949-
featureChecker: featureCheckerFor(MCPAppsFeatureFlag),
2950-
}
2951-
serverTool := IssueWrite(translations.NullTranslationHelper)
2952-
handler := serverTool.Handler(deps)
2953-
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
2954-
"method": "update",
2955-
"owner": "owner",
2956-
"repo": "repo",
2957-
"issue_number": float64(123),
2958-
"type": nil,
2959-
})
2960-
2961-
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
2962-
require.NoError(t, err)
2963-
require.False(t, result.IsError)
2964-
require.NoError(t, readErr)
2965-
require.JSONEq(t, `{"type":null}`, string(gotRequestBody))
2966-
textContent := getTextResult(t, result)
2967-
require.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/123")
2968-
}
2969-
2970-
func TestIssueWriteClearTypeBypassesMCPAppFormWithStateChange(t *testing.T) {
2877+
func TestIssueWriteNullTypeBypassesMCPAppFormWithStateChange(t *testing.T) {
29712878
var gotRequestBody []byte
29722879
var readErr error
29732880
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
@@ -3049,8 +2956,6 @@ func TestIssueWriteClearTypeBypassesMCPAppFormWithStateChange(t *testing.T) {
30492956
require.False(t, result.IsError)
30502957
require.NoError(t, readErr)
30512958
require.JSONEq(t, `{"type":null}`, string(gotRequestBody))
3052-
textContent := getTextResult(t, result)
3053-
require.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/123")
30542959
}
30552960

30562961
func Test_UpdateIssue(t *testing.T) {

pkg/github/params.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ func OptionalParamOK[T any, A map[string]any](args A, p string) (value T, ok boo
3434
return
3535
}
3636

37-
// OptionalNullableStringParam returns a non-empty string or nil while preserving
38-
// whether the parameter was omitted.
37+
// OptionalNullableStringParam preserves omitted, null, and non-empty string values.
3938
func OptionalNullableStringParam(args map[string]any, p string) (*string, bool, error) {
4039
value, ok := args[p]
4140
if !ok {

0 commit comments

Comments
 (0)