Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
10 changes: 8 additions & 2 deletions pkg/http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,15 @@ func RunHTTPServer(cfg ServerConfig) error {
return fmt.Errorf("failed to create OAuth handler: %w", err)
}

r.Group(func(r chi.Router) {
r.Use(middleware.SetCorsHeaders)
// CORS headers are applied at the top level so that every response —
// including the OAuth protected resource metadata endpoints and the
// router's 404 fallback — is readable by browser-based clients. Without
// this, browsers mask the real error (e.g. a 401 auth challenge or 404)
// behind an opaque "missing CORS headers" failure, which made hosted
// servers appear unreachable from web clients (see issue #3095).
r.Use(middleware.SetCorsHeaders)

r.Group(func(r chi.Router) {
// Register Middleware First, needs to be before route registration
handler.RegisterMiddleware(r)

Expand Down
127 changes: 127 additions & 0 deletions pkg/http/server_cors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package http

import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"testing"

"github.com/github/github-mcp-server/pkg/http/middleware"
"github.com/github/github-mcp-server/pkg/http/oauth"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// newCORSTestServer builds the same top-level router layout as RunHTTPServer
// (MCP routes + OAuth metadata routes under a shared CORS middleware) without
// binding a real port, so tests can assert on CORS behavior for every response
// class: MCP auth challenges (401), OAuth metadata (200), and unmatched paths
// (404).
func newCORSTestServer(t *testing.T) http.Handler {
t.Helper()

dotcomHost, err := utils.NewAPIHost("https://api.github.com")
require.NoError(t, err)

handler := NewHTTPMcpHandler(
context.Background(),
&ServerConfig{Version: "test"},
nil, // deps not exercised by these routes
translations.NullTranslationHelper,
slog.Default(),
dotcomHost,
)

oauthHandler, err := oauth.NewAuthHandler(&oauth.Config{
BaseURL: "https://api.example.com",
}, dotcomHost)
require.NoError(t, err)

r := chi.NewRouter()
r.Use(middleware.SetCorsHeaders)

r.Group(func(r chi.Router) {
handler.RegisterMiddleware(r)
handler.RegisterRoutes(r)
})
r.Group(func(r chi.Router) {
oauthHandler.RegisterRoutes(r)
})
return r
}

func TestTopLevelCORSHeadersOnAllResponses(t *testing.T) {
tests := []struct {
name string
method string
path string
wantStatus int
wantACAO string // Access-Control-Allow-Origin
wantExposeWW bool // WWW-Authenticate must be exposed via Access-Control-Expose-Headers
}{
{
name: "MCP endpoint without token returns 401 challenge with CORS headers",
method: http.MethodPost,
path: "/mcp",
wantStatus: http.StatusUnauthorized,
wantACAO: "*",
wantExposeWW: true,
},
{
name: "OAuth protected resource metadata returns 200 with CORS headers",
method: http.MethodGet,
path: "/.well-known/oauth-protected-resource",
wantStatus: http.StatusOK,
wantACAO: "*",
wantExposeWW: false,
},
{
name: "unmatched path falls through to root-mounted MCP handler's 401 with CORS headers",
method: http.MethodGet,
path: "/definitely-not-a-route",
wantStatus: http.StatusUnauthorized,
wantACAO: "*",
wantExposeWW: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := newCORSTestServer(t)

req := httptest.NewRequest(tt.method, tt.path, nil)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)

resp := rr.Result()
assert.Equal(t, tt.wantStatus, resp.StatusCode)
assert.Equal(t, tt.wantACAO, resp.Header.Get("Access-Control-Allow-Origin"))
if tt.wantExposeWW {
assert.NotEmpty(t, resp.Header.Get("WWW-Authenticate"),
"401 challenge should carry WWW-Authenticate per MCP spec")
assert.Contains(t, resp.Header.Get("Access-Control-Expose-Headers"), "WWW-Authenticate",
"WWW-Authenticate must be readable cross-origin")
}
})
}
}

func TestTopLevelCORSPreflightOnMetadataRoute(t *testing.T) {
srv := newCORSTestServer(t)

req := httptest.NewRequest(http.MethodOptions, "/.well-known/oauth-protected-resource", nil)
req.Header.Set("Origin", "https://client.example.com")
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)

resp := rr.Result()
assert.Equal(t, http.StatusOK, resp.StatusCode,
"preflight against the metadata route should short-circuit with 200, not fall through to 405/404")
assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin"))
assert.Contains(t, resp.Header.Get("Access-Control-Allow-Methods"), "GET")
}