Skip to content

Commit bd8c160

Browse files
ci(mcp-diff): add streamable-http job with header-based configs
Adds a sibling mcp-diff-http job that exercises the streamable-http transport against a shared HTTP server, with per-config settings supplied via X-MCP-* request headers — mirroring how the remote server is invoked in production (server-side defaults + per-user header overrides). The config generator gains a -transport flag: - stdio (default, unchanged behaviour) - http-headers (emits headers-only configs targeting a shared server) Two new combined entries layer multiple headers together as a smoke test for header-merging regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1c21513 commit bd8c160

2 files changed

Lines changed: 232 additions & 28 deletions

File tree

.github/workflows/mcp-diff.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,51 @@ jobs:
6161
echo "- New tools/toolsets added" >> $GITHUB_STEP_SUMMARY
6262
echo "- Tool descriptions updated" >> $GITHUB_STEP_SUMMARY
6363
echo "- Capability changes (intentional improvements)" >> $GITHUB_STEP_SUMMARY
64+
65+
mcp-diff-http:
66+
runs-on: ubuntu-latest
67+
68+
steps:
69+
- name: Check out code
70+
uses: actions/checkout@v6
71+
with:
72+
fetch-depth: 0
73+
74+
- name: Set up Go
75+
uses: actions/setup-go@v5
76+
with:
77+
go-version-file: go.mod
78+
79+
- name: Build UI
80+
uses: ./.github/actions/build-ui
81+
82+
- name: Generate diff configurations
83+
id: configs
84+
# See script/print-mcp-diff-configs/main.go. The http-headers variant
85+
# points every config at a shared HTTP server started by the action
86+
# and carries per-config settings via X-MCP-* headers, mirroring how
87+
# the remote server is invoked in production (server-side defaults +
88+
# per-user header overrides).
89+
run: |
90+
{
91+
echo 'configurations<<MCP_DIFF_EOF'
92+
go run ./script/print-mcp-diff-configs -transport http-headers
93+
echo 'MCP_DIFF_EOF'
94+
} >> "$GITHUB_OUTPUT"
95+
96+
- name: Run MCP Server Diff (streamable-http)
97+
uses: SamMorrowDrums/mcp-server-diff@v2.3.5
98+
with:
99+
setup_go: "false"
100+
install_command: go mod download
101+
http_start_command: go run ./cmd/github-mcp-server http --port 8082
102+
http_startup_wait_ms: "5000"
103+
configurations: ${{ steps.configs.outputs.configurations }}
104+
105+
- name: Add interpretation note
106+
if: always()
107+
run: |
108+
echo "" >> $GITHUB_STEP_SUMMARY
109+
echo "---" >> $GITHUB_STEP_SUMMARY
110+
echo "" >> $GITHUB_STEP_SUMMARY
111+
echo "ℹ️ **Note:** This job exercises the streamable-http transport against a shared server, with per-config settings supplied via X-MCP-* request headers." >> $GITHUB_STEP_SUMMARY
Lines changed: 184 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
// Command print-mcp-diff-configs emits the full configuration matrix consumed
2-
// by the mcp-server-diff GitHub Action. The matrix is composed of three parts:
1+
// Command print-mcp-diff-configs emits the configuration matrix consumed by
2+
// the mcp-server-diff GitHub Action. The matrix is composed of three parts:
33
//
44
// 1. Hand-curated baseline configs (default, read-only, common toolset combos)
55
// 2. Insiders configs (--insiders, --insiders --read-only) — meta flag that
@@ -8,54 +8,210 @@
88
// in sync with the Go source so any new user-controllable feature flag
99
// gets diffed without touching the workflow
1010
//
11-
// Usage: go run ./script/print-mcp-diff-configs
11+
// The same logical matrix is rendered for two transports, selected by
12+
// -transport:
13+
//
14+
// stdio Default. Args are appended to the action's top-level
15+
//
16+
// start_command (one stdio process per config).
17+
//
18+
// http-headers streamable-http transport against a shared HTTP server. The
19+
//
20+
// server is started once with no extra flags and every config
21+
// provides its settings via X-MCP-* request headers, mirroring
22+
// how the remote server is invoked in production (server-side
23+
// defaults + per-user header overrides).
24+
//
25+
// Usage:
26+
//
27+
// go run ./script/print-mcp-diff-configs
28+
// go run ./script/print-mcp-diff-configs -transport http-headers
1229
package main
1330

