Skip to content

Commit 3085e59

Browse files
Reject unsupported subscription streams (#3073)
* fix(http): reject unsupported subscription streams Use the Mcp-Method header to reject subscriptions/listen with the spec-defined 404 Method Not Found response instead of opening an idle SSE stream. Preserve SDK validation for missing or mismatched headers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06d5dda1-4086-4996-8d18-152e45e611b0 * refactor(http): clarify subscription rejection Document why header validation precedes the unsupported-method rejection and use named SDK error constants in tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06d5dda1-4086-4996-8d18-152e45e611b0 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06d5dda1-4086-4996-8d18-152e45e611b0
1 parent 0ea1f77 commit 3085e59

3 files changed

Lines changed: 100 additions & 0 deletions

File tree

pkg/http/handler.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,20 @@ import (
88

99
ghcontext "github.com/github/github-mcp-server/pkg/context"
1010
"github.com/github/github-mcp-server/pkg/github"
11+
"github.com/github/github-mcp-server/pkg/http/headers"
1112
"github.com/github/github-mcp-server/pkg/http/middleware"
1213
"github.com/github/github-mcp-server/pkg/http/oauth"
1314
"github.com/github/github-mcp-server/pkg/inventory"
1415
"github.com/github/github-mcp-server/pkg/scopes"
1516
"github.com/github/github-mcp-server/pkg/translations"
1617
"github.com/github/github-mcp-server/pkg/utils"
1718
"github.com/go-chi/chi/v5"
19+
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
1820
"github.com/modelcontextprotocol/go-sdk/mcp"
1921
)
2022

23+
const subscriptionsListenMethod = "subscriptions/listen"
24+
2125
type InventoryFactoryFunc func(r *http.Request) (*inventory.Inventory, error)
2226

2327
// GitHubMCPServerFactoryFunc is a function type for creating a new MCP Server instance.
@@ -219,6 +223,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
219223
return
220224
}
221225

226+
// Let the SDK validate missing or mismatched method headers before the
227+
// middleware rejects a well-formed request as unsupported.
228+
if r.Header.Get(headers.MCPMethodHeader) == subscriptionsListenMethod {
229+
ghServer.AddReceivingMiddleware(rejectSubscriptionsListen)
230+
}
231+
222232
// Cross-origin protection is intentionally left unset: this server
223233
// authenticates via bearer tokens (not cookies), so Sec-Fetch-Site CSRF
224234
// checks are unnecessary and would block browser-based MCP clients. As of
@@ -233,6 +243,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
233243
mcpHandler.ServeHTTP(w, r)
234244
}
235245

246+
func rejectSubscriptionsListen(next mcp.MethodHandler) mcp.MethodHandler {
247+
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
248+
if method == subscriptionsListenMethod {
249+
return nil, &jsonrpc.Error{
250+
Code: jsonrpc.CodeMethodNotFound,
251+
Message: "method not found",
252+
}
253+
}
254+
return next(ctx, method, req)
255+
}
256+
}
257+
236258
func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) {
237259
return github.NewMCPServer(r.Context(), cfg, deps, inventory)
238260
}

pkg/http/handler_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package http
22

33
import (
44
"context"
5+
"encoding/json"
56
"log/slog"
67
"net/http"
78
"net/http/httptest"
@@ -18,6 +19,7 @@ import (
1819
"github.com/github/github-mcp-server/pkg/translations"
1920
"github.com/github/github-mcp-server/pkg/utils"
2021
"github.com/go-chi/chi/v5"
22+
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
2123
"github.com/modelcontextprotocol/go-sdk/mcp"
2224
"github.com/stretchr/testify/assert"
2325
"github.com/stretchr/testify/require"
@@ -906,6 +908,80 @@ func TestCrossOriginProtection(t *testing.T) {
906908
}
907909
}
908910

911+
func TestSubscriptionsListenIsRejected(t *testing.T) {
912+
apiHost, err := utils.NewAPIHost("https://api.githubcopilot.com")
913+
require.NoError(t, err)
914+
915+
handler := NewHTTPMcpHandler(
916+
context.Background(),
917+
&ServerConfig{Version: "test"},
918+
nil,
919+
translations.NullTranslationHelper,
920+
slog.Default(),
921+
apiHost,
922+
WithInventoryFactory(func(_ *http.Request) (*inventory.Inventory, error) {
923+
return inventory.NewBuilder().Build()
924+
}),
925+
WithGitHubMCPServerFactory(func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) {
926+
return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil
927+
}),
928+
)
929+
930+
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}}}`
931+
tests := []struct {
932+
name string
933+
methodHeader string
934+
expectedStatus int
935+
expectedJSONCode int
936+
}{
937+
{
938+
name: "matching method header",
939+
methodHeader: subscriptionsListenMethod,
940+
expectedStatus: http.StatusNotFound,
941+
expectedJSONCode: jsonrpc.CodeMethodNotFound,
942+
},
943+
{
944+
name: "missing method header",
945+
expectedStatus: http.StatusBadRequest,
946+
expectedJSONCode: mcp.CodeHeaderMismatch,
947+
},
948+
{
949+
name: "mismatched method header",
950+
methodHeader: "tools/list",
951+
expectedStatus: http.StatusBadRequest,
952+
expectedJSONCode: mcp.CodeHeaderMismatch,
953+
},
954+
}
955+
956+
for _, tt := range tests {
957+
t.Run(tt.name, func(t *testing.T) {
958+
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
959+
req.Header.Set(headers.ContentTypeHeader, headers.ContentTypeJSON)
960+
req.Header.Set(headers.AcceptHeader, strings.Join([]string{headers.ContentTypeJSON, headers.ContentTypeEventStream}, ", "))
961+
req.Header.Set("MCP-Protocol-Version", "2026-07-28")
962+
if tt.methodHeader != "" {
963+
req.Header.Set(headers.MCPMethodHeader, tt.methodHeader)
964+
}
965+
966+
rr := httptest.NewRecorder()
967+
handler.ServeHTTP(rr, req)
968+
969+
assert.Equal(t, tt.expectedStatus, rr.Code)
970+
assert.Equal(t, headers.ContentTypeJSON, rr.Header().Get(headers.ContentTypeHeader))
971+
972+
var response struct {
973+
ID int `json:"id"`
974+
Error struct {
975+
Code int `json:"code"`
976+
} `json:"error"`
977+
}
978+
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &response))
979+
assert.Equal(t, 1, response.ID)
980+
assert.Equal(t, tt.expectedJSONCode, response.Error.Code)
981+
})
982+
}
983+
}
984+
909985
// TestInsidersRoutePreservesUIMeta is a regression test for the bug where
910986
// _meta.ui was stripped from tools/list responses on the HTTP /insiders route.
911987
//

pkg/http/headers/headers.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ const (
3131

3232
// MCP-specific headers.
3333

34+
// MCPMethodHeader mirrors the JSON-RPC method for request routing.
35+
MCPMethodHeader = "Mcp-Method"
3436
// MCPReadOnlyHeader indicates whether the MCP is in read-only mode.
3537
MCPReadOnlyHeader = "X-MCP-Readonly"
3638
// MCPToolsetsHeader is a comma-separated list of MCP toolsets that the request is for.

0 commit comments

Comments
 (0)