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
4 changes: 4 additions & 0 deletions pkg/context/mcp_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ 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
// RawArguments contains the unmaterialized tool arguments for tools/call requests.
RawArguments json.RawMessage
// ProtocolVersion and ClientCapabilities describe the requesting MCP client
Expand Down
11 changes: 11 additions & 0 deletions pkg/http/middleware/mcp_parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ func parseMCPMethodInfo(body []byte) (*ghcontext.MCPMethodInfo, error) {
case "tools/call":
methodInfo.ItemName = mcpReq.Params.Name
methodInfo.RawArguments = mcpReq.Params.Arguments
if len(methodInfo.RawArguments) > 0 {
var args struct {
Owner json.RawMessage `json:"owner"`
Repo json.RawMessage `json:"repo"`
}
if err := json.Unmarshal(methodInfo.RawArguments, &args); err == nil {
// Decode independently so an invalid type does not discard the other field.
_ = json.Unmarshal(args.Owner, &methodInfo.Owner)
_ = json.Unmarshal(args.Repo, &methodInfo.Repo)
}
}
case "prompts/get":
methodInfo.ItemName = mcpReq.Params.Name
case "resources/read":
Expand Down
78 changes: 78 additions & 0 deletions pkg/http/middleware/mcp_parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ func TestWithMCPParse(t *testing.T) {
expectInfo bool
expectedMethod string
expectedItem string
expectedOwner string
expectedRepo string
expectedRaw string
expectedArgs map[string]any
expectedProtocol string
Expand Down Expand Up @@ -109,6 +111,8 @@ 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"},
},
Expand Down Expand Up @@ -174,6 +178,8 @@ 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)
Expand All @@ -199,6 +205,78 @@ func TestWithMCPParse(t *testing.T) {
}
}

func TestWithMCPParseOwnerRepo(t *testing.T) {
tests := []struct {
name string
arguments string
owner string
repo string
}{
{
name: "owner survives numeric repo",
arguments: `{"owner":"github","repo":123}`,
owner: "github",
},
{
name: "repo survives object owner",
arguments: `{"owner":{"name":"github"},"repo":"github-mcp-server"}`,
repo: "github-mcp-server",
},
{
name: "owner survives array repo",
arguments: `{"owner":"github","repo":["github-mcp-server"]}`,
owner: "github",
},
{
name: "repo survives boolean owner",
arguments: `{"owner":false,"repo":"github-mcp-server"}`,
repo: "github-mcp-server",
},
{
name: "null owner",
arguments: `{"owner":null,"repo":"github-mcp-server"}`,
repo: "github-mcp-server",
},
{
name: "nested arguments are ignored",
arguments: `{"owner":"github","repo":"github-mcp-server","extra":{"owner":"other","items":[1,{"repo":"other"}]}}`,
owner: "github",
repo: "github-mcp-server",
},
{
name: "duplicate field uses last value",
arguments: `{"owner":"github","owner":null,"repo":"github-mcp-server"}`,
repo: "github-mcp-server",
},
{name: "empty object", arguments: `{}`},
{name: "null arguments", arguments: `null`},
{name: "array arguments", arguments: `["github","github-mcp-server"]`},
{name: "string arguments", arguments: `"github"`},
{name: "numeric arguments", arguments: `123`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"test_tool","arguments":` + tt.arguments + `}}`
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
info, ok := ghcontext.MCPMethod(r.Context())
require.True(t, ok)
require.NotNil(t, info)
assert.Equal(t, "tools/call", info.Method)
assert.Equal(t, "test_tool", info.ItemName)
assert.Equal(t, tt.owner, info.Owner)
assert.Equal(t, tt.repo, info.Repo)
assert.JSONEq(t, tt.arguments, string(info.RawArguments))
restoredBody, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.Equal(t, body, string(restoredBody))
})
request := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body))
WithMCPParse()(next).ServeHTTP(httptest.NewRecorder(), request)
})
}
}

func TestWithMCPParseRetainsLargeArgumentsWithoutMaterializingThem(t *testing.T) {
nested := map[string]any{
"items": []any{
Expand Down
Loading