diff --git a/pkg/context/mcp_info.go b/pkg/context/mcp_info.go index ce55056821..fc09e6b139 100644 --- a/pkg/context/mcp_info.go +++ b/pkg/context/mcp_info.go @@ -1,6 +1,11 @@ package context -import "context" +import ( + "context" + "encoding/json" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) type mcpMethodInfoCtx string @@ -9,7 +14,7 @@ var mcpMethodInfoCtxKey mcpMethodInfoCtx = "mcpmethodinfo" // MCPMethodInfo contains pre-parsed MCP method information extracted from the JSON-RPC request. // This is populated early in the request lifecycle to enable: // - Inventory filtering via ForMCPRequest (only register needed tools/resources/prompts) -// - Avoiding duplicate JSON parsing in middlewares (secret-scanning, scope-challenge) +// - Avoiding duplicate JSON envelope parsing in downstream middleware // - Performance optimization for per-request server creation type MCPMethodInfo struct { // Method is the MCP method being called (e.g., "tools/call", "tools/list", "initialize") @@ -17,12 +22,27 @@ type MCPMethodInfo struct { // ItemName is the name of the specific item being accessed (tool name, resource URI, prompt name) // Only populated for call/get methods (tools/call, prompts/get, resources/read) ItemName string - // Owner is the repository owner from tool call arguments, if present - Owner string - // Repo is the repository name from tool call arguments, if present - Repo string - // Arguments contains the raw tool arguments for tools/call requests - Arguments map[string]any + // RawArguments contains the unmaterialized tool arguments for tools/call requests. + RawArguments json.RawMessage + // ProtocolVersion and ClientCapabilities describe the requesting MCP client + // when stateless HTTP parsing makes them available before registration. + ProtocolVersion string + ClientCapabilities *mcp.ClientCapabilities +} + +// DecodeArguments materializes tool arguments when request middleware needs +// call-specific values. Invalid argument shapes are returned to the caller so +// the request can continue to the tool handler's normal validation path. +func (info *MCPMethodInfo) DecodeArguments() (map[string]any, error) { + if len(info.RawArguments) == 0 { + return nil, nil + } + + var arguments map[string]any + if err := json.Unmarshal(info.RawArguments, &arguments); err != nil { + return nil, err + } + return arguments, nil } // WithMCPMethodInfo stores the MCPMethodInfo in the context. diff --git a/pkg/http/middleware/mcp_parse.go b/pkg/http/middleware/mcp_parse.go index 21848fdeb4..bcc44f01f5 100644 --- a/pkg/http/middleware/mcp_parse.go +++ b/pkg/http/middleware/mcp_parse.go @@ -7,6 +7,7 @@ import ( "net/http" ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/modelcontextprotocol/go-sdk/mcp" ) // mcpJSONRPCRequest represents the structure of an MCP JSON-RPC request. @@ -21,7 +22,11 @@ type mcpJSONRPCRequest struct { // For prompts/get // Name is shared with tools/call // For resources/read - URI string `json:"uri,omitempty"` + URI string `json:"uri,omitempty"` + Meta struct { + ProtocolVersion string `json:"io.modelcontextprotocol/protocolVersion,omitempty"` + ClientCapabilities *mcp.ClientCapabilities `json:"io.modelcontextprotocol/clientCapabilities,omitempty"` + } `json:"_meta"` } `json:"params"` } @@ -29,8 +34,8 @@ type mcpJSONRPCRequest struct { // request lifecycle and stores the parsed information in the request context. // This enables: // - Registry filtering via ForMCPRequest (only register needed tools/resources/prompts) -// - Avoiding duplicate JSON parsing in downstream middlewares -// - Access to owner/repo for secret-scanning middleware +// - Avoiding duplicate JSON envelope parsing in downstream middleware +// - Lazy access to raw tool arguments for call-specific policy checks // // The middleware reads the request body, parses it, restores the body for downstream // handlers, and stores the parsed MCPMethodInfo in the request context. @@ -72,54 +77,17 @@ func WithMCPParse() func(http.Handler) http.Handler { return } - // Parse the JSON-RPC request - var mcpReq mcpJSONRPCRequest - err = json.Unmarshal(body, &mcpReq) + methodInfo, err := parseMCPMethodInfo(body) if err != nil { // Log but continue - could be a non-MCP request or malformed JSON next.ServeHTTP(w, r) return } - - // Skip if not a valid JSON-RPC 2.0 request - if mcpReq.JSONRPC != "2.0" || mcpReq.Method == "" { + if methodInfo == nil { next.ServeHTTP(w, r) return } - // Build the MCPMethodInfo - methodInfo := &ghcontext.MCPMethodInfo{ - Method: mcpReq.Method, - } - - // Extract item name based on method type - - switch mcpReq.Method { - case "tools/call": - methodInfo.ItemName = mcpReq.Params.Name - // Parse arguments if present - if len(mcpReq.Params.Arguments) > 0 { - var args map[string]any - err := json.Unmarshal(mcpReq.Params.Arguments, &args) - if err == nil { - methodInfo.Arguments = args - // Extract owner and repo if present - if owner, ok := args["owner"].(string); ok { - methodInfo.Owner = owner - } - if repo, ok := args["repo"].(string); ok { - methodInfo.Repo = repo - } - } - } - case "prompts/get": - methodInfo.ItemName = mcpReq.Params.Name - case "resources/read": - methodInfo.ItemName = mcpReq.Params.URI - default: - // Whatever - } - // Store the parsed info in context ctx = ghcontext.WithMCPMethodInfo(ctx, methodInfo) @@ -128,3 +96,29 @@ func WithMCPParse() func(http.Handler) http.Handler { return http.HandlerFunc(fn) } } + +func parseMCPMethodInfo(body []byte) (*ghcontext.MCPMethodInfo, error) { + var mcpReq mcpJSONRPCRequest + if err := json.Unmarshal(body, &mcpReq); err != nil { + return nil, err + } + if mcpReq.JSONRPC != "2.0" || mcpReq.Method == "" { + return nil, nil + } + + methodInfo := &ghcontext.MCPMethodInfo{ + Method: mcpReq.Method, + ProtocolVersion: mcpReq.Params.Meta.ProtocolVersion, + ClientCapabilities: mcpReq.Params.Meta.ClientCapabilities, + } + switch mcpReq.Method { + case "tools/call": + methodInfo.ItemName = mcpReq.Params.Name + methodInfo.RawArguments = mcpReq.Params.Arguments + case "prompts/get": + methodInfo.ItemName = mcpReq.Params.Name + case "resources/read": + methodInfo.ItemName = mcpReq.Params.URI + } + return methodInfo, nil +} diff --git a/pkg/http/middleware/mcp_parse_test.go b/pkg/http/middleware/mcp_parse_test.go index 0965c6db61..8034dc29a6 100644 --- a/pkg/http/middleware/mcp_parse_test.go +++ b/pkg/http/middleware/mcp_parse_test.go @@ -1,6 +1,7 @@ package middleware import ( + "encoding/json" "io" "net/http" "net/http/httptest" @@ -8,22 +9,25 @@ import ( "testing" ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestWithMCPParse(t *testing.T) { tests := []struct { - name string - method string - path string - body string - expectInfo bool - expectedMethod string - expectedItem string - expectedOwner string - expectedRepo string - expectedArgs map[string]any + name string + method string + path string + body string + expectInfo bool + expectedMethod string + expectedItem string + expectedRaw string + expectedArgs map[string]any + expectedProtocol string + expectedForm bool + expectArgsError bool }{ { name: "health check path is skipped", @@ -75,6 +79,19 @@ func TestWithMCPParse(t *testing.T) { expectInfo: true, expectedMethod: "tools/list", }, + { + name: "tools/list parses client availability", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"tools/list","params":{"_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{}}} + }}}`, + expectInfo: true, + expectedMethod: "tools/list", + expectedProtocol: "2026-07-28", + expectedForm: true, + }, { name: "tools/call parses name", method: http.MethodPost, @@ -92,18 +109,19 @@ func TestWithMCPParse(t *testing.T) { expectInfo: true, expectedMethod: "tools/call", expectedItem: "get_file_contents", - expectedOwner: "github", - expectedRepo: "github-mcp-server", + expectedRaw: `{"owner":"github","repo":"github-mcp-server","path":"README.md"}`, expectedArgs: map[string]any{"owner": "github", "repo": "github-mcp-server", "path": "README.md"}, }, { - name: "tools/call with invalid arguments JSON continues without args", - method: http.MethodPost, - path: "/mcp", - body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents","arguments":"not an object"}}`, - expectInfo: true, - expectedMethod: "tools/call", - expectedItem: "get_file_contents", + name: "tools/call with invalid arguments JSON continues without args", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents","arguments":"not an object"}}`, + expectInfo: true, + expectedMethod: "tools/call", + expectedItem: "get_file_contents", + expectedRaw: `"not an object"`, + expectArgsError: true, }, { name: "prompts/get parses name", @@ -156,10 +174,23 @@ func TestWithMCPParse(t *testing.T) { require.NotNil(t, capturedInfo) assert.Equal(t, tt.expectedMethod, capturedInfo.Method) assert.Equal(t, tt.expectedItem, capturedInfo.ItemName) - assert.Equal(t, tt.expectedOwner, capturedInfo.Owner) - assert.Equal(t, tt.expectedRepo, capturedInfo.Repo) + assert.Equal(t, tt.expectedProtocol, capturedInfo.ProtocolVersion) + if tt.expectedForm { + require.NotNil(t, capturedInfo.ClientCapabilities) + require.NotNil(t, capturedInfo.ClientCapabilities.Elicitation) + assert.Equal(t, &mcp.FormElicitationCapabilities{}, capturedInfo.ClientCapabilities.Elicitation.Form) + } + if tt.expectedRaw != "" { + assert.JSONEq(t, tt.expectedRaw, string(capturedInfo.RawArguments)) + } + decodedArgs, err := capturedInfo.DecodeArguments() + if tt.expectArgsError { + assert.Error(t, err) + } else { + require.NoError(t, err) + } if tt.expectedArgs != nil { - assert.Equal(t, tt.expectedArgs, capturedInfo.Arguments) + assert.Equal(t, tt.expectedArgs, decodedArgs) } } else { assert.False(t, infoCaptured, "MCPMethodInfo should not be present in context") @@ -168,6 +199,28 @@ func TestWithMCPParse(t *testing.T) { } } +func TestWithMCPParseRetainsLargeArgumentsWithoutMaterializingThem(t *testing.T) { + nested := map[string]any{ + "items": []any{ + map[string]any{"payload": strings.Repeat("x", 32*1024)}, + []any{1.0, 2.0, 3.0}, + }, + } + rawArguments, err := json.Marshal(nested) + require.NoError(t, err) + body := `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"test_tool","arguments":` + string(rawArguments) + `}}` + + var capturedInfo *ghcontext.MCPMethodInfo + next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + capturedInfo, _ = ghcontext.MCPMethod(r.Context()) + }) + request := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body)) + WithMCPParse()(next).ServeHTTP(httptest.NewRecorder(), request) + + require.NotNil(t, capturedInfo) + assert.Equal(t, json.RawMessage(rawArguments), capturedInfo.RawArguments) +} + func TestWithMCPParse_BodyRestoration(t *testing.T) { originalBody := `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"test_tool"}}` diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 5fb50fdf74..550be61397 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -41,7 +41,7 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) + - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index cbb3e5f399..a71534d4bb 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -41,7 +41,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) + - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index bc7a0f47a4..6342246340 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -42,7 +42,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) + - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE))