Skip to content

Commit 9d809a0

Browse files
committed
feat: make CORS settings configurable for MCP server (#7827)
Add CORSConfig to the MCP extension configuration allowing operators to control which origins and headers are permitted for cross-origin requests to the MCP endpoint. Changes: - Add CORSConfig struct with allowed_origins and allowed_headers fields - Support exact origin matches, wildcard (*), and subdomain patterns (e.g., http://*.example.com) - Echo the matched origin in Access-Control-Allow-Origin instead of literal '*' (correct CORS behavior for credentialed requests) - Always include MCP protocol headers (Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID) regardless of config - Default to AllowedOrigins: ["*"] for backward compatibility - Add comprehensive table-driven tests for origin matching Resolves #7827 Signed-off-by: Rajneesh180 <rajneeshrehsaan48@gmail.com>
1 parent 043d05e commit 9d809a0

File tree

4 files changed

+219
-7
lines changed

4 files changed

+219
-7
lines changed

cmd/jaeger/internal/extension/jaegermcp/config.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ import (
99
"go.opentelemetry.io/collector/confmap/xconfmap"
1010
)
1111

12+
// CORSConfig holds CORS configuration for the MCP server.
13+
type CORSConfig struct {
14+
// AllowedOrigins is a list of origins that are allowed to make cross-origin requests.
15+
// Use ["*"] to allow any origin. Supports exact matches and wildcard patterns
16+
// (e.g., "http://*.example.com").
17+
AllowedOrigins []string `mapstructure:"allowed_origins"`
18+
19+
// AllowedHeaders is a list of additional headers the client is allowed to use
20+
// in cross-origin requests. MCP protocol headers (Mcp-Session-Id,
21+
// Mcp-Protocol-Version, Last-Event-ID) are always permitted regardless of
22+
// this setting.
23+
AllowedHeaders []string `mapstructure:"allowed_headers"`
24+
}
25+
1226
// Config represents the configuration for the Jaeger MCP server extension.
1327
type Config struct {
1428
// HTTP contains the HTTP server configuration for the MCP protocol endpoint.
@@ -25,6 +39,10 @@ type Config struct {
2539

2640
// MaxSearchResults limits the number of trace search results.
2741
MaxSearchResults int `mapstructure:"max_search_results" valid:"range(1|1000)"`
42+
43+
// CORS configures cross-origin resource sharing for the MCP HTTP endpoint.
44+
// This is required for browser-based MCP clients like the MCP Inspector.
45+
CORS CORSConfig `mapstructure:"cors"`
2846
}
2947

3048
// Validate checks if the configuration is valid.

cmd/jaeger/internal/extension/jaegermcp/factory.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ func createDefaultConfig() component.Config {
4848
ServerVersion: ver,
4949
MaxSpanDetailsPerRequest: 20,
5050
MaxSearchResults: 100,
51+
CORS: CORSConfig{
52+
AllowedOrigins: []string{"*"},
53+
},
5154
}
5255
}
5356

cmd/jaeger/internal/extension/jaegermcp/server.go

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"fmt"
1010
"net"
1111
"net/http"
12+
"strings"
1213
"time"
1314

1415
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -102,7 +103,7 @@ func (s *server) Start(ctx context.Context, host component.Host) error {
102103
})
103104

104105
s.httpServer = &http.Server{
105-
Handler: corsMiddleware(mux),
106+
Handler: corsMiddleware(mux, s.config.CORS),
106107
ReadHeaderTimeout: 30 * time.Second,
107108
}
108109

@@ -212,13 +213,31 @@ func (s *server) healthTool(
212213
}, nil
213214
}
214215

