Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
26 changes: 26 additions & 0 deletions internal/server/http_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/github/gh-aw-mcpg/internal/logger/sanitize"
"github.com/github/gh-aw-mcpg/internal/mcp"
"github.com/github/gh-aw-mcpg/internal/tracing"
sdk "github.com/modelcontextprotocol/go-sdk/mcp"
)

var logHelpers = logger.New("server:helpers")
Expand Down Expand Up @@ -166,6 +167,31 @@ func wrapWithMiddleware(handler http.Handler, logTag string, unifiedServer *Unif
return tracingHandler.ServeHTTP
}

// buildMCPHandler constructs the standard streamable HTTP handler stack used by both
// unified (transport.go) and routed (routed.go) server modes.
//
// The stack (innermost to outermost) is:
// 1. sdk.NewStreamableHTTPHandler – stateful MCP session management
// 2. WrapWithSessionAutoInit – transparent auto-init for clients that skip the
// MCP initialize handshake (e.g. Gemini CLI v0.37.x)
// 3. wrapWithMiddleware – standard middleware chain (OTEL → auth → HMAC →
// shutdown check → SDK logging)
func buildMCPHandler(
serverFactory func(*http.Request) *sdk.Server,
log *logger.Logger,
sessionTimeout time.Duration,
logTag string,
unifiedServer *UnifiedServer,
apiKey, hmacSecret string,
) http.Handler {
h := sdk.NewStreamableHTTPHandler(serverFactory, &sdk.StreamableHTTPOptions{
Stateless: false,
Logger: logger.NewSlogLoggerWithHandler(log),
SessionTimeout: sessionTimeout,
})
return wrapWithMiddleware(WrapWithSessionAutoInit(h), logTag, unifiedServer, apiKey, hmacSecret)
}

// WithSDKLogging wraps an SDK StreamableHTTPHandler to log JSON-RPC translation results.
// This captures the request/response at the HTTP boundary to understand what the SDK
// sees and what it returns, particularly for debugging protocol state issues.
Expand Down
17 changes: 3 additions & 14 deletions internal/server/routed.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@ func CreateHTTPServerForRoutedMode(addr string, unifiedServer *UnifiedServer, ap
backendID := serverID
route := fmt.Sprintf("/mcp/%s", backendID)

// Create StreamableHTTP handler for this route
routeHandler := sdk.NewStreamableHTTPHandler(func(r *http.Request) *sdk.Server {
// Create the standard MCP handler stack (StreamableHTTP + session auto-init + middleware).
finalHandler := buildMCPHandler(func(r *http.Request) *sdk.Server {
if _, ok := setupSessionCallback(r, backendID); !ok {
return nil
}
Expand All @@ -171,18 +171,7 @@ func CreateHTTPServerForRoutedMode(addr string, unifiedServer *UnifiedServer, ap
return serverCache.getOrCreate(backendID, sessionID, func() *sdk.Server {
return createFilteredServer(unifiedServer, backendID)
})
}, &sdk.StreamableHTTPOptions{
Stateless: false,
Logger: logger.NewSlogLoggerWithHandler(logRouted),
SessionTimeout: routedSessionTimeout,
})

// Wrap with session auto-init to handle clients (e.g. Gemini CLI v0.37.x) that send
// tools/call before completing the MCP initialize handshake.
autoInitHandler := WrapWithSessionAutoInit(routeHandler)

// Apply standard middleware stack (outermost-first: OTEL tracing → auth → HMAC → shutdown check → SDK logging)
finalHandler := wrapWithMiddleware(autoInitHandler, "routed:"+backendID, unifiedServer, apiKey, hmacSecret)
}, logRouted, routedSessionTimeout, "routed:"+backendID, unifiedServer, apiKey, hmacSecret)

// Mount the handler at both /mcp/<server> and /mcp/<server>/
mux.Handle(route+"/", finalHandler)
Expand Down
17 changes: 3 additions & 14 deletions internal/server/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ func CreateHTTPServerForMCP(addr string, unifiedServer *UnifiedServer, apiKey, h
registerCommonEndpoints(mux, unifiedServer, apiKey)

logTransport.Print("Registering streamable HTTP handler for MCP protocol")
// Create StreamableHTTP handler for MCP protocol (supports POST requests)
// Create the standard MCP handler stack (StreamableHTTP + session auto-init + middleware).
// This is what Codex uses with transport = "streamablehttp"
streamableHandler := sdk.NewStreamableHTTPHandler(func(r *http.Request) *sdk.Server {
finalHandler := buildMCPHandler(func(r *http.Request) *sdk.Server {
// With streamable HTTP, this callback fires for each new session establishment
// Subsequent JSON-RPC messages in the same session are handled by the SDK
// We use the Authorization header value as the session ID
Expand All @@ -37,18 +37,7 @@ func CreateHTTPServerForMCP(addr string, unifiedServer *UnifiedServer, apiKey, h
}

return unifiedServer.server
}, &sdk.StreamableHTTPOptions{
Stateless: false, // Support stateful sessions
Logger: logger.NewSlogLoggerWithHandler(logTransport), // Integrate SDK logging with project logger
SessionTimeout: envutil.GetEnvDuration("MCP_GATEWAY_SESSION_TIMEOUT", 6*time.Hour), // Configurable; 6h default matches GitHub Actions default timeout
})

// Wrap with session auto-init to handle clients (e.g. Gemini CLI v0.37.x) that send
// tools/call before completing the MCP initialize handshake.
autoInitHandler := WrapWithSessionAutoInit(streamableHandler)

// Apply standard middleware stack (outermost-first: OTEL tracing → auth → HMAC → shutdown check → SDK logging)
finalHandler := wrapWithMiddleware(autoInitHandler, "unified", unifiedServer, apiKey, hmacSecret)
}, logTransport, envutil.GetEnvDuration("MCP_GATEWAY_SESSION_TIMEOUT", 6*time.Hour), "unified", unifiedServer, apiKey, hmacSecret)

// Mount handler at /mcp endpoint (logging is done in the callback above)
mux.Handle("/mcp/", finalHandler)
Expand Down
Loading