@@ -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
7981func (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
83107func (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+ }
0 commit comments