Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
35 changes: 35 additions & 0 deletions pkg/context/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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
}
65 changes: 36 additions & 29 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 @@ -18,7 +18,7 @@
type InventoryFactoryFunc func(r *http.Request) *inventory.Inventory
type GitHubMCPServerFactoryFunc func(ctx context.Context, 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)
config *HTTPServerConfig
deps github.ToolDependencies
logger *slog.Logger
Expand All @@ -27,12 +27,12 @@
inventoryFactoryFunc InventoryFactoryFunc
}

type HTTPMcpHandlerOptions struct {

Check failure on line 30 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 35 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 Down Expand Up @@ -78,6 +78,28 @@

func (h *HTTPMcpHandler) RegisterRoutes(r chi.Router) {
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 @@ -95,7 +117,7 @@
w.WriteHeader(http.StatusInternalServerError)
}

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

Check failure on line 120 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 @@ -104,7 +126,7 @@
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) {

Check failure on line 129 in pkg/http/handler.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 github.NewMCPServer(&github.MCPServerConfig{
Version: cfg.Version,
Translator: cfg.Translator,
Expand All @@ -114,55 +136,40 @@
}, deps, inventory)
}

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

Check failure on line 139 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 {
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)
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 := headers.ParseCommaSeparated(r.Header.Get(headers.MCPToolsHeader)); 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
}
108 changes: 108 additions & 0 deletions pkg/http/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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/http/headers"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
)

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
headers map[string]string
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: "context toolset takes precedence over header",
contextSetup: func(ctx context.Context) context.Context {
return ghcontext.WithToolsets(ctx, []string{"repos"})
},
headers: map[string]string{
headers.MCPToolsetsHeader: "issues",
},
expectedTools: []string{"get_file_contents", "create_repository"},
},
{
name: "tools are additive with toolsets",
contextSetup: func(ctx context.Context) context.Context {
return ghcontext.WithToolsets(ctx, []string{"repos"})
},
headers: map[string]string{
headers.MCPToolsHeader: "list_issues",
},
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)
for k, v := range tt.headers {
req.Header.Set(k, v)
}
req = req.WithContext(tt.contextSetup(req.Context()))

builder := inventory.NewBuilder().
SetTools(tools).
WithToolsets([]string{"all"})

builder = InventoryFiltersForRequest(req, builder)
inv := builder.Build()

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

import (
"net/http"
"slices"
"strings"

ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/http/headers"
)

// WithRequestConfig is a middleware that extracts MCP-related headers and sets them in the request context
func WithRequestConfig(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

if relaxedParseBool(r.Header.Get(headers.MCPReadOnlyHeader)) {
ctx = ghcontext.WithReadonly(ctx, true)
}

if toolsets := headers.ParseCommaSeparated(r.Header.Get(headers.MCPToolsetsHeader)); len(toolsets) > 0 {
ctx = ghcontext.WithToolsets(ctx, toolsets)
}

next.ServeHTTP(w, r.WithContext(ctx))
})
}

// relaxedParseBool parses a string into a boolean value, treating various
// common false values or empty strings as false, and everything else as true.
// It is case-insensitive and trims whitespace.
func relaxedParseBool(s string) bool {
s = strings.TrimSpace(strings.ToLower(s))
falseValues := []string{"", "false", "0", "no", "off", "n", "f"}
return !slices.Contains(falseValues, s)
}
2 changes: 2 additions & 0 deletions pkg/http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@
"time"

"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/http/middleware"
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/go-chi/chi/v5"
)

type HTTPServerConfig struct {

Check failure on line 22 in pkg/http/server.go

View workflow job for this annotation

GitHub Actions / lint

exported: type name will be used as http.HTTPServerConfig by other packages, and that stutters; consider calling this ServerConfig (revive)
// Version of the server
Version string

Expand Down Expand Up @@ -98,6 +99,7 @@
r := chi.NewRouter()

handler := NewHTTPMcpHandler(&cfg, deps, t, logger)
r.Use(middleware.WithRequestConfig)
handler.RegisterRoutes(r)

addr := fmt.Sprintf(":%d", cfg.Port)
Expand Down
Loading