Skip to content

Commit a7cebe8

Browse files
Merge branch 'main' into copilot/add-custom-instructions-parameter
2 parents 45a8dc8 + 19beb33 commit a7cebe8

File tree

111 files changed

+2171
-1818
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

111 files changed

+2171
-1818
lines changed

cmd/github-mcp-server/generate_docs.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ func generateReadmeDocs(readmePath string) error {
5151
t, _ := translations.TranslationHelper()
5252

5353
// (not available to regular users) while including tools with FeatureFlagDisable.
54-
r := github.NewInventory(t).WithToolsets([]string{"all"}).Build()
54+
// Build() can only fail if WithTools specifies invalid tools - not used here
55+
r, _ := github.NewInventory(t).WithToolsets([]string{"all"}).Build()
5556

5657
// Generate toolsets documentation
5758
toolsetsDoc := generateToolsetsDoc(r)
@@ -341,7 +342,8 @@ func generateRemoteToolsetsDoc() string {
341342
t, _ := translations.TranslationHelper()
342343

343344
// Build inventory - stateless
344-
r := github.NewInventory(t).Build()
345+
// Build() can only fail if WithTools specifies invalid tools - not used here
346+
r, _ := github.NewInventory(t).Build()
345347

346348
// Generate table header (icon is combined with Name column)
347349
buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n")

cmd/github-mcp-server/list_scopes.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,10 @@ func runListScopes() error {
121121
inventoryBuilder = inventoryBuilder.WithTools(enabledTools)
122122
}
123123

124-
inv := inventoryBuilder.Build()
124+
inv, err := inventoryBuilder.Build()
125+
if err != nil {
126+
return fmt.Errorf("failed to build inventory: %w", err)
127+
}
125128

126129
// Collect all tools and their scopes
127130
output := collectToolScopes(inv, readOnly)

internal/ghmcp/server.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,15 +221,18 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
221221
WithDeprecatedAliases(github.DeprecatedToolAliases).
222222
WithReadOnly(cfg.ReadOnly).
223223
WithToolsets(enabledToolsets).
224-
WithTools(github.CleanTools(cfg.EnabledTools)).
224+
WithTools(cfg.EnabledTools).
225225
WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures))
226226

227227
// Apply token scope filtering if scopes are known (for PAT filtering)
228228
if cfg.TokenScopes != nil {
229229
inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes))
230230
}
231231

232-
inventory := inventoryBuilder.Build()
232+
inventory, err := inventoryBuilder.Build()
233+
if err != nil {
234+
return nil, fmt.Errorf("failed to build inventory: %w", err)
235+
}
233236

234237
if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 {
235238
fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", "))

internal/toolsnaps/toolsnaps.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,35 @@ func Test(toolName string, tool any) error {
6767
}
6868

