Skip to content
Open
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
20 changes: 20 additions & 0 deletions pkg/http/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@ 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"
"github.com/github/github-mcp-server/pkg/scopes"
"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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
75 changes: 75 additions & 0 deletions pkg/http/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package http

import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -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.
//
Expand Down
2 changes: 2 additions & 0 deletions pkg/http/headers/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading