Skip to content
Open
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
36 changes: 28 additions & 8 deletions pkg/context/mcp_info.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package context

import "context"
import (
"context"
"encoding/json"

"github.com/modelcontextprotocol/go-sdk/mcp"
)

type mcpMethodInfoCtx string

Expand All @@ -9,20 +14,35 @@ 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")
Method string
// 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.
Expand Down
78 changes: 36 additions & 42 deletions pkg/http/middleware/mcp_parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -21,16 +22,20 @@ 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"`
}

// WithMCPParse creates a middleware that parses MCP JSON-RPC requests early in the
// 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.
Expand Down Expand Up @@ -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)

Expand All @@ -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
}
97 changes: 75 additions & 22 deletions pkg/http/middleware/mcp_parse_test.go
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
package middleware

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"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",
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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")
Expand All @@ -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"}}`

Expand Down
2 changes: 1 addition & 1 deletion third-party-licenses.darwin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion third-party-licenses.linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion third-party-licenses.windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading