Skip to content

Commit c35ab93

Browse files
committed
add readonly and toolset support
1 parent abffbf5 commit c35ab93

File tree

3 files changed

+198
-10
lines changed

3 files changed

+198
-10
lines changed

pkg/context/request.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package context
2+
3+
import "context"
4+
5+
// readonlyCtxKey is a context key for read-only mode
6+
type readonlyCtxKey struct{}
7+
8+
// WithReadonly adds read-only mode state to the context
9+
func WithReadonly(ctx context.Context, enabled bool) context.Context {
10+
return context.WithValue(ctx, readonlyCtxKey{}, enabled)
11+
}
12+
13+
// IsReadonly retrieves the read-only mode state from the context
14+
func IsReadonly(ctx context.Context) bool {
15+
if enabled, ok := ctx.Value(readonlyCtxKey{}).(bool); ok {
16+
return enabled
17+
}
18+
return false
19+
}
20+
21+
// toolsetCtxKey is a context key for the active toolset
22+
type toolsetCtxKey struct{}
23+
24+
// WithToolset adds the active toolset to the context
25+
func WithToolset(ctx context.Context, toolset string) context.Context {
26+
return context.WithValue(ctx, toolsetCtxKey{}, toolset)
27+
}
28+
29+
// GetToolset retrieves the active toolset from the context
30+
func GetToolset(ctx context.Context) string {
31+
if toolset, ok := ctx.Value(toolsetCtxKey{}).(string); ok {
32+
return toolset
33+
}
34+
return ""
35+
}

pkg/http/handler.go

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import (
44
"context"
55
"log/slog"
66
"net/http"
7+
"slices"
78
"strings"
89

10+
ghcontext "github.com/github/github-mcp-server/pkg/context"
911
"github.com/github/github-mcp-server/pkg/github"
1012
"github.com/github/github-mcp-server/pkg/http/headers"
1113
"github.com/github/github-mcp-server/pkg/http/middleware"
@@ -78,6 +80,28 @@ func NewHTTPMcpHandler(cfg *HTTPServerConfig,
7880

7981
func (h *HTTPMcpHandler) RegisterRoutes(r chi.Router) {
8082
r.Mount("/", h)
83+
84+
// Mount readonly and toolset routes
85+
r.With(withToolset).Mount("/x/{toolset}", h)
86+
r.With(withReadonly, withToolset).Mount("/x/{toolset}/readonly", h)
87+
r.With(withReadonly).Mount("/readonly", h)
88+
}
89+
90+
// withReadonly is middleware that sets readonly mode in the request context
91+
func withReadonly(next http.Handler) http.Handler {
92+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93+
ctx := ghcontext.WithReadonly(r.Context(), true)
94+
next.ServeHTTP(w, r.WithContext(ctx))
95+
})
96+
}
97+
98+
// withToolset is middleware that extracts the toolset from the URL and sets it in the request context
99+
func withToolset(next http.Handler) http.Handler {
100+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101+
toolset := chi.URLParam(r, "toolset")
102+
ctx := ghcontext.WithToolset(r.Context(), toolset)
103+
next.ServeHTTP(w, r.WithContext(ctx))
104+
})
81105
}
82106

83107
func (h *HTTPMcpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -124,25 +148,38 @@ func DefaultInventoryFactory(cfg *HTTPServerConfig, t translations.TranslationHe
124148
b = b.WithFeatureChecker(checker)
125149
}
126150

127-
b = InventoryFiltersForRequestHeaders(r, b)
151+
b = InventoryFiltersForRequest(r, b)
128152
return b.Build()
129153
}
130154
}
131155

