Skip to content
Merged
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
6 changes: 3 additions & 3 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
}, nil
}

func NewStdioMCPServer(cfg github.MCPServerConfig) (*mcp.Server, error) {
func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Server, error) {
apiHost, err := utils.NewAPIHost(cfg.Host)
if err != nil {
return nil, fmt.Errorf("failed to parse API host: %w", err)
Expand Down Expand Up @@ -144,7 +144,7 @@ func NewStdioMCPServer(cfg github.MCPServerConfig) (*mcp.Server, error) {
return nil, fmt.Errorf("failed to build inventory: %w", err)
}

ghServer, err := github.NewMCPServer(&cfg, deps, inventory)
ghServer, err := github.NewMCPServer(ctx, &cfg, deps, inventory)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub MCP server: %w", err)
}
Expand Down Expand Up @@ -246,7 +246,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
logger.Debug("skipping scope filtering for non-PAT token")
}

ghServer, err := NewStdioMCPServer(github.MCPServerConfig{
ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
Expand Down
51 changes: 51 additions & 0 deletions pkg/context/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package context

import "context"

// readonlyCtxKey is a context key for read-only mode
type readonlyCtxKey struct{}

// WithReadonly adds read-only mode state to the context
func WithReadonly(ctx context.Context, enabled bool) context.Context {
return context.WithValue(ctx, readonlyCtxKey{}, enabled)
}

// IsReadonly retrieves the read-only mode state from the context
func IsReadonly(ctx context.Context) bool {
if enabled, ok := ctx.Value(readonlyCtxKey{}).(bool); ok {
return enabled
}
return false
}

// toolsetsCtxKey is a context key for the active toolsets
type toolsetsCtxKey struct{}

// WithToolsets adds the active toolsets to the context
func WithToolsets(ctx context.Context, toolsets []string) context.Context {
return context.WithValue(ctx, toolsetsCtxKey{}, toolsets)
Comment on lines +24 to +26
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WithToolsets function stores a slice directly in the context without copying it. If the caller modifies the slice after calling WithToolsets, it could lead to unexpected behavior or race conditions since the same slice reference is stored. Consider documenting that the slice should not be modified after being passed, or make a defensive copy of the slice before storing it in the context.

Suggested change
// WithToolsets adds the active toolsets to the context
func WithToolsets(ctx context.Context, toolsets []string) context.Context {
return context.WithValue(ctx, toolsetsCtxKey{}, toolsets)
// WithToolsets adds the active toolsets to the context.
// The provided slice is defensively copied to avoid unexpected mutations.
func WithToolsets(ctx context.Context, toolsets []string) context.Context {
copied := append([]string(nil), toolsets...)
return context.WithValue(ctx, toolsetsCtxKey{}, copied)

Copilot uses AI. Check for mistakes.
}

// GetToolsets retrieves the active toolsets from the context
func GetToolsets(ctx context.Context) []string {
if toolsets, ok := ctx.Value(toolsetsCtxKey{}).([]string); ok {
return toolsets
}
return nil
}

// toolsCtxKey is a context key for tools
type toolsCtxKey struct{}

// WithTools adds the tools to the context
func WithTools(ctx context.Context, tools []string) context.Context {
return context.WithValue(ctx, toolsCtxKey{}, tools)
}

// GetTools retrieves the tools from the context
func GetTools(ctx context.Context) []string {
if tools, ok := ctx.Value(toolsCtxKey{}).([]string); ok {
return tools
}
return nil
}
4 changes: 2 additions & 2 deletions pkg/github/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@

type MCPServerOption func(*mcp.ServerOptions)

func NewMCPServer(cfg *MCPServerConfig, deps ToolDependencies, inventory *inventory.Inventory) (*mcp.Server, error) {
func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependencies, inventory *inventory.Inventory) (*mcp.Server, error) {
// Create the MCP server
serverOpts := &mcp.ServerOptions{
Instructions: inventory.Instructions(),
Expand Down Expand Up @@ -110,7 +110,7 @@
// In dynamic mode with no explicit toolsets, this is a no-op since enabledToolsets
// is empty - users enable toolsets at runtime via the dynamic tools below (but can
// enable toolsets or tools explicitly that do need registration).
inventory.RegisterAll(context.Background(), ghServer, deps)
inventory.RegisterAll(ctx, ghServer, deps)

// Register dynamic toolset management tools (enable/disable) - these are separate
// meta-tools that control the inventory, not part of the inventory itself
Expand All @@ -137,7 +137,7 @@
// createFeatureChecker returns a FeatureFlagChecker that checks if a flag name
// is present in the provided list of enabled features. For the local server,
// this is populated from the --features CLI flag.
func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker {

Check failure on line 140 in pkg/github/server.go

View workflow job for this annotation

GitHub Actions / lint

func createFeatureChecker is unused (unused)
// Build a set for O(1) lookup
featureSet := make(map[string]bool, len(enabledFeatures))
for _, f := range enabledFeatures {
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
return nil, nil
}

func (s stubDeps) GetRepoAccessCache(ctx context.Context) (*lockdown.RepoAccessCache, error) {

Check failure on line 55 in pkg/github/server_test.go

View workflow job for this annotation

GitHub Actions / lint

unused-parameter: parameter 'ctx' seems to be unused, consider removing or renaming it as _ (revive)
return s.repoAccessCache, nil
}
func (s stubDeps) GetT() translations.TranslationHelperFunc { return s.t }
Expand Down Expand Up @@ -134,7 +134,7 @@
require.NoError(t, err, "expected inventory build to succeed")

// Create the server
server, err := NewMCPServer(&cfg, deps, inv)
server, err := NewMCPServer(t.Context(), &cfg, deps, inv)
require.NoError(t, err, "expected server creation to succeed")
require.NotNil(t, server, "expected server to be non-nil")

Expand Down
82 changes: 48 additions & 34 deletions pkg/http/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
"context"
"log/slog"
"net/http"
"strings"

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"
Expand All @@ -16,9 +16,10 @@
)

type InventoryFactoryFunc func(r *http.Request) (*inventory.Inventory, error)
type GitHubMCPServerFactoryFunc func(ctx context.Context, r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error)
type GitHubMCPServerFactoryFunc func(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error)

type HTTPMcpHandler struct {

Check failure on line 21 in pkg/http/handler.go

View workflow job for this annotation

GitHub Actions / lint

exported: type name will be used as http.HTTPMcpHandler by other packages, and that stutters; consider calling this McpHandler (revive)
ctx context.Context
config *HTTPServerConfig
deps github.ToolDependencies
logger *slog.Logger
Expand All @@ -27,12 +28,12 @@
inventoryFactoryFunc InventoryFactoryFunc
}

type HTTPMcpHandlerOptions struct {

Check failure on line 31 in pkg/http/handler.go

View workflow job for this annotation

GitHub Actions / lint

exported: type name will be used as http.HTTPMcpHandlerOptions by other packages, and that stutters; consider calling this McpHandlerOptions (revive)
GitHubMcpServerFactory GitHubMCPServerFactoryFunc
InventoryFactory InventoryFactoryFunc
}

type HTTPMcpHandlerOption func(*HTTPMcpHandlerOptions)

Check failure on line 36 in pkg/http/handler.go

View workflow job for this annotation

GitHub Actions / lint

exported: type name will be used as http.HTTPMcpHandlerOption by other packages, and that stutters; consider calling this McpHandlerOption (revive)

func WithGitHubMCPServerFactory(f GitHubMCPServerFactoryFunc) HTTPMcpHandlerOption {
return func(o *HTTPMcpHandlerOptions) {
Expand All @@ -46,7 +47,9 @@
}
}

func NewHTTPMcpHandler(cfg *HTTPServerConfig,
func NewHTTPMcpHandler(
ctx context.Context,
cfg *HTTPServerConfig,
deps github.ToolDependencies,
t translations.TranslationHelperFunc,
logger *slog.Logger,
Expand All @@ -67,6 +70,7 @@
}

return &HTTPMcpHandler{
ctx: ctx,
config: cfg,
deps: deps,
logger: logger,
Expand All @@ -76,8 +80,33 @@
}
}

// RegisterRoutes registers the routes for the MCP server
// URL-based values take precedence over header-based values
func (h *HTTPMcpHandler) RegisterRoutes(r chi.Router) {
r.Use(middleware.WithRequestConfig)

r.Mount("/", h)
// Mount readonly and toolset routes
r.With(withToolset).Mount("/x/{toolset}", h)
r.With(withReadonly, withToolset).Mount("/x/{toolset}/readonly", h)
r.With(withReadonly).Mount("/readonly", h)
}

// withReadonly is middleware that sets readonly mode in the request context
func withReadonly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := ghcontext.WithReadonly(r.Context(), true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

// withToolset is middleware that extracts the toolset from the URL and sets it in the request context
func withToolset(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
toolset := chi.URLParam(r, "toolset")
ctx := ghcontext.WithToolsets(r.Context(), []string{toolset})
next.ServeHTTP(w, r.WithContext(ctx))
})
}

func (h *HTTPMcpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Expand All @@ -87,7 +116,7 @@
return
}

ghServer, err := h.githubMcpServerFactory(r.Context(), r, h.deps, inventory, &github.MCPServerConfig{
ghServer, err := h.githubMcpServerFactory(r, h.deps, inventory, &github.MCPServerConfig{
Version: h.config.Version,
Translator: h.t,
ContentWindowSize: h.config.ContentWindowSize,
Expand All @@ -99,7 +128,7 @@
w.WriteHeader(http.StatusInternalServerError)
}

mcpHandler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {

Check failure on line 131 in pkg/http/handler.go

View workflow job for this annotation

GitHub Actions / lint

unused-parameter: parameter 'r' seems to be unused, consider removing or renaming it as _ (revive)
return ghServer
}, &mcp.StreamableHTTPOptions{
Stateless: true,
Expand All @@ -108,8 +137,8 @@
middleware.ExtractUserToken()(mcpHandler).ServeHTTP(w, r)
}

func DefaultGitHubMCPServerFactory(ctx context.Context, _ *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) {
return github.NewMCPServer(&github.MCPServerConfig{
func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) {
return github.NewMCPServer(r.Context(), &github.MCPServerConfig{
Version: cfg.Version,
Translator: cfg.Translator,
ContentWindowSize: cfg.ContentWindowSize,
Expand All @@ -118,57 +147,42 @@
}, deps, inventory)
}

func DefaultInventoryFactory(cfg *HTTPServerConfig, t translations.TranslationHelperFunc, staticChecker inventory.FeatureFlagChecker) InventoryFactoryFunc {

Check failure on line 150 in pkg/http/handler.go

View workflow job for this annotation

GitHub Actions / lint

unused-parameter: parameter 'cfg' seems to be unused, consider removing or renaming it as _ (revive)
return func(r *http.Request) (*inventory.Inventory, error) {
b := github.NewInventory(t).WithDeprecatedAliases(github.DeprecatedToolAliases)

// Feature checker composition
headerFeatures := parseCommaSeparatedHeader(r.Header.Get(headers.MCPFeaturesHeader))
headerFeatures := headers.ParseCommaSeparated(r.Header.Get(headers.MCPFeaturesHeader))
if checker := ComposeFeatureChecker(headerFeatures, staticChecker); checker != nil {
b = b.WithFeatureChecker(checker)
}

b = InventoryFiltersForRequestHeaders(r, b)
b = InventoryFiltersForRequest(r, b)
b.WithServerInstructions()

return b.Build()
}
}

// InventoryFiltersForRequestHeaders applies inventory filters based on HTTP request headers.
// Whitespace is trimmed from comma-separated values; empty values are ignored.
func InventoryFiltersForRequestHeaders(r *http.Request, builder *inventory.Builder) *inventory.Builder {
if r.Header.Get(headers.MCPReadOnlyHeader) != "" {
// InventoryFiltersForRequest applies filters to the inventory builder
// based on the request context and headers
func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *inventory.Builder {
ctx := r.Context()

if ghcontext.IsReadonly(ctx) {
builder = builder.WithReadOnly(true)
}

if toolsetsStr := r.Header.Get(headers.MCPToolsetsHeader); toolsetsStr != "" {
toolsets := parseCommaSeparatedHeader(toolsetsStr)
if toolsets := ghcontext.GetToolsets(ctx); len(toolsets) > 0 {
builder = builder.WithToolsets(toolsets)
}

if toolsStr := r.Header.Get(headers.MCPToolsHeader); toolsStr != "" {
tools := parseCommaSeparatedHeader(toolsStr)
if tools := ghcontext.GetTools(ctx); len(tools) > 0 {
if len(ghcontext.GetToolsets(ctx)) == 0 {
builder = builder.WithToolsets([]string{})
}
builder = builder.WithTools(github.CleanTools(tools))
}

return builder
}

// parseCommaSeparatedHeader splits a header value by comma, trims whitespace,
// and filters out empty values.
func parseCommaSeparatedHeader(value string) []string {
if value == "" {
return []string{}
}

parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
101 changes: 101 additions & 0 deletions pkg/http/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package http

import (
"context"
"net/http"
"net/http/httptest"
"testing"

ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/inventory"
"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 inventory.ServerTool{
Tool: mcp.Tool{
Name: name,
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: readOnly},
},
Toolset: inventory.ToolsetMetadata{
ID: inventory.ToolsetID(toolsetID),
Description: "Test: " + toolsetID,
},
}
}

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"},
},
}

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)
})
}
}
21 changes: 21 additions & 0 deletions pkg/http/headers/parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package headers

import "strings"

// ParseCommaSeparated splits a header value by comma, trims whitespace,
// and filters out empty values
func ParseCommaSeparated(value string) []string {
if value == "" {
return []string{}
}

parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
Loading
Loading