chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,947 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
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/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/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func mockTool(name, toolsetID string, readOnly bool) inventory.ServerTool {
|
||||
return mockToolFull(name, toolsetID, readOnly, false)
|
||||
}
|
||||
|
||||
func mockToolFull(name, toolsetID string, readOnly bool, isDefault bool) inventory.ServerTool {
|
||||
return inventory.ServerTool{
|
||||
Tool: mcp.Tool{
|
||||
Name: name,
|
||||
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: readOnly},
|
||||
},
|
||||
Toolset: inventory.ToolsetMetadata{
|
||||
ID: inventory.ToolsetID(toolsetID),
|
||||
Description: "Test: " + toolsetID,
|
||||
Default: isDefault,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type allScopesFetcher struct{}
|
||||
|
||||
func (f allScopesFetcher) FetchTokenScopes(_ context.Context, _ string) ([]string, error) {
|
||||
return []string{
|
||||
string(scopes.Repo),
|
||||
string(scopes.WriteOrg),
|
||||
string(scopes.User),
|
||||
string(scopes.Gist),
|
||||
string(scopes.Notifications),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ scopes.FetcherInterface = allScopesFetcher{}
|
||||
|
||||
func mockToolWithFeatureFlag(name, toolsetID string, readOnly bool, enableFlag, disableFlag string) inventory.ServerTool {
|
||||
tool := mockTool(name, toolsetID, readOnly)
|
||||
tool.FeatureFlagEnable = enableFlag
|
||||
if disableFlag != "" {
|
||||
tool.FeatureFlagDisable = []string{disableFlag}
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
func TestInventoryFiltersForRequest(t *testing.T) {
|
||||
tools := []inventory.ServerTool{
|
||||
mockTool("get_file_contents", "repos", true),
|
||||
mockTool("create_repository", "repos", false),
|
||||
mockTool("list_issues", "issues", true),
|
||||
mockTool("issue_write", "issues", false),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
contextSetup func(context.Context) context.Context
|
||||
expectedTools []string
|
||||
}{
|
||||
{
|
||||
name: "no filters applies defaults",
|
||||
contextSetup: func(ctx context.Context) context.Context { return ctx },
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "issue_write"},
|
||||
},
|
||||
{
|
||||
name: "readonly from context filters write tools",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
return ghcontext.WithReadonly(ctx, true)
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "list_issues"},
|
||||
},
|
||||
{
|
||||
name: "toolset from context filters to toolset",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
return ghcontext.WithToolsets(ctx, []string{"repos"})
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository"},
|
||||
},
|
||||
{
|
||||
name: "tools alone clears default toolsets",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
return ghcontext.WithTools(ctx, []string{"list_issues"})
|
||||
},
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "tools are additive with toolsets",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
ctx = ghcontext.WithToolsets(ctx, []string{"repos"})
|
||||
ctx = ghcontext.WithTools(ctx, []string{"list_issues"})
|
||||
return ctx
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues"},
|
||||
},
|
||||
{
|
||||
name: "excluded tools removes specific tools",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
return ghcontext.WithExcludeTools(ctx, []string{"create_repository", "issue_write"})
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "list_issues"},
|
||||
},
|
||||
{
|
||||
name: "excluded tools overrides explicit tools",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
ctx = ghcontext.WithTools(ctx, []string{"list_issues", "create_repository"})
|
||||
ctx = ghcontext.WithExcludeTools(ctx, []string{"create_repository"})
|
||||
return ctx
|
||||
},
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "excluded tools combines with readonly",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
ctx = ghcontext.WithReadonly(ctx, true)
|
||||
ctx = ghcontext.WithExcludeTools(ctx, []string{"list_issues"})
|
||||
return ctx
|
||||
},
|
||||
expectedTools: []string{"get_file_contents"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req = req.WithContext(tt.contextSetup(req.Context()))
|
||||
|
||||
builder := inventory.NewBuilder().
|
||||
SetTools(tools).
|
||||
WithToolsets([]string{"all"})
|
||||
|
||||
builder = InventoryFiltersForRequest(req, builder)
|
||||
inv, err := builder.Build()
|
||||
require.NoError(t, err)
|
||||
|
||||
available := inv.AvailableTools(context.Background())
|
||||
toolNames := make([]string, len(available))
|
||||
for i, tool := range available {
|
||||
toolNames[i] = tool.Tool.Name
|
||||
}
|
||||
|
||||
assert.ElementsMatch(t, tt.expectedTools, toolNames)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testTools returns a set of mock tools across different toolsets with mixed read-only/write capabilities
|
||||
func testTools() []inventory.ServerTool {
|
||||
return []inventory.ServerTool{
|
||||
mockTool("get_file_contents", "repos", true),
|
||||
mockTool("create_repository", "repos", false),
|
||||
mockTool("list_issues", "issues", true),
|
||||
mockTool("create_issue", "issues", false),
|
||||
mockTool("list_pull_requests", "pull_requests", true),
|
||||
mockTool("create_pull_request", "pull_requests", false),
|
||||
// Feature-flagged tools for testing X-MCP-Features header
|
||||
mockToolWithFeatureFlag("needs_holdback", "repos", true, "mcp_holdback_consolidated_projects", ""),
|
||||
mockToolWithFeatureFlag("hidden_by_holdback", "repos", true, "", "mcp_holdback_consolidated_projects"),
|
||||
}
|
||||
}
|
||||
|
||||
// extractToolNames extracts tool names from an inventory
|
||||
func extractToolNames(ctx context.Context, inv *inventory.Inventory) []string {
|
||||
available := inv.AvailableTools(ctx)
|
||||
names := make([]string, len(available))
|
||||
for i, tool := range available {
|
||||
names[i] = tool.Tool.Name
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func TestHTTPHandlerRoutes(t *testing.T) {
|
||||
tools := testTools()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
headers map[string]string
|
||||
expectedTools []string
|
||||
}{
|
||||
{
|
||||
name: "root path returns all tools",
|
||||
path: "/",
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "readonly path filters write tools",
|
||||
path: "/readonly",
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "toolset path filters to toolset",
|
||||
path: "/x/repos",
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "toolset path with issues",
|
||||
path: "/x/issues",
|
||||
expectedTools: []string{"list_issues", "create_issue"},
|
||||
},
|
||||
{
|
||||
name: "toolset readonly path filters to readonly tools in toolset",
|
||||
path: "/x/repos/readonly",
|
||||
expectedTools: []string{"get_file_contents", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "toolset readonly path with issues",
|
||||
path: "/x/issues/readonly",
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Tools header filters to specific tools",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsHeader: "list_issues",
|
||||
},
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Tools header with multiple tools",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsHeader: "list_issues,get_file_contents",
|
||||
},
|
||||
expectedTools: []string{"list_issues", "get_file_contents"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Tools header does not expose extra tools",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsHeader: "list_issues",
|
||||
},
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Readonly header filters write tools",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPReadOnlyHeader: "true",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Toolsets header filters to toolset",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsetsHeader: "repos",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "URL toolset takes precedence over header toolset",
|
||||
path: "/x/issues",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsetsHeader: "repos",
|
||||
},
|
||||
expectedTools: []string{"list_issues", "create_issue"},
|
||||
},
|
||||
{
|
||||
name: "URL readonly takes precedence over header",
|
||||
path: "/readonly",
|
||||
headers: map[string]string{
|
||||
headers.MCPReadOnlyHeader: "false",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Features header enables flagged tool",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPFeaturesHeader: "mcp_holdback_consolidated_projects",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "needs_holdback"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Features header with unknown flag is ignored",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPFeaturesHeader: "unknown_flag",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Exclude-Tools header removes specific tools",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPExcludeToolsHeader: "create_issue,create_pull_request",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Exclude-Tools with toolset header",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsetsHeader: "issues",
|
||||
headers.MCPExcludeToolsHeader: "create_issue",
|
||||
},
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Exclude-Tools overrides X-MCP-Tools",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsHeader: "list_issues,create_issue",
|
||||
headers.MCPExcludeToolsHeader: "create_issue",
|
||||
},
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Exclude-Tools with readonly path",
|
||||
path: "/readonly",
|
||||
headers: map[string]string{
|
||||
headers.MCPExcludeToolsHeader: "list_issues",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedInventory *inventory.Inventory
|
||||
var capturedCtx context.Context
|
||||
|
||||
// Create feature checker that reads from context without whitelist validation
|
||||
// (the whitelist is tested separately; here we test the filtering logic)
|
||||
featureChecker := func(ctx context.Context, flag string) (bool, error) {
|
||||
return slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag), nil
|
||||
}
|
||||
|
||||
apiHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create inventory factory that captures the built inventory
|
||||
inventoryFactory := func(r *http.Request) (*inventory.Inventory, error) {
|
||||
capturedCtx = r.Context()
|
||||
builder := inventory.NewBuilder().
|
||||
SetTools(tools).
|
||||
WithToolsets([]string{"all"}).
|
||||
WithFeatureChecker(featureChecker)
|
||||
builder = InventoryFiltersForRequest(r, builder)
|
||||
inv, err := builder.Build()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
capturedInventory = inv
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// Create mock MCP server factory that just returns a minimal server
|
||||
mcpServerFactory := 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
|
||||
}
|
||||
|
||||
allScopesFetcher := allScopesFetcher{}
|
||||
|
||||
// Create handler with our factories
|
||||
handler := NewHTTPMcpHandler(
|
||||
context.Background(),
|
||||
&ServerConfig{Version: "test"},
|
||||
nil, // deps not needed for this test
|
||||
translations.NullTranslationHelper,
|
||||
slog.Default(),
|
||||
apiHost,
|
||||
WithInventoryFactory(inventoryFactory),
|
||||
WithGitHubMCPServerFactory(mcpServerFactory),
|
||||
WithScopeFetcher(allScopesFetcher),
|
||||
)
|
||||
|
||||
// Create router and register routes
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterMiddleware(r)
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Create request
|
||||
req := httptest.NewRequest(http.MethodPost, tt.path, nil)
|
||||
|
||||
// Ensure we're setting Authorization header for token context
|
||||
req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_testtoken")
|
||||
|
||||
for k, v := range tt.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
// Execute request
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
// Verify the inventory was captured and has the expected tools
|
||||
require.NotNil(t, capturedInventory, "inventory should have been created")
|
||||
|
||||
toolNames := extractToolNames(capturedCtx, capturedInventory)
|
||||
expectedSorted := make([]string, len(tt.expectedTools))
|
||||
copy(expectedSorted, tt.expectedTools)
|
||||
sort.Strings(expectedSorted)
|
||||
|
||||
assert.Equal(t, expectedSorted, toolNames, "tools should match expected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticConfigEnforcement(t *testing.T) {
|
||||
// Use default toolsets to match real-world behavior where repos/issues/pull_requests are defaults
|
||||
tools := []inventory.ServerTool{
|
||||
mockToolFull("get_file_contents", "repos", true, true),
|
||||
mockToolFull("create_repository", "repos", false, true),
|
||||
mockToolFull("list_issues", "issues", true, true),
|
||||
mockToolFull("create_issue", "issues", false, true),
|
||||
mockToolFull("list_pull_requests", "pull_requests", true, true),
|
||||
mockToolFull("create_pull_request", "pull_requests", false, true),
|
||||
mockToolWithFeatureFlag("hidden_by_holdback", "repos", true, "", "mcp_holdback_consolidated_projects"),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *ServerConfig
|
||||
path string
|
||||
headers map[string]string
|
||||
expectedTools []string
|
||||
}{
|
||||
{
|
||||
name: "no static config preserves existing behavior",
|
||||
config: &ServerConfig{Version: "test"},
|
||||
path: "/",
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static read-only filters write tools",
|
||||
config: &ServerConfig{Version: "test", ReadOnly: true},
|
||||
path: "/",
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static read-only cannot be overridden by header",
|
||||
config: &ServerConfig{Version: "test", ReadOnly: true},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPReadOnlyHeader: "false",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static toolsets restricts available tools",
|
||||
config: &ServerConfig{Version: "test", EnabledToolsets: []string{"repos"}},
|
||||
path: "/",
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static toolsets cannot be expanded by header",
|
||||
config: &ServerConfig{Version: "test", EnabledToolsets: []string{"repos"}},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsetsHeader: "issues",
|
||||
},
|
||||
// Header asks for "issues" but only "repos" tools exist in the static universe
|
||||
expectedTools: []string{},
|
||||
},
|
||||
{
|
||||
name: "per-request header can narrow within static toolset bounds",
|
||||
config: &ServerConfig{Version: "test", EnabledToolsets: []string{"repos", "issues"}},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsetsHeader: "repos",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static exclude-tools removes tools",
|
||||
config: &ServerConfig{Version: "test", ExcludeTools: []string{"create_repository", "create_issue"}},
|
||||
path: "/",
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static exclude-tools cannot be re-included by header",
|
||||
config: &ServerConfig{Version: "test", ExcludeTools: []string{"create_repository"}},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsHeader: "create_repository,list_issues",
|
||||
},
|
||||
// create_repository was excluded at static level, only list_issues available
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "static read-only combined with per-request toolset",
|
||||
config: &ServerConfig{Version: "test", ReadOnly: true},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsetsHeader: "repos",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static toolset with URL readonly",
|
||||
config: &ServerConfig{Version: "test", EnabledToolsets: []string{"repos", "issues"}},
|
||||
path: "/readonly",
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static tools enables specific tools only",
|
||||
config: &ServerConfig{Version: "test", EnabledTools: []string{"list_issues", "get_file_contents"}},
|
||||
path: "/",
|
||||
expectedTools: []string{"list_issues", "get_file_contents"},
|
||||
},
|
||||
{
|
||||
name: "static tools cannot be expanded by header",
|
||||
config: &ServerConfig{Version: "test", EnabledTools: []string{"list_issues"}},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsHeader: "create_repository",
|
||||
},
|
||||
// create_repository isn't in the static universe so it's silently dropped;
|
||||
// the empty filter shows all tools within static bounds
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "static exclude-tools combined with per-request exclude",
|
||||
config: &ServerConfig{Version: "test", ExcludeTools: []string{"create_repository"}},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPExcludeToolsHeader: "create_issue",
|
||||
},
|
||||
// Both static and per-request exclusions apply
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedInventory *inventory.Inventory
|
||||
var capturedCtx context.Context
|
||||
|
||||
featureChecker := func(ctx context.Context, flag string) (bool, error) {
|
||||
return slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag), nil
|
||||
}
|
||||
|
||||
apiHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Build static tools the same way the production code does
|
||||
staticTools, staticResources, staticPrompts := buildStaticInventoryFromTools(tt.config, tools)
|
||||
hasStatic := hasStaticConfig(tt.config)
|
||||
|
||||
validToolNames := make(map[string]bool, len(staticTools))
|
||||
for _, tool := range staticTools {
|
||||
validToolNames[tool.Tool.Name] = true
|
||||
}
|
||||
|
||||
inventoryFactory := func(r *http.Request) (*inventory.Inventory, error) {
|
||||
capturedCtx = r.Context()
|
||||
builder := inventory.NewBuilder().
|
||||
SetTools(staticTools).
|
||||
SetResources(staticResources).
|
||||
SetPrompts(staticPrompts).
|
||||
WithDeprecatedAliases(github.DeprecatedToolAliases).
|
||||
WithFeatureChecker(featureChecker)
|
||||
|
||||
if hasStatic {
|
||||
builder = builder.WithToolsets([]string{"all"})
|
||||
}
|
||||
if tt.config.ReadOnly {
|
||||
builder = builder.WithReadOnly(true)
|
||||
}
|
||||
|
||||
if hasStatic {
|
||||
r = filterRequestTools(r, validToolNames)
|
||||
}
|
||||
|
||||
builder = InventoryFiltersForRequest(r, builder)
|
||||
inv, buildErr := builder.Build()
|
||||
if buildErr != nil {
|
||||
return nil, buildErr
|
||||
}
|
||||
capturedInventory = inv
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
mcpServerFactory := 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
|
||||
}
|
||||
|
||||
handler := NewHTTPMcpHandler(
|
||||
context.Background(),
|
||||
tt.config,
|
||||
nil,
|
||||
translations.NullTranslationHelper,
|
||||
slog.Default(),
|
||||
apiHost,
|
||||
WithInventoryFactory(inventoryFactory),
|
||||
WithGitHubMCPServerFactory(mcpServerFactory),
|
||||
WithScopeFetcher(allScopesFetcher{}),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterMiddleware(r)
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, tt.path, nil)
|
||||
req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_testtoken")
|
||||
for k, v := range tt.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
require.NotNil(t, capturedInventory, "inventory should have been created")
|
||||
|
||||
toolNames := extractToolNames(capturedCtx, capturedInventory)
|
||||
expectedSorted := make([]string, len(tt.expectedTools))
|
||||
copy(expectedSorted, tt.expectedTools)
|
||||
sort.Strings(expectedSorted)
|
||||
|
||||
assert.Equal(t, expectedSorted, toolNames, "tools should match expected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticInventoryPreservesPerRequestFeatureVariants(t *testing.T) {
|
||||
tools := []inventory.ServerTool{
|
||||
mockToolWithFeatureFlag("list_issues", "issues", true, "", github.FeatureFlagCSVOutput),
|
||||
mockToolWithFeatureFlag("list_issues", "issues", true, github.FeatureFlagCSVOutput, ""),
|
||||
}
|
||||
cfg := &ServerConfig{Version: "test", EnabledToolsets: []string{"issues"}}
|
||||
featureChecker := createHTTPFeatureChecker(nil, false)
|
||||
|
||||
staticTools, _, _ := buildStaticInventoryFromTools(cfg, tools)
|
||||
require.Len(t, staticTools, 2, "static upper bounds should preserve both feature variants")
|
||||
|
||||
inv, err := inventory.NewBuilder().
|
||||
SetTools(staticTools).
|
||||
WithFeatureChecker(featureChecker).
|
||||
WithToolsets([]string{"all"}).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := ghcontext.WithInsidersMode(context.Background(), true)
|
||||
available := inv.AvailableTools(ctx)
|
||||
require.Len(t, available, 1)
|
||||
assert.Equal(t, "list_issues", available[0].Tool.Name)
|
||||
assert.Equal(t, github.FeatureFlagCSVOutput, available[0].FeatureFlagEnable)
|
||||
}
|
||||
|
||||
// TestContentTypeHandling verifies that the MCP StreamableHTTP handler
|
||||
// accepts Content-Type values with additional parameters like charset=utf-8.
|
||||
// This is a regression test for https://github.com/github/github-mcp-server/issues/2333
|
||||
// where the Go SDK performs strict string matching against "application/json"
|
||||
// and rejects requests with "application/json; charset=utf-8".
|
||||
func TestContentTypeHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contentType string
|
||||
expectUnsupportedMedia bool
|
||||
}{
|
||||
{
|
||||
name: "exact application/json is accepted",
|
||||
contentType: "application/json",
|
||||
expectUnsupportedMedia: false,
|
||||
},
|
||||
{
|
||||
name: "application/json with charset=utf-8 should be accepted",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
expectUnsupportedMedia: false,
|
||||
},
|
||||
{
|
||||
name: "application/json with charset=UTF-8 should be accepted",
|
||||
contentType: "application/json; charset=UTF-8",
|
||||
expectUnsupportedMedia: false,
|
||||
},
|
||||
{
|
||||
name: "completely wrong content type is rejected",
|
||||
contentType: "text/plain",
|
||||
expectUnsupportedMedia: true,
|
||||
},
|
||||
{
|
||||
name: "empty content type is rejected",
|
||||
contentType: "",
|
||||
expectUnsupportedMedia: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a minimal MCP server factory
|
||||
mcpServerFactory := 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
|
||||
}
|
||||
|
||||
// Create a simple inventory factory
|
||||
inventoryFactory := func(_ *http.Request) (*inventory.Inventory, error) {
|
||||
return inventory.NewBuilder().
|
||||
SetTools(testTools()).
|
||||
WithToolsets([]string{"all"}).
|
||||
Build()
|
||||
}
|
||||
|
||||
apiHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := NewHTTPMcpHandler(
|
||||
context.Background(),
|
||||
&ServerConfig{Version: "test"},
|
||||
nil,
|
||||
translations.NullTranslationHelper,
|
||||
slog.Default(),
|
||||
apiHost,
|
||||
WithInventoryFactory(inventoryFactory),
|
||||
WithGitHubMCPServerFactory(mcpServerFactory),
|
||||
WithScopeFetcher(allScopesFetcher{}),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterMiddleware(r)
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Send an MCP initialize request as a POST with the given Content-Type
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_testtoken")
|
||||
req.Header.Set(headers.AcceptHeader, strings.Join([]string{headers.ContentTypeJSON, headers.ContentTypeEventStream}, ", "))
|
||||
if tt.contentType != "" {
|
||||
req.Header.Set(headers.ContentTypeHeader, tt.contentType)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if tt.expectUnsupportedMedia {
|
||||
assert.Equal(t, http.StatusUnsupportedMediaType, rr.Code,
|
||||
"expected 415 Unsupported Media Type for Content-Type: %q", tt.contentType)
|
||||
} else {
|
||||
assert.NotEqual(t, http.StatusUnsupportedMediaType, rr.Code,
|
||||
"should not get 415 for Content-Type: %q, got status %d", tt.contentType, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// buildStaticInventoryFromTools is a test helper that mirrors buildStaticInventory
|
||||
// but uses the provided mock tools instead of calling github.AllTools.
|
||||
func buildStaticInventoryFromTools(cfg *ServerConfig, tools []inventory.ServerTool) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) {
|
||||
if !hasStaticConfig(cfg) {
|
||||
return tools, nil, nil
|
||||
}
|
||||
|
||||
b := inventory.NewBuilder().
|
||||
SetTools(tools).
|
||||
WithReadOnly(cfg.ReadOnly).
|
||||
WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools))
|
||||
|
||||
if len(cfg.EnabledTools) > 0 {
|
||||
b = b.WithTools(github.CleanTools(cfg.EnabledTools))
|
||||
}
|
||||
|
||||
if len(cfg.ExcludeTools) > 0 {
|
||||
b = b.WithExcludeTools(cfg.ExcludeTools)
|
||||
}
|
||||
|
||||
inv, err := b.Build()
|
||||
if err != nil {
|
||||
return tools, nil, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
return inv.AvailableTools(ctx), inv.AvailableResourceTemplates(ctx), inv.AvailablePrompts(ctx)
|
||||
}
|
||||
|
||||
func TestCrossOriginProtection(t *testing.T) {
|
||||
jsonRPCBody := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}`
|
||||
|
||||
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
|
||||
}),
|
||||
WithScopeFetcher(allScopesFetcher{}),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterMiddleware(r)
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
secFetchSite string
|
||||
origin string
|
||||
}{
|
||||
{
|
||||
name: "cross-site request with bearer token succeeds",
|
||||
secFetchSite: "cross-site",
|
||||
origin: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "same-origin request succeeds",
|
||||
secFetchSite: "same-origin",
|
||||
},
|
||||
{
|
||||
name: "native client without Sec-Fetch-Site succeeds",
|
||||
secFetchSite: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonRPCBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
req.Header.Set(headers.AuthorizationHeader, "Bearer github_pat_xyz")
|
||||
if tt.secFetchSite != "" {
|
||||
req.Header.Set("Sec-Fetch-Site", tt.secFetchSite)
|
||||
}
|
||||
if tt.origin != "" {
|
||||
req.Header.Set("Origin", tt.origin)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code, "unexpected status code; body: %s", rr.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInsidersRoutePreservesUIMeta is a regression test for the bug where
|
||||
// _meta.ui was stripped from tools/list responses on the HTTP /insiders route.
|
||||
//
|
||||
// Before the fix:
|
||||
// - buildStaticInventory called Build() on a builder configured with the
|
||||
// HTTP feature checker (which reads insiders mode from the request ctx).
|
||||
// - Build() invoked checkFeatureFlag(context.Background()) — bg ctx has no
|
||||
// insiders mode, so the FF reported MCP Apps off, and stripMCPAppsMetadata
|
||||
// ran eagerly against the static tool slice at server startup.
|
||||
// - Per-request inventory factories then served pre-stripped tools regardless
|
||||
// of whether the request actually came in via /insiders.
|
||||
//
|
||||
// After the fix:
|
||||
// - Build() no longer touches MCP Apps metadata.
|
||||
// - RegisterTools applies the strip per-request, using the request context
|
||||
// where the HTTP feature checker correctly observes insiders mode.
|
||||
func TestInsidersRoutePreservesUIMeta(t *testing.T) {
|
||||
const uiURI = "ui://test/widget"
|
||||
uiTool := mockTool("with_ui", "repos", true)
|
||||
uiTool.Tool.Meta = mcp.Meta{"ui": map[string]any{"resourceUri": uiURI}}
|
||||
|
||||
checker := createHTTPFeatureChecker(nil, false)
|
||||
build := func() *inventory.Inventory {
|
||||
inv, err := inventory.NewBuilder().
|
||||
SetTools([]inventory.ServerTool{uiTool}).
|
||||
WithFeatureChecker(checker).
|
||||
WithToolsets([]string{"all"}).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
return inv
|
||||
}
|
||||
|
||||
// Simulate a /insiders request: ctx has insiders mode set.
|
||||
insidersCtx := ghcontext.WithInsidersMode(context.Background(), true)
|
||||
|
||||
// AvailableTools no longer strips _meta.ui (post-fix), regardless of ctx.
|
||||
// The strip lives in RegisterTools, gated on the per-request FF check.
|
||||
insidersTools := build().AvailableTools(insidersCtx)
|
||||
plainTools := build().AvailableTools(context.Background())
|
||||
|
||||
// On the /insiders path, the FF check returns true → no strip → _meta preserved.
|
||||
enabled, _ := checker(insidersCtx, "remote_mcp_ui_apps")
|
||||
require.True(t, enabled, "FF should be on for /insiders ctx")
|
||||
require.Len(t, insidersTools, 1)
|
||||
require.NotNil(t, insidersTools[0].Tool.Meta, "_meta should be present on /insiders")
|
||||
require.Equal(t, uiURI, insidersTools[0].Tool.Meta["ui"].(map[string]any)["resourceUri"])
|
||||
|
||||
// On the non-insiders path, RegisterTools strips _meta.ui.
|
||||
plainEnabled, _ := checker(context.Background(), "remote_mcp_ui_apps")
|
||||
require.False(t, plainEnabled, "FF should be off for non-insiders ctx")
|
||||
require.Len(t, plainTools, 1)
|
||||
}
|
||||
|
||||
// TestUIMetaStrippedWhenClientLacksCapability verifies that even on the
|
||||
// /insiders path (where the feature flag is on), UI metadata is stripped from
|
||||
// tools/list responses when the client did NOT advertise the
|
||||
// io.modelcontextprotocol/ui extension capability. Per the 2026-01-26 MCP
|
||||
// Apps spec, servers SHOULD check client capabilities before exposing
|
||||
// UI-enabled tools.
|
||||
func TestUIMetaStrippedWhenClientLacksCapability(t *testing.T) {
|
||||
const uiURI = "ui://test/widget"
|
||||
uiTool := mockTool("with_ui", "repos", true)
|
||||
uiTool.Tool.Meta = mcp.Meta{"ui": map[string]any{"resourceUri": uiURI}}
|
||||
|
||||
checker := createHTTPFeatureChecker(nil, false)
|
||||
build := func() *inventory.Inventory {
|
||||
inv, err := inventory.NewBuilder().
|
||||
SetTools([]inventory.ServerTool{uiTool}).
|
||||
WithFeatureChecker(checker).
|
||||
WithToolsets([]string{"all"}).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
return inv
|
||||
}
|
||||
|
||||
insidersCtx := ghcontext.WithInsidersMode(context.Background(), true)
|
||||
withoutUICap := ghcontext.WithUISupport(insidersCtx, false)
|
||||
withUICap := ghcontext.WithUISupport(insidersCtx, true)
|
||||
|
||||
stripped := build().ToolsForRegistration(withoutUICap)
|
||||
require.Len(t, stripped, 1)
|
||||
require.Nil(t, stripped[0].Tool.Meta["ui"], "_meta.ui should be stripped when client lacks UI capability")
|
||||
|
||||
preserved := build().ToolsForRegistration(withUICap)
|
||||
require.Len(t, preserved, 1)
|
||||
require.NotNil(t, preserved[0].Tool.Meta["ui"], "_meta.ui should be preserved when client advertises UI capability")
|
||||
require.Equal(t, uiURI, preserved[0].Tool.Meta["ui"].(map[string]any)["resourceUri"])
|
||||
|
||||
// Unknown capability falls through to the FF gate (insiders ctx → kept).
|
||||
unknown := build().ToolsForRegistration(insidersCtx)
|
||||
require.Len(t, unknown, 1)
|
||||
require.NotNil(t, unknown[0].Tool.Meta["ui"], "_meta.ui should be preserved when capability is unknown and FF is on")
|
||||
}
|
||||
Reference in New Issue
Block a user