Skip to content

Commit 77c4cc9

Browse files
committed
feat(mcp): add configurable header forwarding to GraphQL requests
Implements header forwarding from MCP requests to GraphQL endpoint with opt-in configuration for additional headers beyond Authorization. Key changes: - Authorization header is always forwarded (maintains backward compatibility) - New `forward_headers` config with `enabled` (default: false) and `allow_list` - Support for exact header matches and regex patterns (e.g., "X-.*") - Case-insensitive header matching - Context-based header storage and filtering - Comprehensive unit tests for filtering logic - Updated JSON schema with validation Configuration example: ```yaml mcp: forward_headers: enabled: true allow_list: - "X-Tenant-ID" - "X-Trace-ID" - "X-.*"
1 parent 72510e2 commit 77c4cc9

File tree

5 files changed

+419
-11
lines changed

5 files changed

+419
-11
lines changed

router/core/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,7 @@ func (r *Router) bootstrap(ctx context.Context) error {
886886
mcpserver.WithEnableArbitraryOperations(r.mcp.EnableArbitraryOperations),
887887
mcpserver.WithExposeSchema(r.mcp.ExposeSchema),
888888
mcpserver.WithStateless(r.mcp.Session.Stateless),
889+
mcpserver.WithForwardHeaders(r.mcp.ForwardHeaders.Enabled, r.mcp.ForwardHeaders.AllowList),
889890
}
890891

891892
// Determine the router GraphQL endpoint

router/pkg/config/config.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -963,15 +963,21 @@ type CacheWarmupConfiguration struct {
963963
}
964964

965965
type MCPConfiguration struct {
966-
Enabled bool `yaml:"enabled" envDefault:"false" env:"MCP_ENABLED"`
967-
Server MCPServer `yaml:"server,omitempty"`
968-
Storage MCPStorageConfig `yaml:"storage,omitempty"`
969-
Session MCPSessionConfig `yaml:"session,omitempty"`
970-
GraphName string `yaml:"graph_name" envDefault:"mygraph" env:"MCP_GRAPH_NAME"`
971-
ExcludeMutations bool `yaml:"exclude_mutations" envDefault:"false" env:"MCP_EXCLUDE_MUTATIONS"`
972-
EnableArbitraryOperations bool `yaml:"enable_arbitrary_operations" envDefault:"false" env:"MCP_ENABLE_ARBITRARY_OPERATIONS"`
973-
ExposeSchema bool `yaml:"expose_schema" envDefault:"false" env:"MCP_EXPOSE_SCHEMA"`
974-
RouterURL string `yaml:"router_url,omitempty" env:"MCP_ROUTER_URL"`
966+
Enabled bool `yaml:"enabled" envDefault:"false" env:"MCP_ENABLED"`
967+
Server MCPServer `yaml:"server,omitempty"`
968+
Storage MCPStorageConfig `yaml:"storage,omitempty"`
969+
Session MCPSessionConfig `yaml:"session,omitempty"`
970+
GraphName string `yaml:"graph_name" envDefault:"mygraph" env:"MCP_GRAPH_NAME"`
971+
ExcludeMutations bool `yaml:"exclude_mutations" envDefault:"false" env:"MCP_EXCLUDE_MUTATIONS"`
972+
EnableArbitraryOperations bool `yaml:"enable_arbitrary_operations" envDefault:"false" env:"MCP_ENABLE_ARBITRARY_OPERATIONS"`
973+
ExposeSchema bool `yaml:"expose_schema" envDefault:"false" env:"MCP_EXPOSE_SCHEMA"`
974+
RouterURL string `yaml:"router_url,omitempty" env:"MCP_ROUTER_URL"`
975+
ForwardHeaders MCPForwardHeadersConfiguration `yaml:"forward_headers"`
976+
}
977+
978+
type MCPForwardHeadersConfiguration struct {
979+
Enabled bool `yaml:"enabled" envDefault:"false" env:"MCP_FORWARD_HEADERS_ENABLED"`
980+
AllowList []string `yaml:"allow_list" env:"MCP_FORWARD_HEADERS_ALLOW_LIST"`
975981
}
976982

977983
type MCPSessionConfig struct {

router/pkg/config/config.schema.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2071,6 +2071,25 @@
20712071
"type": "boolean",
20722072
"default": false,
20732073
"description": "Expose the full GraphQL schema through MCP. When enabled, AI models can request the complete schema of your API."
2074+
},
2075+
"forward_headers": {
2076+
"type": "object",
2077+
"description": "Configuration for forwarding additional HTTP headers from MCP requests to GraphQL requests. The Authorization header is always forwarded regardless of this configuration to maintain backward compatibility. Use this to forward additional headers like tenant IDs, trace IDs, or custom authentication tokens.",
2078+
"additionalProperties": false,
2079+
"properties": {
2080+
"enabled": {
2081+
"type": "boolean",
2082+
"default": false,
2083+
"description": "Enable forwarding of additional headers beyond Authorization. When false (default), only the Authorization header is forwarded. When true, headers matching the allow_list are also forwarded."
2084+
},
2085+
"allow_list": {
2086+
"type": "array",
2087+
"description": "List of additional header names or regex patterns to forward (beyond Authorization which is always forwarded). Supports exact matches (e.g., 'X-Tenant-ID') and regex patterns (e.g., 'X-.*' for all headers starting with 'X-'). Header matching is case-insensitive.",
2088+
"items": {
2089+
"type": "string"
2090+
}
2091+
}
2092+
}
20742093
}
20752094
}
20762095
},

router/pkg/mcpserver/server.go

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"net/http"
11+
"regexp"
1112
"strings"
1213
"time"
1314

@@ -25,16 +26,31 @@ import (
2526
// authKey is a custom context key for storing the auth token.
2627
type authKey struct{}
2728

29+
// headersKey is a custom context key for storing forwarded headers.
30+
type headersKey struct{}
31+
2832
// withAuthKey adds an auth key to the context.
2933
func withAuthKey(ctx context.Context, auth string) context.Context {
3034
return context.WithValue(ctx, authKey{}, auth)
3135
}
3236

37+
// withHeaders adds headers to the context.
38+
func withHeaders(ctx context.Context, headers http.Header) context.Context {
39+
return context.WithValue(ctx, headersKey{}, headers)
40+
}
41+
3342
// authFromRequest extracts the auth token from the request headers.
3443
func authFromRequest(ctx context.Context, r *http.Request) context.Context {
3544
return withAuthKey(ctx, r.Header.Get("Authorization"))
3645
}
3746

47+
// headersFromRequest extracts all headers from the request and stores them in context.
48+
func headersFromRequest(ctx context.Context, r *http.Request) context.Context {
49+
// Clone the headers to avoid any mutation issues
50+
headers := r.Header.Clone()
51+
return withHeaders(ctx, headers)
52+
}
53+
3854
// tokenFromContext extracts the auth token from the context.
3955
// This can be used by clients to pass the auth token to the server.
4056
func tokenFromContext(ctx context.Context) (string, error) {
@@ -45,6 +61,12 @@ func tokenFromContext(ctx context.Context) (string, error) {
4561
return auth, nil
4662
}
4763

64+
// headersFromContext extracts headers from the context.
65+
func headersFromContext(ctx context.Context) (http.Header, bool) {
66+
headers, ok := ctx.Value(headersKey{}).(http.Header)
67+
return headers, ok
68+
}
69+
4870
// Options represents configuration options for the GraphQLSchemaServer
4971
type Options struct {
5072
// GraphName is the name of the graph to be served
@@ -70,6 +92,10 @@ type Options struct {
7092
ExposeSchema bool
7193
// Stateless determines whether the MCP server should be stateless
7294
Stateless bool
95+
// ForwardHeadersEnabled determines whether header forwarding is enabled
96+
ForwardHeadersEnabled bool
97+
// ForwardHeadersAllowList is the list of headers (or regex patterns) to forward
98+
ForwardHeadersAllowList []string
7399
}
74100

75101
// GraphQLSchemaServer represents an MCP server that works with GraphQL schemas and operations
@@ -91,6 +117,8 @@ type GraphQLSchemaServer struct {
91117
operationsManager *OperationsManager
92118
schemaCompiler *SchemaCompiler
93119
registeredTools []string
120+
forwardHeadersEnabled bool
121+
forwardHeadersAllowList []string
94122
}
95123

96124
type graphqlRequest struct {
@@ -218,6 +246,8 @@ func NewGraphQLSchemaServer(routerGraphQLEndpoint string, opts ...func(*Options)
218246
exposeSchema: options.ExposeSchema,
219247
stateless: options.Stateless,
220248
baseURL: options.BaseURL,
249+
forwardHeadersEnabled: options.ForwardHeadersEnabled,
250+
forwardHeadersAllowList: options.ForwardHeadersAllowList,
221251
}
222252

223253
return gs, nil
@@ -285,6 +315,14 @@ func WithStateless(stateless bool) func(*Options) {
285315
}
286316
}
287317

318+
// WithForwardHeaders configures header forwarding to GraphQL requests
319+
func WithForwardHeaders(enabled bool, allowList []string) func(*Options) {
320+
return func(o *Options) {
321+
o.ForwardHeadersEnabled = enabled
322+
o.ForwardHeadersAllowList = allowList
323+
}
324+
}
325+
288326
// Serve starts the server with the configured options and returns a streamable HTTP server.
289327
func (s *GraphQLSchemaServer) Serve() (*server.StreamableHTTPServer, error) {
290328
// Create custom HTTP server
@@ -295,11 +333,20 @@ func (s *GraphQLSchemaServer) Serve() (*server.StreamableHTTPServer, error) {
295333
IdleTimeout: 60 * time.Second,
296334
}
297335

336+
// Create a combined context function that captures both auth and headers
337+
contextFunc := func(ctx context.Context, r *http.Request) context.Context {
338+
ctx = authFromRequest(ctx, r)
339+
if s.forwardHeadersEnabled {
340+
ctx = headersFromRequest(ctx, r)
341+
}
342+
return ctx
343+
}
344+
298345
streamableHTTPServer := server.NewStreamableHTTPServer(s.server,
299346
server.WithStreamableHTTPServer(httpServer),
300347
server.WithLogger(NewZapAdapter(s.logger.With(zap.String("component", "mcp-server")))),
301348
server.WithStateLess(s.stateless),
302-
server.WithHTTPContextFunc(authFromRequest),
349+
server.WithHTTPContextFunc(contextFunc),
303350
server.WithHeartbeatInterval(10*time.Second),
304351
)
305352

@@ -654,6 +701,39 @@ Important Notes:
654701
}
655702
}
656703

704+
// filterHeaders filters headers based on the allowlist configuration.
705+
// It supports both exact matches and regex patterns.
706+
func (s *GraphQLSchemaServer) filterHeaders(headers http.Header) http.Header {
707+
if !s.forwardHeadersEnabled || len(s.forwardHeadersAllowList) == 0 {
708+
return http.Header{}
709+
}
710+
711+
filtered := http.Header{}
712+
713+
for _, pattern := range s.forwardHeadersAllowList {
714+
// Try to compile as regex
715+
re, err := regexp.Compile("(?i)^" + pattern + "$")
716+
if err != nil {
717+
// If it's not a valid regex, treat it as an exact match (case-insensitive)
718+
for headerName, headerValues := range headers {
719+
if strings.EqualFold(headerName, pattern) {
720+
filtered[headerName] = headerValues
721+
}
722+
}
723+
continue
724+
}
725+
726+
// Match using regex
727+
for headerName, headerValues := range headers {
728+
if re.MatchString(headerName) {
729+
filtered[headerName] = headerValues
730+
}
731+
}
732+
}
733+
734+
return filtered
735+
}
736+
657737
// executeGraphQLQuery executes a GraphQL query against the router endpoint
658738
func (s *GraphQLSchemaServer) executeGraphQLQuery(ctx context.Context, query string, variables json.RawMessage) (*mcp.CallToolResult, error) {
659739
// Create the GraphQL request
@@ -675,14 +755,29 @@ func (s *GraphQLSchemaServer) executeGraphQLQuery(ctx context.Context, query str
675755
req.Header.Set("Accept", "application/json")
676756
req.Header.Set("Content-Type", "application/json; charset=utf-8")
677757

758+
// Always forward Authorization header (legacy behavior)
678759
token, err := tokenFromContext(ctx)
679760
if err != nil {
680761
s.logger.Debug("failed to get token from context", zap.Error(err))
681762
} else if token != "" {
682763
req.Header.Set("Authorization", token)
683764
}
684765

685-
// Forward Authorization header if provided
766+
// Forward additional headers if enabled
767+
if s.forwardHeadersEnabled {
768+
if headers, ok := headersFromContext(ctx); ok {
769+
filteredHeaders := s.filterHeaders(headers)
770+
for headerName, headerValues := range filteredHeaders {
771+
// Skip Authorization as it's already handled above
772+
if strings.EqualFold(headerName, "Authorization") {
773+
continue
774+
}
775+
for _, headerValue := range headerValues {
776+
req.Header.Add(headerName, headerValue)
777+
}
778+
}
779+
}
780+
}
686781

687782
resp, err := s.httpClient.Do(req)
688783
if err != nil {

0 commit comments

Comments
 (0)