215-
// corsMiddleware wraps an http.Handler to add CORS headers.
216-
// This is required for browser-based MCP clients like the MCP Inspector.
217-
func corsMiddleware(next http.Handler) http.Handler {
216+
// corsMiddleware wraps an http.Handler to add CORS headers based on the
217+
// provided CORSConfig. MCP protocol headers are always included regardless
218+
// of the configuration. This is required for browser-based MCP clients like
219+
// the MCP Inspector.
220+
func corsMiddleware(next http.Handler, cfg CORSConfig) http.Handler {
221+
// MCP protocol headers that must always be allowed and exposed.
222+
mcpHeaders := []string{"Mcp-Session-Id", "Mcp-Protocol-Version", "Last-Event-ID"}
223+
224+
// Build allowed headers: base set + MCP headers + user-configured headers.
225+
allowedHeaders := []string{"Content-Type", "Accept"}
226+
allowedHeaders = append(allowedHeaders, mcpHeaders...)
227+
allowedHeaders = append(allowedHeaders, cfg.AllowedHeaders...)
228+
229+
allowHeadersStr := strings.Join(allowedHeaders, ", ")
230+
218231
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
219-
w.Header().Set("Access-Control-Allow-Origin", "*")
232+
origin := r.Header.Get("Origin")
233+
if origin != "" && isOriginAllowed(origin, cfg.AllowedOrigins) {
234+
w.Header().Set("Access-Control-Allow-Origin", origin)
235+
} else if containsWildcard(cfg.AllowedOrigins) {
236+
w.Header().Set("Access-Control-Allow-Origin", "*")
237+
}
238+
220239
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
221-
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID")
240+
w.Header().Set("Access-Control-Allow-Headers", allowHeadersStr)
222241
w.Header().Set("Access-Control-Expose-Headers", "Mcp-Session-Id")
223242

224243
// Handle preflight requests
@@ -230,3 +249,38 @@ func corsMiddleware(next http.Handler) http.Handler {
230249
next.ServeHTTP(w, r)
231250
})
232251
}
252+
253+
// containsWildcard checks if the origins list includes "*".
254+
func containsWildcard(origins []string) bool {
255+
for _, o := range origins {
256+
if o == "*" {
257+
return true
258+
}
259+
}
260+
return false
261+
}
262+
263+
// isOriginAllowed checks whether the given origin matches any of the allowed
264+
// origin patterns. Patterns may use a leading wildcard to match subdomains
265+
// (e.g., "http://*.example.com" matches "http://app.example.com").
266+
func isOriginAllowed(origin string, allowedOrigins []string) bool {
267+
for _, pattern := range allowedOrigins {
268+
if pattern == "*" {
269+
return true
270+
}
271+
if pattern == origin {
272+
return true
273+
}
274+
// Support wildcard subdomain matching: "http://*.example.com"
275+
if strings.Contains(pattern, "*") {
276+
// Split pattern at the wildcard and check prefix/suffix
277+
parts := strings.SplitN(pattern, "*", 2)
278+
if len(parts) == 2 &&
279+
strings.HasPrefix(origin, parts[0]) &&
280+
strings.HasSuffix(origin, parts[1]) {
281+
return true
282+
}
283+
}
284+
}
285+
return false
286+
}

cmd/jaeger/internal/extension/jaegermcp/server_test.go

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,9 @@ func TestCORSPreflight(t *testing.T) {
706706
},
707707
ServerName: "jaeger-test",
708708
ServerVersion: "1.0.0",
709+
CORS: CORSConfig{
710+
AllowedOrigins: []string{"*"},
711+
},
709712
}
710713