6969
func writeSnap(snapPath string, contents []byte) error {
70+
// Sort the JSON keys recursively to ensure consistent output.
71+
// We do this by unmarshaling and remarshaling, which ensures Go's JSON encoder
72+
// sorts all map keys alphabetically at every level.
73+
sortedJSON, err := sortJSONKeys(contents)
74+
if err != nil {
75+
return fmt.Errorf("failed to sort JSON keys: %w", err)
76+
}
77+
7078
// Ensure the directory exists
7179
if err := os.MkdirAll(filepath.Dir(snapPath), 0700); err != nil {
7280
return fmt.Errorf("failed to create snapshot directory: %w", err)
7381
}
7482

7583
// Write the snapshot file
76-
if err := os.WriteFile(snapPath, contents, 0600); err != nil {
84+
if err := os.WriteFile(snapPath, sortedJSON, 0600); err != nil {
7785
return fmt.Errorf("failed to write snapshot file: %w", err)
7886
}
7987

8088
return nil
8189
}
90+
91+
// sortJSONKeys recursively sorts all object keys in a JSON byte array by
92+
// unmarshaling to map[string]any and remarshaling. Go's JSON encoder
93+
// automatically sorts map keys alphabetically.
94+
func sortJSONKeys(jsonData []byte) ([]byte, error) {
95+
var data any
96+
if err := json.Unmarshal(jsonData, &data); err != nil {
97+
return nil, err
98+
}
99+
100+
return json.MarshalIndent(data, "", " ")
101+
}

internal/toolsnaps/toolsnaps_test.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,184 @@ func TestMalformedSnapshotJSON(t *testing.T) {
131131
require.Error(t, err)
132132
assert.Contains(t, err.Error(), "failed to parse snapshot JSON for dummy", "expected error about malformed snapshot JSON")
133133
}
134+
135+
func TestSortJSONKeys(t *testing.T) {
136+
tests := []struct {
137+
name string
138+
input string
139+
expected string
140+
}{
141+
{
142+
name: "simple object",
143+
input: `{"z": 1, "a": 2, "m": 3}`,
144+
expected: "{\n \"a\": 2,\n \"m\": 3,\n \"z\": 1\n}",
145+
},
146+
{
147+
name: "nested object",
148+
input: `{"z": {"y": 1, "x": 2}, "a": 3}`,
149+
expected: "{\n \"a\": 3,\n \"z\": {\n \"x\": 2,\n \"y\": 1\n }\n}",
150+
},
151+
{
152+
name: "array with objects",
153+
input: `{"items": [{"z": 1, "a": 2}, {"y": 3, "b": 4}]}`,
154+
expected: "{\n \"items\": [\n {\n \"a\": 2,\n \"z\": 1\n },\n {\n \"b\": 4,\n \"y\": 3\n }\n ]\n}",
155+
},
156+
{
157+
name: "deeply nested",
158+
input: `{"z": {"y": {"x": 1, "a": 2}, "b": 3}, "m": 4}`,
159+
expected: "{\n \"m\": 4,\n \"z\": {\n \"b\": 3,\n \"y\": {\n \"a\": 2,\n \"x\": 1\n }\n }\n}",
160+
},
161+
{
162+
name: "properties field like in toolsnaps",
163+
input: `{"name": "test", "properties": {"repo": {"type": "string"}, "owner": {"type": "string"}, "page": {"type": "number"}}}`,
164+
expected: "{\n \"name\": \"test\",\n \"properties\": {\n \"owner\": {\n \"type\": \"string\"\n },\n \"page\": {\n \"type\": \"number\"\n },\n \"repo\": {\n \"type\": \"string\"\n }\n }\n}",
165+
},
166+
}
167+
168+
for _, tt := range tests {
169+
t.Run(tt.name, func(t *testing.T) {
170+
result, err := sortJSONKeys([]byte(tt.input))
171+
require.NoError(t, err)
172+
assert.Equal(t, tt.expected, string(result))
173+
})
174+
}
175+
}
176+
177+
func TestSortJSONKeysIdempotent(t *testing.T) {
178+
// Given a JSON string that's already sorted
179+
input := `{"a": 1, "b": {"x": 2, "y": 3}, "c": [{"m": 4, "n": 5}]}`
180+
181+
// When we sort it once
182+
sorted1, err := sortJSONKeys([]byte(input))
183+
require.NoError(t, err)
184+
185+
// And sort it again
186+
sorted2, err := sortJSONKeys(sorted1)
187+
require.NoError(t, err)
188+
189+
// Then the results should be identical
190+
assert.Equal(t, string(sorted1), string(sorted2))
191+
}
192+
193+
func TestToolSnapKeysSorted(t *testing.T) {
194+
withIsolatedWorkingDir(t)
195+
196+
// Given a tool with fields that could be in any order
197+
type complexTool struct {
198+
Name string `json:"name"`
199+
Description string `json:"description"`
200+
Properties map[string]interface{} `json:"properties"`
201+
Annotations map[string]interface{} `json:"annotations"`
202+
}
203+
204+
tool := complexTool{
205+
Name: "test_tool",
206+
Description: "A test tool",
207+
Properties: map[string]interface{}{
208+
"zzz": "last",
209+
"aaa": "first",
210+
"mmm": "middle",
211+
"owner": map[string]interface{}{"type": "string", "description": "Owner"},
212+
"repo": map[string]interface{}{"type": "string", "description": "Repo"},
213+
},
214+
Annotations: map[string]interface{}{
215+
"readOnly": true,
216+
"title": "Test",
217+
},
218+
}
219+
220+
// When we write the snapshot
221+
t.Setenv("UPDATE_TOOLSNAPS", "true")
222+
err := Test("complex", tool)
223+
require.NoError(t, err)
224+
225+
// Then the snapshot file should have sorted keys
226+
snapJSON, err := os.ReadFile("__toolsnaps__/complex.snap")
227+
require.NoError(t, err)
228+
229+
// Verify that the JSON is properly sorted by checking key order
230+
var parsed map[string]interface{}
231+
err = json.Unmarshal(snapJSON, &parsed)
232+
require.NoError(t, err)
233+
234+
// Check that properties are sorted
235+
propsJSON, _ := json.MarshalIndent(parsed["properties"], "", " ")
236+
propsStr := string(propsJSON)
237+
// The properties should have "aaa" before "mmm" before "zzz"
238+
aaaIndex := -1
239+
mmmIndex := -1
240+
zzzIndex := -1
241+
for i, line := range propsStr {
242+
if line == 'a' && i+2 < len(propsStr) && propsStr[i:i+3] == "aaa" {
243+
aaaIndex = i
244+
}
245+
if line == 'm' && i+2 < len(propsStr) && propsStr[i:i+3] == "mmm" {
246+
mmmIndex = i
247+
}
248+
if line == 'z' && i+2 < len(propsStr) && propsStr[i:i+3] == "zzz" {
249+
zzzIndex = i
250+
}
251+
}
252+
assert.Greater(t, mmmIndex, aaaIndex, "mmm should come after aaa")
253+
assert.Greater(t, zzzIndex, mmmIndex, "zzz should come after mmm")
254+
}
255+
256+
func TestStructFieldOrderingSortedAlphabetically(t *testing.T) {
257+
withIsolatedWorkingDir(t)
258+
259+
// Given a struct with fields defined in non-alphabetical order
260+
// This test ensures that struct field order doesn't affect the JSON output
261+
type toolWithNonAlphabeticalFields struct {
262+
ZField string `json:"zField"` // Should appear last in JSON
263+
AField string `json:"aField"` // Should appear first in JSON
264+
MField string `json:"mField"` // Should appear in the middle
265+
}
266+
267+
tool := toolWithNonAlphabeticalFields{
268+
ZField: "z value",
269+
AField: "a value",
270+
MField: "m value",
271+
}
272+
273+
// When we write the snapshot
274+
t.Setenv("UPDATE_TOOLSNAPS", "true")
275+
err := Test("struct_field_order", tool)
276+
require.NoError(t, err)
277+
278+
// Then the snapshot file should have alphabetically sorted keys despite struct field order
279+
snapJSON, err := os.ReadFile("__toolsnaps__/struct_field_order.snap")
280+
require.NoError(t, err)
281+
282+
snapStr := string(snapJSON)
283+
284+
// Find the positions of each field in the JSON string
285+
aFieldIndex := -1
286+
mFieldIndex := -1
287+
zFieldIndex := -1
288+
for i := 0; i < len(snapStr)-7; i++ {
289+
switch snapStr[i : i+6] {
290+
case "aField":
291+
aFieldIndex = i
292+
case "mField":
293+
mFieldIndex = i
294+
case "zField":
295+
zFieldIndex = i
296+
}
297+
}
298+
299+
// Verify alphabetical ordering in the JSON output
300+
require.NotEqual(t, -1, aFieldIndex, "aField should be present")
301+
require.NotEqual(t, -1, mFieldIndex, "mField should be present")
302+
require.NotEqual(t, -1, zFieldIndex, "zField should be present")
303+
assert.Less(t, aFieldIndex, mFieldIndex, "aField should appear before mField")
304+
assert.Less(t, mFieldIndex, zFieldIndex, "mField should appear before zField")
305+
306+
// Also verify idempotency - running the test again should produce identical output
307+
err = Test("struct_field_order", tool)
308+
require.NoError(t, err)
309+
310+
snapJSON2, err := os.ReadFile("__toolsnaps__/struct_field_order.snap")
311+
require.NoError(t, err)
312+
313+
assert.Equal(t, string(snapJSON), string(snapJSON2), "Multiple runs should produce identical output")
314+
}

pkg/github/__toolsnaps__/actions_get.snap

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,8 @@
55
},
66
"description": "Get details about specific GitHub Actions resources.\nUse this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs.\n",
77
"inputSchema": {
8-
"type": "object",
9-
"required": [
10-
"method",
11-
"owner",
12-
"repo",
13-
"resource_id"
14-
],
158
"properties": {
169
"method": {
17-
"type": "string",
1810
"description": "The method to execute",
1911
"enum": [
2012
"get_workflow",
@@ -23,21 +15,29 @@
2315
"download_workflow_run_artifact",
2416
"get_workflow_run_usage",
2517
"get_workflow_run_logs_url"
26-
]
18+
],
19+
"type": "string"
2720
},
2821
"owner": {
29-
"type": "string",
30-
"description": "Repository owner"
22+
"description": "Repository owner",
23+
"type": "string"
3124
},
3225
"repo": {
33-
"type": "string",
34-
"description": "Repository name"
26+
"description": "Repository name",
27+
"type": "string"
3528
},
3629
"resource_id": {
37-
"type": "string",
38-
"description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n"
30+
"description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n",
31+
"type": "string"
3932
}
40-
}
33+
},
34+
"required": [
35+
"method",
36+
"owner",
37+
"repo",
38+
"resource_id"
39+
],
40+
"type": "object"
4141
},
4242
"name": "actions_get"
4343
}

0 commit comments

Comments
 (0)