Skip to content

Commit d0320b8

Browse files
RossTarrantSamMorrowDrums
authored andcommitted
Allow browser-based MCP clients via CORS and cross-origin bypass
1 parent 7fd6a92 commit d0320b8

File tree

5 files changed

+167
-1
lines changed

5 files changed

+167
-1
lines changed

pkg/http/handler.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
223223
return
224224
}
225225

226+
// Bypass cross-origin protection: this server uses bearer tokens (not
227+
// cookies), so Sec-Fetch-Site CSRF checks are unnecessary. See PR #2359.
228+
crossOriginProtection := http.NewCrossOriginProtection()
229+
crossOriginProtection.AddInsecureBypassPattern("/")
230+
226231
mcpHandler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
227232
return ghServer
228233
}, &mcp.StreamableHTTPOptions{
229-
Stateless: true,
234+
Stateless: true,
235+
CrossOriginProtection: crossOriginProtection,
230236
})
231237

232238
mcpHandler.ServeHTTP(w, r)

pkg/http/handler_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,3 +756,72 @@ func buildStaticInventoryFromTools(cfg *ServerConfig, tools []inventory.ServerTo
756756
ctx := context.Background()
757757
return inv.AvailableTools(ctx), inv.AvailableResourceTemplates(ctx), inv.AvailablePrompts(ctx)
758758
}
759+
760+
func TestCrossOriginProtection(t *testing.T) {
761+
jsonRPCBody := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}`
762+
763+
apiHost, err := utils.NewAPIHost("https://api.githubcopilot.com")
764+
require.NoError(t, err)
765+
766+
handler := NewHTTPMcpHandler(
767+
context.Background(),
768+
&ServerConfig{
769+
Version: "test",
770+
},
771+
nil,
772+
translations.NullTranslationHelper,
773+
slog.Default(),
774+
apiHost,
775+
WithInventoryFactory(func(_ *http.Request) (*inventory.Inventory, error) {
776+
return inventory.NewBuilder().Build()
777+
}),
778+
WithGitHubMCPServerFactory(func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) {
779+
return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil
780+
}),
781+
WithScopeFetcher(allScopesFetcher{}),
782+
)
783+
784+
r := chi.NewRouter()
785+
handler.RegisterMiddleware(r)
786+
handler.RegisterRoutes(r)
787+
788+
tests := []struct {
789+
name string
790+
secFetchSite string
791+
origin string
792+
}{
793+
{
794+
name: "cross-site request with bearer token succeeds",
795+
secFetchSite: "cross-site",
796+
origin: "https://example.com",
797+
},
798+
{
799+
name: "same-origin request succeeds",
800+
secFetchSite: "same-origin",
801+
},
802+
{
803+
name: "native client without Sec-Fetch-Site succeeds",
804+
secFetchSite: "",
805+
},
806+
}
807+
808+
for _, tt := range tests {
809+
t.Run(tt.name, func(t *testing.T) {
810+
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonRPCBody))
811+
req.Header.Set("Content-Type", "application/json")
812+
req.Header.Set("Accept", "application/json, text/event-stream")
813+
req.Header.Set(headers.AuthorizationHeader, "Bearer github_pat_xyz")
814+
if tt.secFetchSite != "" {
815+
req.Header.Set("Sec-Fetch-Site", tt.secFetchSite)
816+
}
817+
if tt.origin != "" {
818+
req.Header.Set("Origin", tt.origin)
819+
}
820+
821+
rr := httptest.NewRecorder()
822+
r.ServeHTTP(rr, req)
823+
824+
assert.Equal(t, http.StatusOK, rr.Code, "unexpected status code; body: %s", rr.Body.String())
825+
})
826+
}
827+
}

pkg/http/middleware/cors.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package middleware
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
7+
"github.com/github/github-mcp-server/pkg/http/headers"
8+
)
9+
10+
// SetCorsHeaders is middleware that sets CORS headers to allow browser-based
11+
// MCP clients to connect from any origin. This is safe because the server
12+
// authenticates via bearer tokens (not cookies), so cross-origin requests
13+
// cannot exploit ambient credentials.
14+
func SetCorsHeaders(h http.Handler) http.Handler {
15+
allowHeaders := strings.Join([]string{
16+
"Content-Type",
17+
"Mcp-Session-Id",
18+
"Mcp-Protocol-Version",
19+
"Last-Event-ID",
20+
headers.AuthorizationHeader,
21+
headers.MCPReadOnlyHeader,
22+
headers.MCPToolsetsHeader,
23+
headers.MCPToolsHeader,
24+
headers.MCPExcludeToolsHeader,
25+
headers.MCPFeaturesHeader,
26+
headers.MCPLockdownHeader,
27+
headers.MCPInsidersHeader,
28+
}, ", ")
29+
30+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31+
w.Header().Set("Access-Control-Allow-Origin", "*")
32+
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
33+
w.Header().Set("Access-Control-Max-Age", "86400")
34+
w.Header().Set("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate")
35+
w.Header().Set("Access-Control-Allow-Headers", allowHeaders)
36+
37+
if r.Method == http.MethodOptions {
38+
w.WriteHeader(http.StatusOK)
39+
return
40+
}
41+
h.ServeHTTP(w, r)
42+
})
43+
}

pkg/http/middleware/cors_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package middleware_test
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/github/github-mcp-server/pkg/http/middleware"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestSetCorsHeaders(t *testing.T) {
13+
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
14+
w.WriteHeader(http.StatusOK)
15+
})
16+
handler := middleware.SetCorsHeaders(inner)
17+
18+
t.Run("OPTIONS preflight returns 200 with CORS headers", func(t *testing.T) {
19+
req := httptest.NewRequest(http.MethodOptions, "/", nil)
20+
req.Header.Set("Origin", "http://localhost:6274")
21+
rr := httptest.NewRecorder()
22+
handler.ServeHTTP(rr, req)
23+
24+
assert.Equal(t, http.StatusOK, rr.Code)
25+
assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin"))
26+
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Methods"), "POST")
27+
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Authorization")
28+
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Content-Type")
29+
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Mcp-Session-Id")
30+
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "X-MCP-Lockdown")
31+
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "X-MCP-Insiders")
32+
assert.Contains(t, rr.Header().Get("Access-Control-Expose-Headers"), "Mcp-Session-Id")
33+
assert.Contains(t, rr.Header().Get("Access-Control-Expose-Headers"), "WWW-Authenticate")
34+
})
35+
36+
t.Run("POST request includes CORS headers", func(t *testing.T) {
37+
req := httptest.NewRequest(http.MethodPost, "/", nil)
38+
req.Header.Set("Origin", "http://localhost:6274")
39+
rr := httptest.NewRecorder()
40+
handler.ServeHTTP(rr, req)
41+
42+
assert.Equal(t, http.StatusOK, rr.Code)
43+
assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin"))
44+
})
45+
}

pkg/http/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
ghcontext "github.com/github/github-mcp-server/pkg/context"
1515
"github.com/github/github-mcp-server/pkg/github"
16+
"github.com/github/github-mcp-server/pkg/http/middleware"
1617
"github.com/github/github-mcp-server/pkg/http/oauth"
1718
"github.com/github/github-mcp-server/pkg/inventory"
1819
"github.com/github/github-mcp-server/pkg/lockdown"
@@ -167,6 +168,8 @@ func RunHTTPServer(cfg ServerConfig) error {
167168
}
168169

169170
r.Group(func(r chi.Router) {
171+
r.Use(middleware.SetCorsHeaders)
172+
170173
// Register Middleware First, needs to be before route registration
171174
handler.RegisterMiddleware(r)
172175

0 commit comments

Comments
 (0)