diff --git a/pkg/http/handler.go b/pkg/http/handler.go index ab229e55dc..ed62fb97b2 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -8,6 +8,7 @@ import ( ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/http/headers" "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/inventory" @@ -15,9 +16,12 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-chi/chi/v5" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" ) +const subscriptionsListenMethod = "subscriptions/listen" + type InventoryFactoryFunc func(r *http.Request) (*inventory.Inventory, error) // GitHubMCPServerFactoryFunc is a function type for creating a new MCP Server instance. @@ -219,6 +223,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if r.Header.Get(headers.MCPMethodHeader) == subscriptionsListenMethod { + ghServer.AddReceivingMiddleware(rejectSubscriptionsListen) + } + // Cross-origin protection is intentionally left unset: this server // authenticates via bearer tokens (not cookies), so Sec-Fetch-Site CSRF // checks are unnecessary and would block browser-based MCP clients. As of @@ -233,6 +241,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { mcpHandler.ServeHTTP(w, r) } +func rejectSubscriptionsListen(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method == subscriptionsListenMethod { + return nil, &jsonrpc.Error{ + Code: jsonrpc.CodeMethodNotFound, + Message: "method not found", + } + } + return next(ctx, method, req) + } +} + func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) { return github.NewMCPServer(r.Context(), cfg, deps, inventory) } diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index b4d509c3e5..4e444fee56 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -2,6 +2,7 @@ package http import ( "context" + "encoding/json" "log/slog" "net/http" "net/http/httptest" @@ -906,6 +907,80 @@ func TestCrossOriginProtection(t *testing.T) { } } +func TestSubscriptionsListenIsRejected(t *testing.T) { + apiHost, err := utils.NewAPIHost("https://api.githubcopilot.com") + require.NoError(t, err) + + handler := NewHTTPMcpHandler( + context.Background(), + &ServerConfig{Version: "test"}, + nil, + translations.NullTranslationHelper, + slog.Default(), + apiHost, + WithInventoryFactory(func(_ *http.Request) (*inventory.Inventory, error) { + return inventory.NewBuilder().Build() + }), + WithGitHubMCPServerFactory(func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) { + return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil + }), + ) + + body := `{"jsonrpc":"2.0","id":1,"method":"subscriptions/listen","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}},"notifications":{"toolsListChanged":true}}}` + tests := []struct { + name string + methodHeader string + expectedStatus int + expectedJSONCode int + }{ + { + name: "matching method header", + methodHeader: subscriptionsListenMethod, + expectedStatus: http.StatusNotFound, + expectedJSONCode: -32601, + }, + { + name: "missing method header", + expectedStatus: http.StatusBadRequest, + expectedJSONCode: -32020, + }, + { + name: "mismatched method header", + methodHeader: "tools/list", + expectedStatus: http.StatusBadRequest, + expectedJSONCode: -32020, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.Header.Set(headers.ContentTypeHeader, headers.ContentTypeJSON) + req.Header.Set(headers.AcceptHeader, strings.Join([]string{headers.ContentTypeJSON, headers.ContentTypeEventStream}, ", ")) + req.Header.Set("MCP-Protocol-Version", "2026-07-28") + if tt.methodHeader != "" { + req.Header.Set(headers.MCPMethodHeader, tt.methodHeader) + } + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + assert.Equal(t, headers.ContentTypeJSON, rr.Header().Get(headers.ContentTypeHeader)) + + var response struct { + ID int `json:"id"` + Error struct { + Code int `json:"code"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &response)) + assert.Equal(t, 1, response.ID) + assert.Equal(t, tt.expectedJSONCode, response.Error.Code) + }) + } +} + // TestInsidersRoutePreservesUIMeta is a regression test for the bug where // _meta.ui was stripped from tools/list responses on the HTTP /insiders route. // diff --git a/pkg/http/headers/headers.go b/pkg/http/headers/headers.go index e032a0ce93..14a9e25db9 100644 --- a/pkg/http/headers/headers.go +++ b/pkg/http/headers/headers.go @@ -31,6 +31,8 @@ const ( // MCP-specific headers. + // MCPMethodHeader mirrors the JSON-RPC method for request routing. + MCPMethodHeader = "Mcp-Method" // MCPReadOnlyHeader indicates whether the MCP is in read-only mode. MCPReadOnlyHeader = "X-MCP-Readonly" // MCPToolsetsHeader is a comma-separated list of MCP toolsets that the request is for.