From d52f74aac0e6b68e975005ef9484e2589fa54432 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 25 Sep 2025 14:04:55 +0100 Subject: [PATCH 1/2] create issues_read tool for read operations --- pkg/github/issues.go | 404 +++++++++++++++++++++++++++++++++++++++++++ pkg/github/tools.go | 7 +- 2 files changed, 408 insertions(+), 3 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 1c88a9fde..751b54093 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -225,6 +225,410 @@ func fragmentToIssue(fragment IssueFragment) *github.Issue { } } +func IssuesRead(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("issues_read", + mcp.WithDescription(t("TOOL_ISSUES_DESCRIPTION", `The issues_read tool is to perform READ operations on issues. These are: getting an issue, listing issues, and searching issues. + HOW TO USE THIS TOOL: + First, you MUST decide an "operation" to perform: GET (to get an issue), LIST (to list issues), or SEARCH (to search for issues). + + As to tool arguments, follow this guide: + - for the SEARCH operation, you MUST provide "query" argument and you can OPTIONALLY provide "owner", "repo", "sort", "order" parameters. + - for the LIST operation, you MUST provide "owner" and "repo" arguments and you can OPTIONALLY provide "state", "labels", "orderBy", "direction", "since", "page", "per_page" parameters. + - for the GET operation, you MUST provide "owner", "repo", and "issue_number" arguments. + `)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_ISSUE_TYPES_USER_TITLE", "List available issue types"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("operation", + mcp.Required(), + mcp.Description("The operation to perform. This argument is REQUIRED for all operations. Choose between: GET, LIST, SEARCH."), + mcp.Enum("GET", "LIST", "SEARCH"), + ), + // FOR ALL OPERATIONS + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository. OPTIONAL for SEARCH operation. REQUIRED for LIST and GET operations."), + ), + mcp.WithString("repo", + mcp.Description("The name of the repository. OPTIONAL for SEARCH operation. REQUIRED for LIST and GET operations."), + ), + mcp.WithNumber("issue_number", + mcp.Description("The number of the issue. REQUIRED for GET operation."), + ), + // FOR OPERATION: LIST ISSUES + mcp.WithString("state", + mcp.Description("Filter by state, by default both open and closed issues are returned when not provided. OPTIONAL for LIST operation. Ignore for SEARCH operation and GET operation."), + mcp.Enum("OPEN", "CLOSED"), + ), + mcp.WithArray("labels", + mcp.Description("Filter by labels. OPTIONAL for LIST operation. Ignore for SEARCH operation and GET operation."), + mcp.Items( + map[string]interface{}{ + "type": "string", + }, + ), + ), + mcp.WithString("orderBy", + mcp.Description("Order issues by field. If provided, the 'direction' also needs to be provided. OPTIONAL for LIST operation. IGNORE for SEARCH operation and GET operation."), + mcp.Enum("CREATED_AT", "UPDATED_AT", "COMMENTS"), + ), + mcp.WithString("direction", + mcp.Description("Order direction. If provided, the 'orderBy' also needs to be provided. OPTIONAL for LIST operation. Ignore for SEARCH operation and GET operation."), + mcp.Enum("ASC", "DESC"), + ), + mcp.WithString("since", + mcp.Description("Filter by date (ISO 8601 timestamp). OPTIONAL for LIST operation Ignore for SEARCH operation and GET operation."), + ), + // FOR OPERATION: SEARCH ISSUES + mcp.WithString("query", + mcp.Description("Search query using GitHub issues search syntax. REQUIRED for SEARCH operation. Ignore for LIST operation and GET operation."), + ), + mcp.WithString("sort", + mcp.Description("Sort field by number of matches of categories, defaults to best match. OPTIONAL for SEARCH operation. Ignore for LIST operation and GET operation."), + mcp.Enum( + "comments", + "reactions", + "reactions-+1", + "reactions--1", + "reactions-smile", + "reactions-thinking_face", + "reactions-heart", + "reactions-tada", + "interactions", + "created", + "updated", + ), + ), + mcp.WithString("order", + mcp.Description("Sort order. OPTIONAL for SEARCH operation. Ignore for LIST operation and GET operation."), + mcp.Enum("asc", "desc"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + operation, err := RequiredParam[string](request, "operation") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + operation = strings.ToUpper(operation) + switch operation { + case "GET": + return GetIssueHandler(getClient, ctx, request) + case "LIST": + return ListIssuesHandler(getGQLClient, ctx, request) + case "SEARCH": + return SearchIssuesHandler(getClient, ctx, request) + } + return mcp.NewToolResultError("unknown operation"), nil + } +} + +func GetIssueHandler(getClient GetClientFn, ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber) + if err != nil { + return nil, fmt.Errorf("failed to get issue: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get issue: %s", string(body))), nil + } + + r, err := json.Marshal(issue) + if err != nil { + return nil, fmt.Errorf("failed to marshal issue: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func ListIssuesHandler(getGQLClient GetGQLClientFn, ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Set optional parameters if provided + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // If the state has a value, cast into an array of strings + var states []githubv4.IssueState + if state != "" { + states = append(states, githubv4.IssueState(state)) + } else { + states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed} + } + + // Get labels + labels, err := OptionalStringArrayParam(request, "labels") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + orderBy, err := OptionalParam[string](request, "orderBy") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // These variables are required for the GraphQL query to be set by default + // If orderBy is empty, default to CREATED_AT + if orderBy == "" { + orderBy = "CREATED_AT" + } + // If direction is empty, default to DESC + if direction == "" { + direction = "DESC" + } + + since, err := OptionalParam[string](request, "since") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // There are two optional parameters: since and labels. + var sinceTime time.Time + var hasSince bool + if since != "" { + sinceTime, err = parseISOTimestamp(since) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil + } + hasSince = true + } + hasLabels := len(labels) > 0 + + // Get pagination parameters and convert to GraphQL format + pagination, err := OptionalCursorPaginationParams(request) + if err != nil { + return nil, err + } + + // Check if someone tried to use page-based pagination instead of cursor-based + if _, pageProvided := request.GetArguments()["page"]; pageProvided { + return mcp.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil + } + + // Check if pagination parameters were explicitly provided + _, perPageProvided := request.GetArguments()["perPage"] + paginationExplicit := perPageProvided + + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return 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 mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + } + + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "states": states, + "orderBy": githubv4.IssueOrderField(orderBy), + "direction": githubv4.OrderDirection(direction), + "first": githubv4.Int(*paginationParams.First), + } + + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + // Used within query, therefore must be set to nil and provided as $after + vars["after"] = (*githubv4.String)(nil) + } + + // Ensure optional parameters are set + if hasLabels { + // Use query with labels filtering - convert string labels to githubv4.String slice + labelStrings := make([]githubv4.String, len(labels)) + for i, label := range labels { + labelStrings[i] = githubv4.String(label) + } + vars["labels"] = labelStrings + } + + if hasSince { + vars["since"] = githubv4.DateTime{Time: sinceTime} + } + + issueQuery := getIssueQueryType(hasLabels, hasSince) + if err := client.Query(ctx, issueQuery, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Extract and convert all issue nodes using the common interface + var issues []*github.Issue + var pageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + var totalCount int + + if queryResult, ok := issueQuery.(IssueQueryResult); ok { + fragment := queryResult.GetIssueFragment() + for _, issue := range fragment.Nodes { + issues = append(issues, fragmentToIssue(issue)) + } + pageInfo = fragment.PageInfo + totalCount = fragment.TotalCount + } + + // Create response with issues + response := map[string]interface{}{ + "issues": issues, + "pageInfo": map[string]interface{}{ + "hasNextPage": pageInfo.HasNextPage, + "hasPreviousPage": pageInfo.HasPreviousPage, + "startCursor": string(pageInfo.StartCursor), + "endCursor": string(pageInfo.EndCursor), + }, + "totalCount": totalCount, + } + out, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal issues: %w", err) + } + return mcp.NewToolResultText(string(out)), nil +} + +func SearchIssuesHandler(getClient GetClientFn, ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + title, err := RequiredParam[string](request, "title") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Optional parameters + body, err := OptionalParam[string](request, "body") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get assignees + assignees, err := OptionalStringArrayParam(request, "assignees") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get labels + labels, err := OptionalStringArrayParam(request, "labels") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional milestone + milestone, err := OptionalIntParam(request, "milestone") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var milestoneNum *int + if milestone != 0 { + milestoneNum = &milestone + } + + // Get optional type + issueType, err := OptionalParam[string](request, "type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Create the issue request + issueRequest := &github.IssueRequest{ + Title: github.Ptr(title), + Body: github.Ptr(body), + Assignees: &assignees, + Labels: &labels, + Milestone: milestoneNum, + } + + if issueType != "" { + issueRequest.Type = github.Ptr(issueType) + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest) + if err != nil { + return nil, fmt.Errorf("failed to create issue: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil + } + + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } + + // GetIssue creates a tool to get details of a specific issue in a GitHub repository. func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_issue", diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 7fb5332aa..1c00a2389 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -55,9 +55,10 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG ) issues := toolsets.NewToolset("issues", "GitHub Issues related tools"). AddReadTools( - toolsets.NewServerTool(GetIssue(getClient, t)), - toolsets.NewServerTool(SearchIssues(getClient, t)), - toolsets.NewServerTool(ListIssues(getGQLClient, t)), + toolsets.NewServerTool(IssuesRead(getClient, getGQLClient, t)), + //toolsets.NewServerTool(GetIssue(getClient, t)), + //toolsets.NewServerTool(SearchIssues(getClient, t)), + //toolsets.NewServerTool(ListIssues(getGQLClient, t)), toolsets.NewServerTool(GetIssueComments(getClient, t)), toolsets.NewServerTool(ListIssueTypes(getClient, t)), toolsets.NewServerTool(ListSubIssues(getClient, t)), From b8c3e1138e8c18da42bd5b42b2adddc93a68c78e Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 25 Sep 2025 15:34:36 +0100 Subject: [PATCH 2/2] fixes, linting, autogen readme docs --- README.md | 41 ++-- pkg/github/issues.go | 523 +++++++++++++++++++++---------------------- pkg/github/tools.go | 3 - 3 files changed, 277 insertions(+), 290 deletions(-) diff --git a/README.md b/README.md index 6ed566086..3819f290e 100644 --- a/README.md +++ b/README.md @@ -533,11 +533,6 @@ The following sets of tools are available (all are on by default): - `title`: Issue title (string, required) - `type`: Type of this issue (string, optional) -- **get_issue** - Get issue details - - `issue_number`: The number of the issue (number, required) - - `owner`: The owner of the repository (string, required) - - `repo`: The name of the repository (string, required) - - **get_issue_comments** - Get issue comments - `issue_number`: Issue number (number, required) - `owner`: Repository owner (string, required) @@ -545,20 +540,25 @@ The following sets of tools are available (all are on by default): - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) +- **issues_read** - List available issue types + - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. OPTIONAL for LIST operation. Ignore for SEARCH operation and GET operation. (string, optional) + - `issue_number`: The number of the issue. REQUIRED for GET operation. (number, optional) + - `labels`: Filter by labels. OPTIONAL for LIST operation. Ignore for SEARCH operation and GET operation. (string[], optional) + - `operation`: The operation to perform. This argument is REQUIRED for all operations. Choose between: GET, LIST, SEARCH. (string, required) + - `order`: Sort order. OPTIONAL for SEARCH operation. Ignore for LIST operation and GET operation. (string, optional) + - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. OPTIONAL for LIST operation. IGNORE for SEARCH operation and GET operation. (string, optional) + - `owner`: The owner of the repository. OPTIONAL for SEARCH operation. REQUIRED for LIST and GET operations. (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `query`: Search query using GitHub issues search syntax. REQUIRED for SEARCH operation. Ignore for LIST operation and GET operation. (string, optional) + - `repo`: The name of the repository. OPTIONAL for SEARCH operation. REQUIRED for LIST and GET operations. (string, optional) + - `since`: Filter by date (ISO 8601 timestamp). OPTIONAL for LIST operation Ignore for SEARCH operation and GET operation. (string, optional) + - `sort`: Sort field by number of matches of categories, defaults to best match. OPTIONAL for SEARCH operation. Ignore for LIST operation and GET operation. (string, optional) + - `state`: Filter by state, by default both open and closed issues are returned when not provided. OPTIONAL for LIST operation. Ignore for SEARCH operation and GET operation. (string, optional) + - **list_issue_types** - List available issue types - `owner`: The organization owner of the repository (string, required) -- **list_issues** - List issues - - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - - `labels`: Filter by labels (string[], optional) - - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) - - `owner`: Repository owner (string, required) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `repo`: Repository name (string, required) - - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) - - **list_sub_issues** - List sub-issues - `issue_number`: Issue number (number, required) - `owner`: Repository owner (string, required) @@ -580,15 +580,6 @@ The following sets of tools are available (all are on by default): - `repo`: Repository name (string, required) - `sub_issue_id`: The ID of the sub-issue to reprioritize. ID is not the same as issue number (number, required) -- **search_issues** - Search issues - - `order`: Sort order (string, optional) - - `owner`: Optional repository owner. If provided with repo, only issues for this repository are listed. (string, optional) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `query`: Search query using GitHub issues search syntax (string, required) - - `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional) - - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) - - **update_issue** - Edit issue - `assignees`: New assignees (string[], optional) - `body`: New description (string, optional) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 751b54093..61bda17a9 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -314,320 +314,319 @@ func IssuesRead(getClient GetClientFn, getGQLClient GetGQLClientFn, t translatio operation = strings.ToUpper(operation) switch operation { case "GET": - return GetIssueHandler(getClient, ctx, request) + return GetIssueHandler(ctx, getClient, request) case "LIST": - return ListIssuesHandler(getGQLClient, ctx, request) + return ListIssuesHandler(ctx, getGQLClient, request) case "SEARCH": - return SearchIssuesHandler(getClient, ctx, request) + return SearchIssuesHandler(ctx, getClient, request) } return mcp.NewToolResultError("unknown operation"), nil } } -func GetIssueHandler(getClient GetClientFn, ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func GetIssueHandler(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - issueNumber, err := RequiredInt(request, "issue_number") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber) - if err != nil { - return nil, fmt.Errorf("failed to get issue: %w", err) - } - defer func() { _ = resp.Body.Close() }() + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber) + if err != nil { + return nil, fmt.Errorf("failed to get issue: %w", err) + } + defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to get issue: %s", string(body))), nil - } + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get issue: %s", string(body))), nil + } - r, err := json.Marshal(issue) - if err != nil { - return nil, fmt.Errorf("failed to marshal issue: %w", err) - } + r, err := json.Marshal(issue) + if err != nil { + return nil, fmt.Errorf("failed to marshal issue: %w", err) + } - return mcp.NewToolResultText(string(r)), nil + return mcp.NewToolResultText(string(r)), nil } -func ListIssuesHandler(getGQLClient GetGQLClientFn, ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func ListIssuesHandler(ctx context.Context, getGQLClient GetGQLClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - // Set optional parameters if provided - state, err := OptionalParam[string](request, "state") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + // Set optional parameters if provided + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - // If the state has a value, cast into an array of strings - var states []githubv4.IssueState - if state != "" { - states = append(states, githubv4.IssueState(state)) - } else { - states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed} - } + // If the state has a value, cast into an array of strings + var states []githubv4.IssueState + if state != "" { + states = append(states, githubv4.IssueState(state)) + } else { + states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed} + } - // Get labels - labels, err := OptionalStringArrayParam(request, "labels") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + // Get labels + labels, err := OptionalStringArrayParam(request, "labels") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - orderBy, err := OptionalParam[string](request, "orderBy") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + orderBy, err := OptionalParam[string](request, "orderBy") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - direction, err := OptionalParam[string](request, "direction") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - // These variables are required for the GraphQL query to be set by default - // If orderBy is empty, default to CREATED_AT - if orderBy == "" { - orderBy = "CREATED_AT" - } - // If direction is empty, default to DESC - if direction == "" { - direction = "DESC" - } + // These variables are required for the GraphQL query to be set by default + // If orderBy is empty, default to CREATED_AT + if orderBy == "" { + orderBy = "CREATED_AT" + } + // If direction is empty, default to DESC + if direction == "" { + direction = "DESC" + } - since, err := OptionalParam[string](request, "since") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + since, err := OptionalParam[string](request, "since") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - // There are two optional parameters: since and labels. - var sinceTime time.Time - var hasSince bool - if since != "" { - sinceTime, err = parseISOTimestamp(since) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil - } - hasSince = true - } - hasLabels := len(labels) > 0 + // There are two optional parameters: since and labels. + var sinceTime time.Time + var hasSince bool + if since != "" { + sinceTime, err = parseISOTimestamp(since) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil + } + hasSince = true + } + hasLabels := len(labels) > 0 - // Get pagination parameters and convert to GraphQL format - pagination, err := OptionalCursorPaginationParams(request) - if err != nil { - return nil, err - } + // Get pagination parameters and convert to GraphQL format + pagination, err := OptionalCursorPaginationParams(request) + if err != nil { + return nil, err + } - // Check if someone tried to use page-based pagination instead of cursor-based - if _, pageProvided := request.GetArguments()["page"]; pageProvided { - return mcp.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil - } + // Check if someone tried to use page-based pagination instead of cursor-based + if _, pageProvided := request.GetArguments()["page"]; pageProvided { + return mcp.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil + } - // Check if pagination parameters were explicitly provided - _, perPageProvided := request.GetArguments()["perPage"] - paginationExplicit := perPageProvided + // Check if pagination parameters were explicitly provided + _, perPageProvided := request.GetArguments()["perPage"] + paginationExplicit := perPageProvided - paginationParams, err := pagination.ToGraphQLParams() - if err != nil { - return nil, err - } + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return nil, err + } - // Use default of 30 if pagination was not explicitly provided - if !paginationExplicit { - defaultFirst := int32(DefaultGraphQLPageSize) - paginationParams.First = &defaultFirst - } + // 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 mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil - } + client, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + } - vars := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "states": states, - "orderBy": githubv4.IssueOrderField(orderBy), - "direction": githubv4.OrderDirection(direction), - "first": githubv4.Int(*paginationParams.First), - } + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "states": states, + "orderBy": githubv4.IssueOrderField(orderBy), + "direction": githubv4.OrderDirection(direction), + "first": githubv4.Int(*paginationParams.First), + } - if paginationParams.After != nil { - vars["after"] = githubv4.String(*paginationParams.After) - } else { - // Used within query, therefore must be set to nil and provided as $after - vars["after"] = (*githubv4.String)(nil) - } + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + // Used within query, therefore must be set to nil and provided as $after + vars["after"] = (*githubv4.String)(nil) + } - // Ensure optional parameters are set - if hasLabels { - // Use query with labels filtering - convert string labels to githubv4.String slice - labelStrings := make([]githubv4.String, len(labels)) - for i, label := range labels { - labelStrings[i] = githubv4.String(label) - } - vars["labels"] = labelStrings - } + // Ensure optional parameters are set + if hasLabels { + // Use query with labels filtering - convert string labels to githubv4.String slice + labelStrings := make([]githubv4.String, len(labels)) + for i, label := range labels { + labelStrings[i] = githubv4.String(label) + } + vars["labels"] = labelStrings + } - if hasSince { - vars["since"] = githubv4.DateTime{Time: sinceTime} - } + if hasSince { + vars["since"] = githubv4.DateTime{Time: sinceTime} + } - issueQuery := getIssueQueryType(hasLabels, hasSince) - if err := client.Query(ctx, issueQuery, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + issueQuery := getIssueQueryType(hasLabels, hasSince) + if err := client.Query(ctx, issueQuery, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - // Extract and convert all issue nodes using the common interface - var issues []*github.Issue - var pageInfo struct { - HasNextPage githubv4.Boolean - HasPreviousPage githubv4.Boolean - StartCursor githubv4.String - EndCursor githubv4.String - } - var totalCount int + // Extract and convert all issue nodes using the common interface + var issues []*github.Issue + var pageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + var totalCount int - if queryResult, ok := issueQuery.(IssueQueryResult); ok { - fragment := queryResult.GetIssueFragment() - for _, issue := range fragment.Nodes { - issues = append(issues, fragmentToIssue(issue)) - } - pageInfo = fragment.PageInfo - totalCount = fragment.TotalCount - } + if queryResult, ok := issueQuery.(IssueQueryResult); ok { + fragment := queryResult.GetIssueFragment() + for _, issue := range fragment.Nodes { + issues = append(issues, fragmentToIssue(issue)) + } + pageInfo = fragment.PageInfo + totalCount = fragment.TotalCount + } - // Create response with issues - response := map[string]interface{}{ - "issues": issues, - "pageInfo": map[string]interface{}{ - "hasNextPage": pageInfo.HasNextPage, - "hasPreviousPage": pageInfo.HasPreviousPage, - "startCursor": string(pageInfo.StartCursor), - "endCursor": string(pageInfo.EndCursor), - }, - "totalCount": totalCount, - } - out, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal issues: %w", err) - } - return mcp.NewToolResultText(string(out)), nil + // Create response with issues + response := map[string]interface{}{ + "issues": issues, + "pageInfo": map[string]interface{}{ + "hasNextPage": pageInfo.HasNextPage, + "hasPreviousPage": pageInfo.HasPreviousPage, + "startCursor": string(pageInfo.StartCursor), + "endCursor": string(pageInfo.EndCursor), + }, + "totalCount": totalCount, + } + out, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal issues: %w", err) + } + return mcp.NewToolResultText(string(out)), nil } -func SearchIssuesHandler(getClient GetClientFn, ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - title, err := RequiredParam[string](request, "title") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +func SearchIssuesHandler(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + title, err := RequiredParam[string](request, "title") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - // Optional parameters - body, err := OptionalParam[string](request, "body") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + // Optional parameters + body, err := OptionalParam[string](request, "body") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - // Get assignees - assignees, err := OptionalStringArrayParam(request, "assignees") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + // Get assignees + assignees, err := OptionalStringArrayParam(request, "assignees") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - // Get labels - labels, err := OptionalStringArrayParam(request, "labels") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + // Get labels + labels, err := OptionalStringArrayParam(request, "labels") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - // Get optional milestone - milestone, err := OptionalIntParam(request, "milestone") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + // Get optional milestone + milestone, err := OptionalIntParam(request, "milestone") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - var milestoneNum *int - if milestone != 0 { - milestoneNum = &milestone - } + var milestoneNum *int + if milestone != 0 { + milestoneNum = &milestone + } - // Get optional type - issueType, err := OptionalParam[string](request, "type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + // Get optional type + issueType, err := OptionalParam[string](request, "type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - // Create the issue request - issueRequest := &github.IssueRequest{ - Title: github.Ptr(title), - Body: github.Ptr(body), - Assignees: &assignees, - Labels: &labels, - Milestone: milestoneNum, - } + // Create the issue request + issueRequest := &github.IssueRequest{ + Title: github.Ptr(title), + Body: github.Ptr(body), + Assignees: &assignees, + Labels: &labels, + Milestone: milestoneNum, + } - if issueType != "" { - issueRequest.Type = github.Ptr(issueType) - } + if issueType != "" { + issueRequest.Type = github.Ptr(issueType) + } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest) - if err != nil { - return nil, fmt.Errorf("failed to create issue: %w", err) - } - defer func() { _ = resp.Body.Close() }() + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest) + if err != nil { + return nil, fmt.Errorf("failed to create issue: %w", err) + } + defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusCreated { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil - } + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil + } - // Return minimal response with just essential information - minimalResponse := MinimalResponse{ - ID: fmt.Sprintf("%d", issue.GetID()), - URL: issue.GetHTMLURL(), - } + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.GetHTMLURL(), + } - r, err := json.Marshal(minimalResponse) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + r, err := json.Marshal(minimalResponse) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - return mcp.NewToolResultText(string(r)), nil - } - + return mcp.NewToolResultText(string(r)), nil +} // GetIssue creates a tool to get details of a specific issue in a GitHub repository. func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 1c00a2389..127f5eed7 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -56,9 +56,6 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG issues := toolsets.NewToolset("issues", "GitHub Issues related tools"). AddReadTools( toolsets.NewServerTool(IssuesRead(getClient, getGQLClient, t)), - //toolsets.NewServerTool(GetIssue(getClient, t)), - //toolsets.NewServerTool(SearchIssues(getClient, t)), - //toolsets.NewServerTool(ListIssues(getGQLClient, t)), toolsets.NewServerTool(GetIssueComments(getClient, t)), toolsets.NewServerTool(ListIssueTypes(getClient, t)), toolsets.NewServerTool(ListSubIssues(getClient, t)),