Skip to content

Commit 68bb295

Browse files
authored
refactor: eliminate three duplicate code patterns in launcher, config, and server packages (#1585)
- [x] Pattern 1 (#1540): Extract `launchStdioConnection` helper in `internal/launcher/launcher.go` - [x] Pattern 2 (#1541): Add `GetAPIKey()` to `*config.Config` and replace 3 occurrences in `root.go` - [x] Pattern 3 (#1542): Add `writeJSONResponse` to `http_helpers.go` and replace occurrences in `handlers.go`, `health.go`, `routed.go` - [x] Add unit tests for `GetAPIKey()` in config test file - [x] Fix: `rejectIfShutdown` now passes `json.RawMessage(shutdownErrorJSON)` to keep the constant referenced in production code <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>[duplicate-code] Duplicate Code Analysis Report</issue_title> > <issue_description>## Summary > > Analysis of the Go codebase identified **3 significant duplication patterns** spanning the `launcher`, `server`, and `cmd` packages. Two patterns involve repeated 3-line idioms appearing 3+ times; one involves ~20 lines of near-identical goroutine+timeout logic duplicated across two closely related functions. > > ## Detected Patterns > > This analysis found 3 significant duplication patterns: > > 1. **Goroutine+timeout connection launch logic in launcher.go** – Severity: **High** – See sub-issue #1540 > 2. **API key extraction from gateway config in root.go** – Severity: **Medium** – See sub-issue #1541 > 3. **JSON response writing pattern in server package** – Severity: **Low** – See sub-issue #1542 > > ## Overall Impact > > - **Total Duplicated Lines**: ~35 lines (excluding minor boilerplate) > - **Affected Files**: 3 Go files (`internal/launcher/launcher.go`, `internal/cmd/root.go`, `internal/server/handlers.go` / `health.go` / `routed.go`) > - **Maintainability Risk**: Medium — the launcher pattern is the most risky; a bug fix in one `select`/timeout branch would need to be replicated manually in the other > - **Refactoring Priority**: Medium — the launcher pattern should be addressed first; the others are straightforward one-liner extractions > > ## Next Steps > > 1. Review individual pattern sub-issues for detailed analysis > 2. Prioritize `#1540` (launcher goroutine pattern) as it poses the greatest bug-divergence risk > 3. `#1541` and `#1542` are small extractions that can be done opportunistically > > ## Analysis Metadata > > - **Analyzed Files**: 68 non-test Go files under `internal/` and `main.go` > - **Detection Method**: Static code analysis (grep, structural comparison) > - **Commit**: 18c5699 > - **Analysis Date**: 2026-03-02</issue_description> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > </comments> > </details> > **Custom agent used: agentic-workflows** > GitHub Agentic Workflows (gh-aw) - Create, debug, and upgrade AI-powered workflows with intelligent prompt routing <!-- START COPILOT CODING AGENT SUFFIX --> - Fixes #1539 <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs.
2 parents 598a392 + aac88d9 commit 68bb295

8 files changed

Lines changed: 68 additions & 91 deletions

File tree

internal/cmd/root.go

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,7 @@ func run(cmd *cobra.Command, args []string) error {
285285
logger.LogInfoMd("startup", "Routes: /mcp/<server> for servers: %v", unifiedServer.GetServerIDs())
286286

287287
// Extract API key from gateway config (spec 7.1)
288-
apiKey := ""
289-
if cfg.Gateway != nil {
290-
apiKey = cfg.Gateway.APIKey
291-
}
288+
apiKey := cfg.GetAPIKey()
292289

293290
httpServer = server.CreateHTTPServerForRoutedMode(listenAddr, unifiedServer, apiKey)
294291
} else {
@@ -298,10 +295,7 @@ func run(cmd *cobra.Command, args []string) error {
298295
logger.LogInfoMd("startup", "Endpoint: /mcp")
299296

300297
// Extract API key from gateway config (spec 7.1)
301-
apiKey := ""
302-
if cfg.Gateway != nil {
303-
apiKey = cfg.Gateway.APIKey
304-
}
298+
apiKey := cfg.GetAPIKey()
305299

306300
httpServer = server.CreateHTTPServerForMCP(listenAddr, unifiedServer, apiKey)
307301
}
@@ -362,10 +356,7 @@ func writeGatewayConfig(cfg *config.Config, listenAddr, mode string, w io.Writer
362356
debugLog.Printf("Resolved gateway address: host=%s, port=%s", host, port)
363357

364358
// Extract API key from gateway config (per spec section 7.1)
365-
apiKey := ""
366-
if cfg.Gateway != nil {
367-
apiKey = cfg.Gateway.APIKey
368-
}
359+
apiKey := cfg.GetAPIKey()
369360
debugLog.Printf("Gateway config: auth_enabled=%v", apiKey != "")
370361

371362
debugLog.Printf("Gateway auth: apiKeyConfigured=%v", apiKey != "")

internal/config/config_core.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ type GatewayConfig struct {
9191
PayloadSizeThreshold int `toml:"payload_size_threshold" json:"payload_size_threshold,omitempty"`
9292
}
9393

94+
// GetAPIKey returns the gateway API key, handling a nil Gateway safely.
95+
func (c *Config) GetAPIKey() string {
96+
if c.Gateway == nil {
97+
return ""
98+
}
99+
return c.Gateway.APIKey
100+
}
101+
94102
// ServerConfig represents an individual MCP server configuration.
95103
type ServerConfig struct {
96104
// Type is the server type: "stdio" or "http"

internal/config/config_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1609,3 +1609,21 @@ func TestApplyGatewayDefaults_OtherFieldsUnaffected(t *testing.T) {
16091609
// Defaults applied for the zero fields
16101610
assert.Equal(t, DefaultPort, cfg.Port)
16111611
}
1612+
1613+
// TestGetAPIKey verifies that GetAPIKey handles nil Gateway and returns the key when set.
1614+
func TestGetAPIKey(t *testing.T) {
1615+
t.Run("nil Gateway returns empty string", func(t *testing.T) {
1616+
cfg := &Config{}
1617+
assert.Equal(t, "", cfg.GetAPIKey())
1618+
})
1619+
1620+
t.Run("Gateway with no key returns empty string", func(t *testing.T) {
1621+
cfg := &Config{Gateway: &GatewayConfig{}}
1622+
assert.Equal(t, "", cfg.GetAPIKey())
1623+
})
1624+
1625+
t.Run("Gateway with key returns key", func(t *testing.T) {
1626+
cfg := &Config{Gateway: &GatewayConfig{APIKey: "my-secret-key"}}
1627+
assert.Equal(t, "my-secret-key", cfg.GetAPIKey())
1628+
})
1629+
}

internal/launcher/launcher.go

Lines changed: 24 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -123,56 +123,12 @@ func GetOrLaunch(l *Launcher, serverID string) (*mcp.Connection, error) {
123123
}
124124

125125
// stdio backends from this point
126-
// Warn if using direct command in a container
127-
isDirectCommand := serverCfg.Command != "docker"
128-
if l.runningInContainer && isDirectCommand {
129-
l.logSecurityWarning(serverID, serverCfg)
130-
}
131-
132-
// Log the command being executed
133-
l.logLaunchStart(serverID, "", serverCfg, isDirectCommand)
134-
135-
// Check for environment variable passthrough (only check args after -e flags)
136-
l.logEnvPassthrough(serverCfg.Args)
137-
138-
if len(serverCfg.Env) > 0 {
139-
log.Printf("[LAUNCHER] Additional env vars: %v", sanitize.TruncateSecretMap(serverCfg.Env))
140-
}
141-
142-
log.Printf("[LAUNCHER] Starting server with %v timeout", l.startupTimeout)
143-
logLauncher.Printf("Starting server with timeout: serverID=%s, timeout=%v", serverID, l.startupTimeout)
144-
145-
// Create a buffered channel to receive connection result
146-
// Buffer size of 1 prevents goroutine leak if timeout occurs before connection completes
147-
resultChan := make(chan connectionResult, 1)
148-
149-
logLauncher.Printf("Starting connection goroutine: serverID=%s", serverID)
150-
// Launch connection in a goroutine
151-
go func() {
152-
conn, err := mcp.NewConnection(l.ctx, serverID, serverCfg.Command, serverCfg.Args, serverCfg.Env)
153-
resultChan <- connectionResult{conn, err}
154-
}()
155-
156-
// Wait for connection with timeout
157-
select {
158-
case result := <-resultChan:
159-
conn, err := result.conn, result.err
160-
if err != nil {
161-
// Enhanced error logging for command-based servers
162-
l.logLaunchError(serverID, "", err, serverCfg, isDirectCommand)
163-
return nil, fmt.Errorf("failed to create connection: %w", err)
164-
}
165-
166-
l.logLaunchSuccess(serverID, "")
167-
168-
l.connections[serverID] = conn
169-
return conn, nil
170-
171-
case <-time.After(l.startupTimeout):
172-
// Timeout occurred
173-
l.logTimeoutError(serverID, "")
174-
return nil, fmt.Errorf("server startup timeout after %v", l.startupTimeout)
126+
conn, err := l.launchStdioConnection(serverID, "", serverCfg)
127+
if err != nil {
128+
return nil, err
175129
}
130+
l.connections[serverID] = conn
131+
return conn, nil
176132
}
177133

178134
// GetOrLaunchForSession returns a session-aware connection or launches a new one
@@ -219,6 +175,22 @@ func GetOrLaunchForSession(l *Launcher, serverID, sessionID string) (*mcp.Connec
219175
return conn, nil
220176
}
221177

178+
conn, err := l.launchStdioConnection(serverID, sessionID, serverCfg)
179+
if err != nil {
180+
// Record error in session pool
181+
l.sessionPool.RecordError(serverID, sessionID)
182+
return nil, err
183+
}
184+
185+
// Add to session pool
186+
l.sessionPool.Set(serverID, sessionID, conn)
187+
return conn, nil
188+
}
189+
190+
// launchStdioConnection creates a new stdio connection using a goroutine+timeout pattern.
191+
// It handles the security warning, launch logging, and the buffered-channel/select timeout.
192+
// Returns the raw *mcp.Connection on success; the caller is responsible for storing it.
193+
func (l *Launcher) launchStdioConnection(serverID, sessionID string, serverCfg *config.ServerConfig) (*mcp.Connection, error) {
222194
// Warn if using direct command in a container
223195
isDirectCommand := serverCfg.Command != "docker"
224196
if l.runningInContainer && isDirectCommand {
@@ -235,13 +207,14 @@ func GetOrLaunchForSession(l *Launcher, serverID, sessionID string) (*mcp.Connec
235207
log.Printf("[LAUNCHER] Additional env vars: %v", sanitize.TruncateSecretMap(serverCfg.Env))
236208
}
237209

238-
log.Printf("[LAUNCHER] Starting server for session with %v timeout", l.startupTimeout)
239-
logLauncher.Printf("Starting session server with timeout: serverID=%s, sessionID=%s, timeout=%v", serverID, sessionID, l.startupTimeout)
210+
log.Printf("[LAUNCHER] Starting server with %v timeout", l.startupTimeout)
211+
logLauncher.Printf("Starting server with timeout: serverID=%s, sessionID=%s, timeout=%v", serverID, sessionID, l.startupTimeout)
240212

241213
// Create a buffered channel to receive connection result
242214
// Buffer size of 1 prevents goroutine leak if timeout occurs before connection completes
243215
resultChan := make(chan connectionResult, 1)
244216

217+
logLauncher.Printf("Starting connection goroutine: serverID=%s", serverID)
245218
// Launch connection in a goroutine
246219
go func() {
247220
conn, err := mcp.NewConnection(l.ctx, serverID, serverCfg.Command, serverCfg.Args, serverCfg.Env)
@@ -254,24 +227,13 @@ func GetOrLaunchForSession(l *Launcher, serverID, sessionID string) (*mcp.Connec
254227
conn, err := result.conn, result.err
255228
if err != nil {
256229
l.logLaunchError(serverID, sessionID, err, serverCfg, isDirectCommand)
257-
258-
// Record error in session pool
259-
l.sessionPool.RecordError(serverID, sessionID)
260-
261230
return nil, fmt.Errorf("failed to create connection: %w", err)
262231
}
263-
264232
l.logLaunchSuccess(serverID, sessionID)
265-
266-
// Add to session pool
267-
l.sessionPool.Set(serverID, sessionID, conn)
268233
return conn, nil
269234

270235
case <-time.After(l.startupTimeout):
271-
// Timeout occurred
272236
l.logTimeoutError(serverID, sessionID)
273-
// Record error in session pool before returning
274-
l.sessionPool.RecordError(serverID, sessionID)
275237
return nil, fmt.Errorf("server startup timeout after %v", l.startupTimeout)
276238
}
277239
}

internal/server/handlers.go

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package server
22

33
import (
44
"context"
5-
"encoding/json"
65
"log"
76
"net/http"
87
"os"
@@ -51,9 +50,7 @@ func handleClose(unifiedServer *UnifiedServer) http.Handler {
5150
if unifiedServer.IsShutdown() {
5251
logHandlers.Print("Gateway already shutdown, returning 410 Gone")
5352
logger.LogWarn("shutdown", "Close endpoint called but gateway already closed, remote=%s", r.RemoteAddr)
54-
w.Header().Set("Content-Type", "application/json")
55-
w.WriteHeader(http.StatusGone) // 410 Gone
56-
json.NewEncoder(w).Encode(map[string]interface{}{
53+
writeJSONResponse(w, http.StatusGone, map[string]interface{}{
5754
"error": "Gateway has already been closed",
5855
})
5956
return
@@ -65,14 +62,11 @@ func handleClose(unifiedServer *UnifiedServer) http.Handler {
6562
logHandlers.Printf("Shutdown completed: servers_terminated=%d", serversTerminated)
6663

6764
// Return success response (spec 5.1.3)
68-
w.Header().Set("Content-Type", "application/json")
69-
w.WriteHeader(http.StatusOK)
70-
response := map[string]interface{}{
65+
writeJSONResponse(w, http.StatusOK, map[string]interface{}{
7166
"status": "closed",
7267
"message": "Gateway shutdown initiated",
7368
"serversTerminated": serversTerminated,
74-
}
75-
json.NewEncoder(w).Encode(response)
69+
})
7670

7771
logger.LogInfo("shutdown", "Close endpoint response sent, servers_terminated=%d", serversTerminated)
7872
log.Printf("Gateway shutdown initiated. Terminated %d server(s)", serversTerminated)

internal/server/health.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package server
22

33
import (
4-
"encoding/json"
54
"net/http"
65

76
"github.com/github/gh-aw-mcpg/internal/logger"
@@ -52,11 +51,8 @@ func HandleHealth(unifiedServer *UnifiedServer) http.HandlerFunc {
5251
return func(w http.ResponseWriter, r *http.Request) {
5352
logHealth.Printf("Health check request: method=%s, remote=%s", r.Method, r.RemoteAddr)
5453

55-
w.Header().Set("Content-Type", "application/json")
56-
w.WriteHeader(http.StatusOK)
57-
5854
response := BuildHealthResponse(unifiedServer)
5955
logHealth.Printf("Health response: status=%s, servers=%d", response.Status, len(response.Servers))
60-
json.NewEncoder(w).Encode(response)
56+
writeJSONResponse(w, http.StatusOK, response)
6157
}
6258
}

internal/server/http_helpers.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package server
33
import (
44
"bytes"
55
"context"
6+
"encoding/json"
67
"io"
78
"log"
89
"net/http"
@@ -15,6 +16,14 @@ import (
1516

1617
var logHelpers = logger.New("server:helpers")
1718

19+
// writeJSONResponse sets the Content-Type header, writes the status code, and encodes
20+
// body as JSON. It centralises the three-line pattern used across HTTP handlers.
21+
func writeJSONResponse(w http.ResponseWriter, statusCode int, body interface{}) {
22+
w.Header().Set("Content-Type", "application/json")
23+
w.WriteHeader(statusCode)
24+
json.NewEncoder(w).Encode(body)
25+
}
26+
1827
// withResponseLogging wraps an http.Handler to log response bodies
1928
func withResponseLogging(handler http.Handler) http.Handler {
2029
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

internal/server/routed.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package server
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"log"
78
"net/http"
@@ -22,9 +23,7 @@ func rejectIfShutdown(unifiedServer *UnifiedServer, next http.Handler, logNamesp
2223
if unifiedServer.IsShutdown() {
2324
log.Printf("Rejecting request during shutdown: remote=%s, method=%s, path=%s", r.RemoteAddr, r.Method, r.URL.Path)
2425
logger.LogWarn("shutdown", "Request rejected during shutdown, remote=%s, path=%s", r.RemoteAddr, r.URL.Path)
25-
w.Header().Set("Content-Type", "application/json")
26-
w.WriteHeader(http.StatusServiceUnavailable)
27-
w.Write([]byte(shutdownErrorJSON))
26+
writeJSONResponse(w, http.StatusServiceUnavailable, json.RawMessage(shutdownErrorJSON))
2827
return
2928
}
3029
next.ServeHTTP(w, r)

0 commit comments

Comments
 (0)