1431
import (
1532
"encoding/json"
33+
"flag"
1634
"fmt"
1735
"os"
1836
"sort"
37+
"strings"
1938

2039
"github.com/github/github-mcp-server/pkg/github"
40+
mcphdr "github.com/github/github-mcp-server/pkg/http/headers"
2141
)
2242

2343
type config struct {
24-
Name string `json:"name"`
25-
Args string `json:"args"`
44+
Name string `json:"name"`
45+
Args string `json:"args,omitempty"`
46+
Transport string `json:"transport,omitempty"`
47+
ServerURL string `json:"server_url,omitempty"`
48+
Headers map[string]string `json:"headers,omitempty"`
2649
}
2750

51+
// baseEntry describes one logical configuration in transport-agnostic form.
52+
// settings are translated to either CLI flags or X-MCP-* headers depending on
53+
// the target transport.
54+
type baseEntry struct {
55+
name string
56+
settings settings
57+
}
58+
59+
type settings struct {
60+
toolsets string // comma-separated, "" for defaults
61+
tools string
62+
excludeTools string
63+
features string
64+
readOnly bool
65+
insiders bool
66+
lockdown bool
67+
}
68+
69+
const httpServerURL = "http://localhost:8082/mcp"
70+
2871
func main() {
29-
configs := []config{
30-
{Name: "default", Args: ""},
31-
{Name: "read-only", Args: "--read-only"},
32-
{Name: "toolsets-repos", Args: "--toolsets=repos"},
33-
{Name: "toolsets-issues", Args: "--toolsets=issues"},
34-
{Name: "toolsets-context", Args: "--toolsets=context"},
35-
{Name: "toolsets-pull_requests", Args: "--toolsets=pull_requests"},
36-
{Name: "toolsets-repos,issues", Args: "--toolsets=repos,issues"},
37-
{Name: "toolsets-issues,context", Args: "--toolsets=issues,context"},
38-
{Name: "toolsets-all", Args: "--toolsets=all"},
39-
{Name: "tools-get_me", Args: "--tools=get_me"},
40-
{Name: "tools-get_me,list_issues", Args: "--tools=get_me,list_issues"},
41-
{Name: "toolsets-repos+read-only", Args: "--toolsets=repos --read-only"},
42-
{Name: "insiders", Args: "--insiders"},
43-
{Name: "insiders+read-only", Args: "--insiders --read-only"},
72+
transport := flag.String("transport", "stdio", "Transport to target: stdio or http-headers")
73+
flag.Parse()
74+
75+
entries := baseEntries()
76+
77+
var out []config
78+
switch *transport {
79+
case "stdio":
80+
for _, e := range entries {
81+
out = append(out, config{Name: e.name, Args: e.settings.toArgs()})
82+
}
83+
case "http-headers":
84+
for _, e := range entries {
85+
h := e.settings.toHeaders()
86+
if h == nil {
87+
h = map[string]string{}
88+
}
89+
// The action's top-level headers may be replaced (not merged) by
90+
// per-config headers, so always include the bearer token here.
91+
// The token must match a recognized GitHub prefix so the server's
92+
// Authorization parser accepts it without contacting the API.
93+
h[mcphdr.AuthorizationHeader] = "Bearer ghp_test"
94+
out = append(out, config{
95+
Name: e.name,
96+
Transport: "streamable-http",
97+
ServerURL: httpServerURL,
98+
Headers: h,
99+
})
100+
}
101+
default:
102+
fmt.Fprintf(os.Stderr, "unknown transport %q (want stdio or http-headers)\n", *transport)
103+
os.Exit(2)
104+
}
105+
106+
enc := json.NewEncoder(os.Stdout)
107+
enc.SetIndent("", " ")
108+
if err := enc.Encode(out); err != nil {
109+
fmt.Fprintln(os.Stderr, err)
110+
os.Exit(1)
111+
}
112+
}
113+
114+
func baseEntries() []baseEntry {
115+
entries := []baseEntry{
116+
{name: "default"},
117+
{name: "read-only", settings: settings{readOnly: true}},
118+
{name: "toolsets-repos", settings: settings{toolsets: "repos"}},
119+
{name: "toolsets-issues", settings: settings{toolsets: "issues"}},
120+
{name: "toolsets-context", settings: settings{toolsets: "context"}},
121+
{name: "toolsets-pull_requests", settings: settings{toolsets: "pull_requests"}},
122+
{name: "toolsets-repos,issues", settings: settings{toolsets: "repos,issues"}},
123+
{name: "toolsets-issues,context", settings: settings{toolsets: "issues,context"}},
124+
{name: "toolsets-all", settings: settings{toolsets: "all"}},
125+
{name: "tools-get_me", settings: settings{tools: "get_me"}},
126+
{name: "tools-get_me,list_issues", settings: settings{tools: "get_me,list_issues"}},
127+
{name: "toolsets-repos+read-only", settings: settings{toolsets: "repos", readOnly: true}},
128+
{name: "insiders", settings: settings{insiders: true}},
129+
{name: "insiders+read-only", settings: settings{insiders: true, readOnly: true}},
130+
// Combined entries: exercise multiple settings together so we catch
131+
// regressions when several X-MCP-* headers (or CLI flags) are merged.
132+
{name: "combined-toolsets+exclude+readonly", settings: settings{
133+
toolsets: "repos,issues",
134+
excludeTools: "delete_file",
135+
readOnly: true,
136+
}},
137+
{name: "combined-insiders+toolsets+features", settings: settings{
138+
insiders: true,
139+
toolsets: "repos",
140+
features: firstFeatureFlag(),
141+
}},
44142
}
45143

46144
flags := append([]string(nil), github.AllowedFeatureFlags...)
47145
sort.Strings(flags)
48146
for _, f := range flags {
49-
configs = append(configs, config{
50-
Name: "feature-" + f,
51-
Args: "--features=" + f,
147+
entries = append(entries, baseEntry{
148+
name: "feature-" + f,
149+
settings: settings{features: f},
52150
})
53151
}
152+
return entries
153+
}
54154

55-
enc := json.NewEncoder(os.Stdout)
56-
enc.SetIndent("", " ")
57-
if err := enc.Encode(configs); err != nil {
58-
fmt.Fprintln(os.Stderr, err)
59-
os.Exit(1)
155+
func (s settings) toArgs() string {
156+
var parts []string
157+
if s.toolsets != "" {
158+
parts = append(parts, "--toolsets="+s.toolsets)
159+
}
160+
if s.tools != "" {
161+
parts = append(parts, "--tools="+s.tools)
162+
}
163+
if s.excludeTools != "" {
164+
parts = append(parts, "--exclude-tools="+s.excludeTools)
165+
}
166+
if s.features != "" {
167+
parts = append(parts, "--features="+s.features)
168+
}
169+
if s.readOnly {
170+
parts = append(parts, "--read-only")
171+
}
172+
if s.insiders {
173+
parts = append(parts, "--insiders")
174+
}
175+
if s.lockdown {
176+
parts = append(parts, "--lockdown-mode")
177+
}
178+
return strings.Join(parts, " ")
179+
}
180+
181+
func (s settings) toHeaders() map[string]string {
182+
h := map[string]string{}
183+
if s.toolsets != "" {
184+
h[mcphdr.MCPToolsetsHeader] = s.toolsets
60185
}
186+
if s.tools != "" {
187+
h[mcphdr.MCPToolsHeader] = s.tools
188+
}
189+
if s.excludeTools != "" {
190+
h[mcphdr.MCPExcludeToolsHeader] = s.excludeTools
191+
}
192+
if s.features != "" {
193+
h[mcphdr.MCPFeaturesHeader] = s.features
194+
}
195+
if s.readOnly {
196+
h[mcphdr.MCPReadOnlyHeader] = "true"
197+
}
198+
if s.insiders {
199+
h[mcphdr.MCPInsidersHeader] = "true"
200+
}
201+
if s.lockdown {
202+
h[mcphdr.MCPLockdownHeader] = "true"
203+
}
204+
if len(h) == 0 {
205+
return nil
206+
}
207+
return h
208+
}
209+
210+
func firstFeatureFlag() string {
211+
flags := append([]string(nil), github.AllowedFeatureFlags...)
212+
if len(flags) == 0 {
213+
return ""
214+
}
215+
sort.Strings(flags)
216+
return flags[0]
61217
}

0 commit comments

Comments
 (0)