Skip to content

Commit 784e4c4

Browse files
authored
Add X-MCP-Toolsets header support for remote GitHub MCP configuration (#2015)
1 parent c0280e9 commit 784e4c4

6 files changed

Lines changed: 227 additions & 12 deletions

File tree

.changeset/patch-add-mcp-toolsets-header.md

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/github-mcp-tools-report.lock.yml

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/workflow/claude_engine.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -682,16 +682,24 @@ func (e *ClaudeEngine) renderGitHubClaudeMCPConfig(yaml *strings.Builder, github
682682

683683
// Use effective token with precedence: custom > top-level > default
684684
effectiveToken := getEffectiveGitHubToken(customGitHubToken, workflowData.GitHubToken)
685-
yaml.WriteString(fmt.Sprintf(" \"Authorization\": \"Bearer %s\"", effectiveToken))
685+
686+
// Collect headers in a map
687+
headers := make(map[string]string)
688+
headers["Authorization"] = fmt.Sprintf("Bearer %s", effectiveToken)
686689

687690
// Add X-MCP-Readonly header if read-only mode is enabled
688691
if readOnly {
689-
yaml.WriteString(",\n")
690-
yaml.WriteString(" \"X-MCP-Readonly\": \"true\"\n")
691-
} else {
692-
yaml.WriteString("\n")
692+
headers["X-MCP-Readonly"] = "true"
693+
}
694+
695+
// Add X-MCP-Toolsets header if toolsets are configured
696+
if toolsets != "" {
697+
headers["X-MCP-Toolsets"] = toolsets
693698
}
694699

700+
// Write headers using helper
701+
writeHeadersToYAML(yaml, headers, " ")
702+
695703
yaml.WriteString(" }\n")
696704
} else {
697705
// Local mode - use Docker-based GitHub MCP server (default)

pkg/workflow/copilot_engine.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,14 +278,24 @@ func (e *CopilotEngine) renderGitHubCopilotMCPConfig(yaml *strings.Builder, gith
278278
yaml.WriteString(" \"type\": \"http\",\n")
279279
yaml.WriteString(" \"url\": \"https://api.githubcopilot.com/mcp/\",\n")
280280
yaml.WriteString(" \"headers\": {\n")
281-
yaml.WriteString(" \"Authorization\": \"Bearer \\${GITHUB_PERSONAL_ACCESS_TOKEN}\"\n")
281+
282+
// Collect headers in a map
283+
headers := make(map[string]string)
284+
headers["Authorization"] = "Bearer \\${GITHUB_PERSONAL_ACCESS_TOKEN}"
282285

283286
// Add X-MCP-Readonly header if read-only mode is enabled
284287
if readOnly {
285-
yaml.WriteString(",\n")
286-
yaml.WriteString(" \"X-MCP-Readonly\": \"true\"\n")
288+
headers["X-MCP-Readonly"] = "true"
287289
}
288290

291+
// Add X-MCP-Toolsets header if toolsets are configured
292+
if toolsets != "" {
293+
headers["X-MCP-Toolsets"] = toolsets
294+
}
295+
296+
// Write headers using helper
297+
writeHeadersToYAML(yaml, headers, " ")
298+
289299
yaml.WriteString(" },\n")
290300

291301
// Populate tools field with allowed tools or "*" if none specified

pkg/workflow/env.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package workflow
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
)
8+
9+
// writeHeadersToYAML writes a map of headers to YAML format with proper comma placement
10+
// indent is the indentation string to use for each header line (e.g., " ")
11+
func writeHeadersToYAML(yaml *strings.Builder, headers map[string]string, indent string) {
12+
if len(headers) == 0 {
13+
return
14+
}
15+
16+
// Sort keys for deterministic output
17+
keys := make([]string, 0, len(headers))
18+
for key := range headers {
19+
keys = append(keys, key)
20+
}
21+
sort.Strings(keys)
22+
23+
// Write each header with proper comma placement
24+
for i, key := range keys {
25+
value := headers[key]
26+
if i < len(keys)-1 {
27+
// Not the last header, add comma
28+
fmt.Fprintf(yaml, "%s\"%s\": \"%s\",\n", indent, key, value)
29+
} else {
30+
// Last header, no comma
31+
fmt.Fprintf(yaml, "%s\"%s\": \"%s\"\n", indent, key, value)
32+
}
33+
}
34+
}

pkg/workflow/github_toolset_integration_test.go

Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,167 @@ This workflow tests remote mode with array toolsets.
202202

203203
yamlStr := string(yamlContent)
204204

205-
// In remote mode, toolsets are not currently supported via environment variables
206-
// The remote server might have different configuration mechanism
207-
// For now, we verify the workflow compiles successfully
205+
// In remote mode, toolsets should be passed via X-MCP-Toolsets header
208206
if !strings.Contains(yamlStr, "https://api.githubcopilot.com/mcp/") {
209207
t.Errorf("Expected remote mode URL in YAML")
210208
}
209+
210+
// Check for X-MCP-Toolsets header with the configured toolsets
211+
if !strings.Contains(yamlStr, `"X-MCP-Toolsets": "repos,issues"`) {
212+
t.Errorf("Expected X-MCP-Toolsets header with 'repos,issues' in remote mode, but didn't find it.\nGenerated YAML:\n%s", yamlStr)
213+
}
214+
}
215+
216+
func TestGitHubToolsetRemoteModeMultipleEngines(t *testing.T) {
217+
tests := []struct {
218+
name string
219+
workflowMD string
220+
expectedHeader string
221+
engineType string
222+
}{
223+
{
224+
name: "Claude remote mode with toolsets",
225+
workflowMD: `---
226+
on: push
227+
engine: claude
228+
tools:
229+
github:
230+
mode: remote
231+
toolset: [repos, issues, pull_requests]
232+
---
233+
234+
# Test Workflow
235+
236+
Claude remote mode with toolsets.
237+
`,
238+
expectedHeader: `"X-MCP-Toolsets": "repos,issues,pull_requests"`,
239+
engineType: "claude",
240+
},
241+
{
242+
name: "Copilot remote mode with toolsets",
243+
workflowMD: `---
244+
on: push
245+
engine: copilot
246+
tools:
247+
github:
248+
mode: remote
249+
toolset: [actions, discussions]
250+
---
251+
252+
# Test Workflow
253+
254+
Copilot remote mode with toolsets.
255+
`,
256+
expectedHeader: `"X-MCP-Toolsets": "actions,discussions"`,
257+
engineType: "copilot",
258+
},
259+
{
260+
name: "Remote mode with all toolsets",
261+
workflowMD: `---
262+
on: push
263+
engine: claude
264+
tools:
265+
github:
266+
mode: remote
267+
toolset: [all]
268+
---
269+
270+
# Test Workflow
271+
272+
Remote mode with all toolsets.
273+
`,
274+
expectedHeader: `"X-MCP-Toolsets": "all"`,
275+
engineType: "claude",
276+
},
277+
{
278+
name: "Remote mode without toolsets",
279+
workflowMD: `---
280+
on: push
281+
engine: claude
282+
tools:
283+
github:
284+
mode: remote
285+
---
286+
287+
# Test Workflow
288+
289+
Remote mode without toolsets.
290+
`,
291+
expectedHeader: "", // No header should be added
292+
engineType: "claude",
293+
},
294+
{
295+
name: "Remote mode with toolsets and read-only",
296+
workflowMD: `---
297+
on: push
298+
engine: copilot
299+
tools:
300+
github:
301+
mode: remote
302+
toolset: [repos, issues]
303+
read-only: true
304+
---
305+
306+
# Test Workflow
307+
308+
Remote mode with toolsets and read-only.
309+
`,
310+
expectedHeader: `"X-MCP-Toolsets": "repos,issues"`,
311+
engineType: "copilot",
312+
},
313+
}
314+
315+
for _, tt := range tests {
316+
t.Run(tt.name, func(t *testing.T) {
317+
// Create temporary directory for test
318+
tempDir := t.TempDir()
319+
mdPath := filepath.Join(tempDir, "test-workflow.md")
320+
321+
// Write workflow file
322+
err := os.WriteFile(mdPath, []byte(tt.workflowMD), 0644)
323+
if err != nil {
324+
t.Fatalf("Failed to write test workflow: %v", err)
325+
}
326+
327+
// Compile the workflow
328+
compiler := NewCompiler(false, "", "test")
329+
compileErr := compiler.CompileWorkflow(mdPath)
330+
if compileErr != nil {
331+
t.Fatalf("Failed to compile workflow: %v", compileErr)
332+
}
333+
334+
// Read the generated YAML
335+
yamlPath := strings.TrimSuffix(mdPath, ".md") + ".lock.yml"
336+
yamlContent, readErr := os.ReadFile(yamlPath)
337+
if readErr != nil {
338+
t.Fatalf("Failed to read generated YAML: %v", readErr)
339+
}
340+
341+
yamlStr := string(yamlContent)
342+
343+
// Verify remote mode URL
344+
if !strings.Contains(yamlStr, "https://api.githubcopilot.com/mcp/") {
345+
t.Errorf("Expected remote mode URL in YAML")
346+
}
347+
348+
// Check for expected header
349+
if tt.expectedHeader != "" {
350+
if !strings.Contains(yamlStr, tt.expectedHeader) {
351+
t.Errorf("Expected header %q in YAML but didn't find it.\nGenerated YAML:\n%s", tt.expectedHeader, yamlStr)
352+
}
353+
} else {
354+
// Verify no X-MCP-Toolsets header is present
355+
if strings.Contains(yamlStr, "X-MCP-Toolsets") {
356+
t.Errorf("Expected no X-MCP-Toolsets header in YAML but found one.\nGenerated YAML:\n%s", yamlStr)
357+
}
358+
}
359+
360+
// If read-only is in the test name, also verify X-MCP-Readonly header
361+
if strings.Contains(tt.name, "read-only") {
362+
if !strings.Contains(yamlStr, `"X-MCP-Readonly": "true"`) {
363+
t.Errorf("Expected X-MCP-Readonly header in YAML but didn't find it.\nGenerated YAML:\n%s", yamlStr)
364+
}
365+
}
366+
})
367+
}
211368
}

0 commit comments

Comments
 (0)