Skip to content
Draft
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
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,5 @@ require (
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

replace github.com/modelcontextprotocol/go-sdk => github.com/SamMorrowDrums/go-sdk v0.0.0-20251204132411-f66cde03f0bc
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/SamMorrowDrums/go-sdk v0.0.0-20251204132411-f66cde03f0bc h1:GbuI2fLul69iqi2/f/OhWBiWXmZkP3R7h+ijwtZnqzY=
github.com/SamMorrowDrums/go-sdk v0.0.0-20251204132411-f66cde03f0bc/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
Expand Down Expand Up @@ -55,8 +57,6 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/migueleliasweb/go-github-mock v1.3.0 h1:2sVP9JEMB2ubQw1IKto3/fzF51oFC6eVWOOFDgQoq88=
github.com/migueleliasweb/go-github-mock v1.3.0/go.mod h1:ipQhV8fTcj/G6m7BKzin08GaJ/3B5/SonRAkgrk0zCY=
github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g=
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021/go.mod h1:WERUkUryfUWlrHnFSO/BEUZ+7Ns8aZy7iVOGewxKzcc=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
Expand Down
12 changes: 12 additions & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ import (
"github.com/stretchr/testify/require"
)

// mapToTypedInput converts a map[string]interface{} to a typed struct using JSON marshaling.
// This is useful for tests that need to pass typed input to handlers.
func mapToTypedInput[T any](t *testing.T, m map[string]interface{}) T {
t.Helper()
var result T
jsonBytes, err := json.Marshal(m)
require.NoError(t, err, "failed to marshal map to JSON")
err = json.Unmarshal(jsonBytes, &result)
require.NoError(t, err, "failed to unmarshal JSON to typed input")
return result
}

type expectations struct {
path string
queryParams map[string]string
Expand Down
200 changes: 38 additions & 162 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,69 +229,29 @@ func fragmentToIssue(fragment IssueFragment) *github.Issue {
}

// IssueRead creates a tool to get details of a specific issue in a GitHub repository.
func IssueRead(getClient GetClientFn, getGQLClient GetGQLClientFn, cache *lockdown.RepoAccessCache, t translations.TranslationHelperFunc, flags FeatureFlags) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"method": {
Type: "string",
Description: `The read operation to perform on a single issue.
Options are:
1. get - Get details of a specific issue.
2. get_comments - Get issue comments.
3. get_sub_issues - Get sub-issues of the issue.
4. get_labels - Get labels assigned to the issue.
`,
Enum: []any{"get", "get_comments", "get_sub_issues", "get_labels"},
},
"owner": {
Type: "string",
Description: "The owner of the repository",
},
"repo": {
Type: "string",
Description: "The name of the repository",
},
"issue_number": {
Type: "number",
Description: "The number of the issue",
},
},
Required: []string{"method", "owner", "repo", "issue_number"},
}
WithPagination(schema)

func IssueRead(getClient GetClientFn, getGQLClient GetGQLClientFn, cache *lockdown.RepoAccessCache, t translations.TranslationHelperFunc, flags FeatureFlags) (mcp.Tool, mcp.ToolHandlerFor[IssueReadInput, any]) {
return mcp.Tool{
Name: "issue_read",
Description: t("TOOL_ISSUE_READ_DESCRIPTION", "Get information about a specific issue in a GitHub repository."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_ISSUE_READ_USER_TITLE", "Get issue details"),
ReadOnlyHint: true,
},
InputSchema: schema,
InputSchema: IssueReadInput{}.MCPSchema(),
},
func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
method, err := RequiredParam[string](args, "method")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
func(ctx context.Context, _ *mcp.CallToolRequest, input IssueReadInput) (*mcp.CallToolResult, any, error) {
// Set pagination defaults
page := input.Page
if page == 0 {
page = 1
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
perPage := input.PerPage
if perPage == 0 {
perPage = 30
}
issueNumber, err := RequiredInt(args, "issue_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

pagination, err := OptionalPaginationParams(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
pagination := PaginationParams{
Page: page,
PerPage: perPage,
}

client, err := getClient(ctx)
Expand All @@ -304,21 +264,21 @@ Options are:
return utils.NewToolResultErrorFromErr("failed to get GitHub graphql client", err), nil, nil
}

switch method {
switch input.Method {
case "get":
result, err := GetIssue(ctx, client, cache, owner, repo, issueNumber, flags)
result, err := GetIssue(ctx, client, cache, input.Owner, input.Repo, input.IssueNumber, flags)
return result, nil, err
case "get_comments":
result, err := GetIssueComments(ctx, client, cache, owner, repo, issueNumber, pagination, flags)
result, err := GetIssueComments(ctx, client, cache, input.Owner, input.Repo, input.IssueNumber, pagination, flags)
return result, nil, err
case "get_sub_issues":
result, err := GetSubIssues(ctx, client, cache, owner, repo, issueNumber, pagination, flags)
result, err := GetSubIssues(ctx, client, cache, input.Owner, input.Repo, input.IssueNumber, pagination, flags)
return result, nil, err
case "get_labels":
result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber)
result, err := GetIssueLabels(ctx, gqlClient, input.Owner, input.Repo, input.IssueNumber)
return result, nil, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", input.Method)), nil, nil
}
}
}
Expand Down Expand Up @@ -1313,97 +1273,28 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
}

// ListIssues creates a tool to list and filter repository issues
func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"state": {
Type: "string",
Description: "Filter by state, by default both open and closed issues are returned when not provided",
Enum: []any{"OPEN", "CLOSED"},
},
"labels": {
Type: "array",
Description: "Filter by labels",
Items: &jsonschema.Schema{
Type: "string",
},
},
"orderBy": {
Type: "string",
Description: "Order issues by field. If provided, the 'direction' also needs to be provided.",
Enum: []any{"CREATED_AT", "UPDATED_AT", "COMMENTS"},
},
"direction": {
Type: "string",
Description: "Order direction. If provided, the 'orderBy' also needs to be provided.",
Enum: []any{"ASC", "DESC"},
},
"since": {
Type: "string",
Description: "Filter by date (ISO 8601 timestamp)",
},
},
Required: []string{"owner", "repo"},
}
WithCursorPagination(schema)

