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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -926,7 +926,7 @@ The following sets of tools are available:
- **Required OAuth Scopes**: `repo`
- `assignees`: Usernames to assign to this issue (string[], optional)
- `body`: Issue body content (string, optional)
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
- `duplicate_of`: Issue number that this issue is a duplicate of. Required when state_reason is 'duplicate'. (number, optional)
- `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional)
- `issue_number`: Issue number to update (number, optional)
- `labels`: Labels to apply to this issue (string[], optional)
Expand All @@ -941,7 +941,7 @@ The following sets of tools are available:
- `state`: New state (string, optional)
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
- `title`: Issue title (string, optional)
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
- `type`: Type of this issue. For updates, pass null to remove the current type. Only use if issue types are enabled for this repository. Use list_issue_types to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string | null, optional)

- **list_issue_fields** - List issue fields
- **Required OAuth Scopes (any of)**: `repo`, `read:org`
Expand Down
48 changes: 35 additions & 13 deletions cmd/github-mcp-server/generate_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,19 +273,7 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {
requiredStr = "required"
}

var typeStr string

// Get the type and description
switch prop.Type {
case "array":
if prop.Items != nil {
typeStr = prop.Items.Type + "[]"
} else {
typeStr = "array"
}
default:
typeStr = prop.Type
}
typeStr := schemaTypeString(prop)

// Indent any continuation lines in the description to maintain markdown formatting
description := indentMultilineDescription(prop.Description, " ")
Expand All @@ -300,6 +288,40 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {
}
}

func schemaTypeString(schema *jsonschema.Schema) string {
switch {
case schema.Type == "array":
if schema.Items != nil {
return schema.Items.Type + "[]"
}
return "array"
case schema.Type != "":
return schema.Type
case len(schema.Types) > 0:
return strings.Join(schema.Types, " | ")
}

var union []*jsonschema.Schema
switch {
case len(schema.AnyOf) > 0:
union = schema.AnyOf
case len(schema.OneOf) > 0:
union = schema.OneOf
default:
// A schema without type constraints accepts any value.
return "any"
}

types := make([]string, 0, len(union))
for _, member := range union {
memberType := schemaTypeString(member)
if !slices.Contains(types, memberType) {
types = append(types, memberType)
}
}
return strings.Join(types, " | ")
}

// scopesEqual checks if two scope slices contain the same elements (order-independent)
func scopesEqual(a, b []string) bool {
if len(a) != len(b) {
Expand Down
27 changes: 27 additions & 0 deletions cmd/github-mcp-server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"

"github.com/google/jsonschema-go/jsonschema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -36,3 +37,29 @@ func TestGitHubAppFlagsAreStdioOnly(t *testing.T) {
assert.NotNil(t, stdioCmd.Flags().Lookup("app-id"))
assert.Nil(t, httpCmd.Flags().Lookup("app-id"))
}

func TestSchemaTypeString(t *testing.T) {
tests := []struct {
name string
schema *jsonschema.Schema
want string
}{
{name: "type", schema: &jsonschema.Schema{Type: "string"}, want: "string"},
{name: "types", schema: &jsonschema.Schema{Types: []string{"string", "number"}}, want: "string | number"},
{name: "unconstrained", schema: &jsonschema.Schema{}, want: "any"},
{name: "anyOf", schema: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{{Type: "string"}, {Type: "null"}}}, want: "string | null"},
{name: "oneOf", schema: &jsonschema.Schema{OneOf: []*jsonschema.Schema{{Type: "number"}, {Type: "string"}}}, want: "number | string"},
{
name: "array",
schema: &jsonschema.Schema{Type: "array", Items: &jsonschema.Schema{Type: "string"}},
want: "string[]",
},
{name: "untyped array", schema: &jsonschema.Schema{Type: "array"}, want: "array"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, schemaTypeString(tc.schema))
})
}
}
6 changes: 3 additions & 3 deletions docs/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ runtime behavior (such as output formatting) won't appear here.
- **MCP App UI**: `ui://github-mcp-server/issue-write`
- `assignees`: Usernames to assign to this issue (string[], optional)
- `body`: Issue body content (string, optional)
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
- `duplicate_of`: Issue number that this issue is a duplicate of. Required when state_reason is 'duplicate'. (number, optional)
- `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional)
- `issue_number`: Issue number to update (number, optional)
- `labels`: Labels to apply to this issue (string[], optional)
Expand All @@ -71,7 +71,7 @@ runtime behavior (such as output formatting) won't appear here.
- `state`: New state (string, optional)
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
- `title`: Issue title (string, optional)
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
- `type`: Type of this issue. For updates, pass null to remove the current type. Only use if issue types are enabled for this repository. Use list_issue_types to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string | null, optional)

- **ui_get** - Get UI data
- **Required OAuth Scopes (any of)**: `repo`, `read:org`
Expand Down Expand Up @@ -200,7 +200,7 @@ runtime behavior (such as output formatting) won't appear here.
- `confidence`: 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. (string, optional)
- `is_suggestion`: 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. (boolean, optional)
- `issue_number`: The issue number to update (number, required)
- `issue_type`: The issue type to set (string, required)
- `issue_type`: The issue type to set, or null to remove the current type (string | null, required)
- `owner`: Repository owner (username or organization) (string, required)
- `rationale`: 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). (string, optional)
- `repo`: Repository name (string, required)
Expand Down
4 changes: 2 additions & 2 deletions docs/insiders-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
- **MCP App UI**: `ui://github-mcp-server/issue-write`
- `assignees`: Usernames to assign to this issue (string[], optional)
- `body`: Issue body content (string, optional)
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
- `duplicate_of`: Issue number that this issue is a duplicate of. Required when state_reason is 'duplicate'. (number, optional)
- `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional)
- `issue_number`: Issue number to update (number, optional)
- `labels`: Labels to apply to this issue (string[], optional)
Expand All @@ -65,7 +65,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
- `state`: New state (string, optional)
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
- `title`: Issue title (string, optional)
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
- `type`: Type of this issue. For updates, pass null to remove the current type. Only use if issue types are enabled for this repository. Use list_issue_types to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string | null, optional)

- **ui_get** - Get UI data
- **Required OAuth Scopes (any of)**: `repo`, `read:org`
Expand Down
14 changes: 11 additions & 3 deletions pkg/github/__toolsnaps__/issue_write.snap
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"type": "string"
},
"duplicate_of": {
"description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.",
"description": "Issue number that this issue is a duplicate of. Required when state_reason is 'duplicate'.",
"type": "number"
},
"issue_fields": {
Expand Down Expand Up @@ -120,8 +120,16 @@
"type": "string"
},
"type": {
"description": "Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter.",
"type": "string"
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"description": "Type of this issue. For updates, pass null to remove the current type. Only use if issue types are enabled for this repository. Use list_issue_types to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter."
}
},
"required": [
Expand Down
14 changes: 11 additions & 3 deletions pkg/github/__toolsnaps__/update_issue_type.snap
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"readOnlyHint": false,
"title": "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.",
"description": "Set or remove the type of an existing issue. Pass null to remove the current type. When setting a value, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice.",
"inputSchema": {
"properties": {
"confidence": {
Expand All @@ -28,8 +28,16 @@
"type": "number"
},
"issue_type": {
"description": "The issue type to set",
"type": "string"
"anyOf": [
{
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"description": "The issue type to set, or null to remove the current type"
},
"owner": {
"description": "Repository owner (username or organization)",
Expand Down
52 changes: 52 additions & 0 deletions pkg/github/granular_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package github
import (
"context"
"encoding/json"
"maps"
"net/http"
"strings"
"testing"
Expand Down Expand Up @@ -787,6 +788,18 @@ func TestGranularUpdateIssueType(t *testing.T) {
},
},
},
{
name: "remove type with null",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": nil,
},
expectedReq: map[string]any{
"type": nil,
},
},
}

for _, tc := range tests {
Expand All @@ -807,6 +820,45 @@ func TestGranularUpdateIssueType(t *testing.T) {
}
}

func TestGranularUpdateIssueTypeRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
args map[string]any
omitType bool
wantError string
}{
{name: "missing type", omitType: true, wantError: "missing required parameter: issue_type"},
{name: "empty type", args: map[string]any{"issue_type": ""}, wantError: "parameter issue_type must not be empty"},
{name: "null with rationale", args: map[string]any{"rationale": "live validation"}, wantError: "suggestion metadata is not supported"},
{name: "null with confidence", args: map[string]any{"confidence": "HIGH"}, wantError: "suggestion metadata is not supported"},
{name: "null suggestion", args: map[string]any{"is_suggestion": true}, wantError: "suggestion metadata is not supported"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
deps := BaseDeps{}
serverTool := GranularUpdateIssueType(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)
args := map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": nil,
}
if tc.omitType {
delete(args, "issue_type")
}
maps.Copy(args, tc.args)
request := createMCPRequest(args)

result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.wantError)
})
}
}

func TestGranularUpdateIssueTypeSuggest(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading
Loading