711714
server := newServer(config, componenttest.NewNopTelemetrySettings())
@@ -727,6 +730,140 @@ func TestCORSPreflight(t *testing.T) {
727730
defer resp.Body.Close()
728731

729732
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
730-
assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin"))
733+
assert.Equal(t, "http://localhost:3000", resp.Header.Get("Access-Control-Allow-Origin"))
731734
assert.Equal(t, "GET, POST, DELETE, OPTIONS", resp.Header.Get("Access-Control-Allow-Methods"))
732735
}
736+
737+
func TestCORSConfigurableOrigins(t *testing.T) {
738+
tests := []struct {
739+
name string
740+
allowedOrigins []string
741+
requestOrigin string
742+
expectedOrigin string
743+
}{
744+
{
745+
name: "wildcard allows any origin",
746+
allowedOrigins: []string{"*"},
747+
requestOrigin: "http://example.com",
748+
expectedOrigin: "http://example.com",
749+
},
750+
{
751+
name: "exact match allowed",
752+
allowedOrigins: []string{"http://localhost:3000"},
753+
requestOrigin: "http://localhost:3000",
754+
expectedOrigin: "http://localhost:3000",
755+
},
756+
{
757+
name: "non-matching origin blocked",
758+
allowedOrigins: []string{"http://localhost:3000"},
759+
requestOrigin: "http://evil.com",
760+
expectedOrigin: "",
761+
},
762+
{
763+
name: "wildcard subdomain match",
764+
allowedOrigins: []string{"http://*.example.com"},
765+
requestOrigin: "http://app.example.com",
766+
expectedOrigin: "http://app.example.com",
767+
},
768+
{
769+
name: "wildcard subdomain no match",
770+
allowedOrigins: []string{"http://*.example.com"},
771+
requestOrigin: "http://evil.com",
772+
expectedOrigin: "",
773+
},
774+
{
775+
name: "multiple origins first match",
776+
allowedOrigins: []string{"http://localhost:3000", "http://localhost:8080"},
777+
requestOrigin: "http://localhost:8080",
778+
expectedOrigin: "http://localhost:8080",
779+
},
780+
{
781+
name: "empty origins blocks all",
782+
allowedOrigins: []string{},
783+
requestOrigin: "http://localhost:3000",
784+
expectedOrigin: "",
785+
},
786+
}
787+
788+
for _, tt := range tests {
789+
t.Run(tt.name, func(t *testing.T) {
790+
config := &Config{
791+
HTTP: confighttp.ServerConfig{
792+
NetAddr: confignet.AddrConfig{
793+
Endpoint: "localhost:0",
794+
Transport: confignet.TransportTypeTCP,
795+
},
796+
},
797+
ServerName: "jaeger-test",
798+
ServerVersion: "1.0.0",
799+
CORS: CORSConfig{
800+
AllowedOrigins: tt.allowedOrigins,
801+
},
802+
}
803+
804+
srv := newServer(config, componenttest.NewNopTelemetrySettings())
805+
host := newMockHost()
806+
err := srv.Start(context.Background(), host)
807+
require.NoError(t, err)
808+
defer srv.Shutdown(context.Background())
809+
810+
addr := srv.listener.Addr().String()
811+
url := fmt.Sprintf("http://%s/mcp", addr)
812+
813+
req, err := http.NewRequest(http.MethodOptions, url, http.NoBody)
814+
require.NoError(t, err)
815+
req.Header.Set("Origin", tt.requestOrigin)
816+
req.Header.Set("Access-Control-Request-Method", "POST")
817+
818+
resp, err := http.DefaultClient.Do(req)
819+
require.NoError(t, err)
820+
defer resp.Body.Close()
821+
822+
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
823+
assert.Equal(t, tt.expectedOrigin, resp.Header.Get("Access-Control-Allow-Origin"))
824+
})
825+
}
826+
}
827+
828+
func TestCORSCustomHeaders(t *testing.T) {
829+
config := &Config{
830+
HTTP: confighttp.ServerConfig{
831+
NetAddr: confignet.AddrConfig{
832+
Endpoint: "localhost:0",
833+
Transport: confignet.TransportTypeTCP,
834+
},
835+
},
836+
ServerName: "jaeger-test",
837+
ServerVersion: "1.0.0",
838+
CORS: CORSConfig{
839+
AllowedOrigins: []string{"*"},
840+
AllowedHeaders: []string{"X-Custom-Header"},
841+
},
842+
}
843+
844+
srv := newServer(config, componenttest.NewNopTelemetrySettings())
845+
host := newMockHost()
846+
err := srv.Start(context.Background(), host)
847+
require.NoError(t, err)
848+
defer srv.Shutdown(context.Background())
849+
850+
addr := srv.listener.Addr().String()
851+
url := fmt.Sprintf("http://%s/mcp", addr)
852+
853+
req, err := http.NewRequest(http.MethodOptions, url, http.NoBody)
854+
require.NoError(t, err)
855+
req.Header.Set("Origin", "http://localhost:3000")
856+
req.Header.Set("Access-Control-Request-Method", "POST")
857+
858+
resp, err := http.DefaultClient.Do(req)
859+
require.NoError(t, err)
860+
defer resp.Body.Close()
861+
862+
allowHeaders := resp.Header.Get("Access-Control-Allow-Headers")
863+
// MCP protocol headers must always be present
864+
assert.Contains(t, allowHeaders, "Mcp-Session-Id")
865+
assert.Contains(t, allowHeaders, "Mcp-Protocol-Version")
866+
assert.Contains(t, allowHeaders, "Last-Event-ID")
867+
// Custom header should be included
868+
assert.Contains(t, allowHeaders, "X-Custom-Header")
869+
}

0 commit comments

Comments
 (0)