132-
// InventoryFiltersForRequestHeaders applies inventory filters based on HTTP request headers.
133-
// Whitespace is trimmed from comma-separated values; empty values are ignored.
134-
func InventoryFiltersForRequestHeaders(r *http.Request, builder *inventory.Builder) *inventory.Builder {
135-
if r.Header.Get(headers.MCPReadOnlyHeader) != "" {
156+
// InventoryFiltersForRequest applies inventory filters from request context and headers
157+
// Whitespace is trimmed from comma-separated values; empty values are ignored
158+
// Route configuration (context) takes precedence over headers for toolsets
159+
func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *inventory.Builder {
160+
ctx := r.Context()
161+
162+
// Enable readonly mode if set in context or via header
163+
if ghcontext.IsReadonly(ctx) || relaxedParseBool(r.Header.Get(headers.MCPReadOnlyHeader)) {
136164
builder = builder.WithReadOnly(true)
137165
}
138166

139-
if toolsetsStr := r.Header.Get(headers.MCPToolsetsHeader); toolsetsStr != "" {
140-
toolsets := parseCommaSeparatedHeader(toolsetsStr)
141-
builder = builder.WithToolsets(toolsets)
167+
// Parse request configuration
168+
contextToolset := ghcontext.GetToolset(ctx)
169+
headerToolsets := parseCommaSeparatedHeader(r.Header.Get(headers.MCPToolsetsHeader))
170+
tools := parseCommaSeparatedHeader(r.Header.Get(headers.MCPToolsHeader))
171+
172+
// Apply toolset filtering (route wins, then header, then tools-only mode, else defaults)
173+
switch {
174+
case contextToolset != "":
175+
builder = builder.WithToolsets([]string{contextToolset})
176+
case len(headerToolsets) > 0:
177+
builder = builder.WithToolsets(headerToolsets)
178+
case len(tools) > 0:
179+
builder = builder.WithToolsets([]string{})
142180
}
143181

144-
if toolsStr := r.Header.Get(headers.MCPToolsHeader); toolsStr != "" {
145-
tools := parseCommaSeparatedHeader(toolsStr)
182+
if len(tools) > 0 {
146183
builder = builder.WithTools(github.CleanTools(tools))
147184
}
148185

@@ -166,3 +203,12 @@ func parseCommaSeparatedHeader(value string) []string {
166203
}
167204
return result
168205
}
206+
207+
// relaxedParseBool parses a string into a boolean value, treating various
208+
// common false values or empty strings as false, and everything else as true.
209+
// It is case-insensitive and trims whitespace.
210+
func relaxedParseBool(s string) bool {
211+
s = strings.TrimSpace(strings.ToLower(s))
212+
falseValues := []string{"", "false", "0", "no", "off", "n", "f"}
213+
return !slices.Contains(falseValues, s)
214+
}

pkg/http/handler_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package http
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
ghcontext "github.com/github/github-mcp-server/pkg/context"
10+
"github.com/github/github-mcp-server/pkg/http/headers"
11+
"github.com/github/github-mcp-server/pkg/inventory"
12+
"github.com/modelcontextprotocol/go-sdk/mcp"
13+
"github.com/stretchr/testify/assert"
14+
)
15+
16+
func mockTool(name, toolsetID string, readOnly bool) inventory.ServerTool {
17+
return inventory.ServerTool{
18+
Tool: mcp.Tool{
19+
Name: name,
20+
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: readOnly},
21+
},
22+
Toolset: inventory.ToolsetMetadata{
23+
ID: inventory.ToolsetID(toolsetID),
24+
Description: "Test: " + toolsetID,
25+
},
26+
}
27+
}
28+
29+
func TestInventoryFiltersForRequest(t *testing.T) {
30+
tools := []inventory.ServerTool{
31+
mockTool("get_file_contents", "repos", true),
32+
mockTool("create_repository", "repos", false),
33+
mockTool("list_issues", "issues", true),
34+
mockTool("issue_write", "issues", false),
35+
}
36+
37+
tests := []struct {
38+
name string
39+
contextSetup func(context.Context) context.Context
40+
headers map[string]string
41+
expectedTools []string
42+
}{
43+
{
44+
name: "no filters applies defaults",
45+
contextSetup: func(ctx context.Context) context.Context { return ctx },
46+
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "issue_write"},
47+
},
48+
{
49+
name: "readonly from context filters write tools",
50+
contextSetup: func(ctx context.Context) context.Context {
51+
return ghcontext.WithReadonly(ctx, true)
52+
},
53+
expectedTools: []string{"get_file_contents", "list_issues"},
54+
},
55+
{
56+
name: "toolset from context filters to toolset",
57+
contextSetup: func(ctx context.Context) context.Context {
58+
return ghcontext.WithToolset(ctx, "repos")
59+
},
60+
expectedTools: []string{"get_file_contents", "create_repository"},
61+
},
62+
{
63+
name: "context toolset takes precedence over header",
64+
contextSetup: func(ctx context.Context) context.Context {
65+
return ghcontext.WithToolset(ctx, "repos")
66+
},
67+
headers: map[string]string{
68+
headers.MCPToolsetsHeader: "issues",
69+
},
70+
expectedTools: []string{"get_file_contents", "create_repository"},
71+
},
72+
{
73+
name: "tools are additive with toolsets",
74+
contextSetup: func(ctx context.Context) context.Context { return ctx },
75+
headers: map[string]string{
76+
headers.MCPToolsetsHeader: "repos",
77+
headers.MCPToolsHeader: "list_issues",
78+
},
79+
expectedTools: []string{"get_file_contents", "create_repository", "list_issues"},
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
req := httptest.NewRequest(http.MethodGet, "/", nil)
86+
for k, v := range tt.headers {
87+
req.Header.Set(k, v)
88+
}
89+
req = req.WithContext(tt.contextSetup(req.Context()))
90+
91+
builder := inventory.NewBuilder().
92+
SetTools(tools).
93+
WithToolsets([]string{"all"})
94+
95+
builder = InventoryFiltersForRequest(req, builder)
96+
inv := builder.Build()
97+
98+
available := inv.AvailableTools(context.Background())
99+
toolNames := make([]string, len(available))
100+
for i, tool := range available {
101+
toolNames[i] = tool.Tool.Name
102+
}
103+
104+
assert.ElementsMatch(t, tt.expectedTools, toolNames)
105+
})
106+
}
107+
}

0 commit comments

Comments
 (0)