func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[ListIssuesInput, any]) {
return mcp.Tool{
Name: "list_issues",
Description: t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"),
ReadOnlyHint: true,
},
InputSchema: schema,
InputSchema: ListIssuesInput{}.MCPSchema(),
},
func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

// Set optional parameters if provided
state, err := OptionalParam[string](args, "state")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

func(ctx context.Context, _ *mcp.CallToolRequest, input ListIssuesInput) (*mcp.CallToolResult, any, error) {
// If the state has a value, cast into an array of strings
var states []githubv4.IssueState
if state != "" {
states = append(states, githubv4.IssueState(state))
if input.State != "" {
states = append(states, githubv4.IssueState(input.State))
} else {
states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed}
}

// Get labels
labels, err := OptionalStringArrayParam(args, "labels")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

orderBy, err := OptionalParam[string](args, "orderBy")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

direction, err := OptionalParam[string](args, "direction")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
labels := input.Labels
orderBy := input.OrderBy
direction := input.Direction

// These variables are required for the GraphQL query to be set by default
// If orderBy is empty, default to CREATED_AT
Expand All @@ -1415,16 +1306,12 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun
direction = "DESC"
}

since, err := OptionalParam[string](args, "since")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

// There are two optional parameters: since and labels.
var sinceTime time.Time
var hasSince bool
if since != "" {
sinceTime, err = parseISOTimestamp(since)
if input.Since != "" {
var err error
sinceTime, err = parseISOTimestamp(input.Since)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil, nil
}
Expand All @@ -1433,39 +1320,28 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun
hasLabels := len(labels) > 0

// Get pagination parameters and convert to GraphQL format
pagination, err := OptionalCursorPaginationParams(args)
if err != nil {
return nil, nil, err
perPage := input.PerPage
if perPage == 0 {
perPage = 30
}

// Check if someone tried to use page-based pagination instead of cursor-based
if _, pageProvided := args["page"]; pageProvided {
return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil
pagination := CursorPaginationParams{
PerPage: perPage,
After: input.After,
}

// Check if pagination parameters were explicitly provided
_, perPageProvided := args["perPage"]
paginationExplicit := perPageProvided

paginationParams, err := pagination.ToGraphQLParams()
if err != nil {
return nil, nil, err
}

// Use default of 30 if pagination was not explicitly provided
if !paginationExplicit {
defaultFirst := int32(DefaultGraphQLPageSize)
paginationParams.First = &defaultFirst
}

client, err := getGQLClient(ctx)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
}

vars := map[string]interface{}{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
"owner": githubv4.String(input.Owner),
"repo": githubv4.String(input.Repo),
"states": states,
"orderBy": githubv4.IssueOrderField(orderBy),
"direction": githubv4.OrderDirection(direction),
Expand Down
15 changes: 10 additions & 5 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,8 @@ func Test_GetIssue(t *testing.T) {
_, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), cache, translations.NullTranslationHelper, flags)

request := createMCPRequest(tc.requestArgs)
result, _, err := handler(context.Background(), &request, tc.requestArgs)
typedInput := mapToTypedInput[IssueReadInput](t, tc.requestArgs)
result, _, err := handler(context.Background(), &request, typedInput)

if tc.expectHandlerError {
require.Error(t, err)
Expand Down Expand Up @@ -1244,7 +1245,8 @@ func Test_ListIssues(t *testing.T) {
_, handler := ListIssues(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)

req := createMCPRequest(tc.reqParams)
res, _, err := handler(context.Background(), &req, tc.reqParams)
typedInput := mapToTypedInput[ListIssuesInput](t, tc.reqParams)
res, _, err := handler(context.Background(), &req, typedInput)
text := getTextResult(t, res).Text

if tc.expectError {
Expand Down Expand Up @@ -1988,9 +1990,10 @@ func Test_GetIssueComments(t *testing.T) {

// Create call request
request := createMCPRequest(tc.requestArgs)
typedInput := mapToTypedInput[IssueReadInput](t, tc.requestArgs)

// Call handler
result, _, err := handler(context.Background(), &request, tc.requestArgs)
result, _, err := handler(context.Background(), &request, typedInput)

// Verify results
if tc.expectError {
Expand Down Expand Up @@ -2102,7 +2105,8 @@ func Test_GetIssueLabels(t *testing.T) {
_, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), stubRepoAccessCache(gqlClient, 15*time.Minute), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false}))

request := createMCPRequest(tc.requestArgs)
result, _, err := handler(context.Background(), &request, tc.requestArgs)
typedInput := mapToTypedInput[IssueReadInput](t, tc.requestArgs)
result, _, err := handler(context.Background(), &request, typedInput)

require.NoError(t, err)
assert.NotNil(t, result)
Expand Down Expand Up @@ -2991,9 +2995,10 @@ func Test_GetSubIssues(t *testing.T) {

// Create call request
request := createMCPRequest(tc.requestArgs)
typedInput := mapToTypedInput[IssueReadInput](t, tc.requestArgs)

// Call handler
result, _, err := handler(context.Background(), &request, tc.requestArgs)
result, _, err := handler(context.Background(), &request, typedInput)

// Verify results
if tc.expectError {
Expand Down
Loading