package github import ( "context" "encoding/json" "fmt" "github.com/github/github-mcp-server/pkg/translations" "github.com/go-viper/mapstructure/v2" "github.com/google/go-github/v74/github" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/shurcooL/githubv4" ) const DefaultGraphQLPageSize = 30 // Common interface for all discussion query types type DiscussionQueryResult interface { GetDiscussionFragment() DiscussionFragment } // Implement the interface for all query types func (q *BasicNoOrder) GetDiscussionFragment() DiscussionFragment { return q.Repository.Discussions } func (q *BasicWithOrder) GetDiscussionFragment() DiscussionFragment { return q.Repository.Discussions } func (q *WithCategoryAndOrder) GetDiscussionFragment() DiscussionFragment { return q.Repository.Discussions } func (q *WithCategoryNoOrder) GetDiscussionFragment() DiscussionFragment { return q.Repository.Discussions } type DiscussionFragment struct { Nodes []NodeFragment PageInfo PageInfoFragment TotalCount githubv4.Int } type NodeFragment struct { Number githubv4.Int Title githubv4.String CreatedAt githubv4.DateTime UpdatedAt githubv4.DateTime Author struct { Login githubv4.String } Category struct { Name githubv4.String } `graphql:"category"` URL githubv4.String `graphql:"url"` } type PageInfoFragment struct { HasNextPage bool HasPreviousPage bool StartCursor githubv4.String EndCursor githubv4.String } type BasicNoOrder struct { Repository struct { Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after)"` } `graphql:"repository(owner: $owner, name: $repo)"` } type BasicWithOrder struct { Repository struct { Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection })"` } `graphql:"repository(owner: $owner, name: $repo)"` } type WithCategoryAndOrder struct { Repository struct { Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection })"` } `graphql:"repository(owner: $owner, name: $repo)"` } type WithCategoryNoOrder struct { Repository struct { Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"` } `graphql:"repository(owner: $owner, name: $repo)"` } func fragmentToDiscussion(fragment NodeFragment) *github.Discussion { return &github.Discussion{ Number: github.Ptr(int(fragment.Number)), Title: github.Ptr(string(fragment.Title)), HTMLURL: github.Ptr(string(fragment.URL)), CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, User: &github.User{ Login: github.Ptr(string(fragment.Author.Login)), }, DiscussionCategory: &github.DiscussionCategory{ Name: github.Ptr(string(fragment.Category.Name)), }, } } func getQueryType(useOrdering bool, categoryID *githubv4.ID) any { if categoryID != nil && useOrdering { return &WithCategoryAndOrder{} } if categoryID != nil && !useOrdering { return &WithCategoryNoOrder{} } if categoryID == nil && useOrdering { return &BasicWithOrder{} } return &BasicNoOrder{} } func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_discussions", mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), ), mcp.WithString("repo", mcp.Description("Repository name. If not provided, discussions will be queried at the organisation level."), ), mcp.WithString("category", mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."), ), mcp.WithString("orderBy", mcp.Description("Order discussions by field. If provided, the 'direction' also needs to be provided."), mcp.Enum("CREATED_AT", "UPDATED_AT"), ), mcp.WithString("direction", mcp.Description("Order direction."), mcp.Enum("ASC", "DESC"), ), WithCursorPagination(), ), func(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 := OptionalParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } // when not provided, default to the .github repository // this will query discussions at the organisation level if repo == "" { repo = ".github" } category, err := OptionalParam[string](request, "category") 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 } // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(request) if err != nil { return nil, err } paginationParams, err := pagination.ToGraphQLParams() if err != nil { return nil, err } client, err := getGQLClient(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil } var categoryID *githubv4.ID if category != "" { id := githubv4.ID(category) categoryID = &id } vars := map[string]interface{}{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "first": githubv4.Int(*paginationParams.First), } if paginationParams.After != nil { vars["after"] = githubv4.String(*paginationParams.After) } else { vars["after"] = (*githubv4.String)(nil) } // this is an extra check in case the tool description is misinterpreted, because // we shouldn't use ordering unless both a 'field' and 'direction' are provided useOrdering := orderBy != "" && direction != "" if useOrdering { vars["orderByField"] = githubv4.DiscussionOrderField(orderBy) vars["orderByDirection"] = githubv4.OrderDirection(direction) } if categoryID != nil { vars["categoryId"] = *categoryID } discussionQuery := getQueryType(useOrdering, categoryID) if err := client.Query(ctx, discussionQuery, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil } // Extract and convert all discussion nodes using the common interface var discussions []*github.Discussion var pageInfo PageInfoFragment var totalCount githubv4.Int if queryResult, ok := discussionQuery.(DiscussionQueryResult); ok { fragment := queryResult.GetDiscussionFragment() for _, node := range fragment.Nodes { discussions = append(discussions, fragmentToDiscussion(node)) } pageInfo = fragment.PageInfo totalCount = fragment.TotalCount } // Create response with pagination info response := map[string]interface{}{ "discussions": discussions, "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 discussions: %w", err) } return mcp.NewToolResultText(string(out)), nil } } func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_discussion", mcp.WithDescription(t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), ), mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name"), ), mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number"), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Decode params var params struct { Owner string Repo string DiscussionNumber int32 } if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { return mcp.NewToolResultError(err.Error()), nil } client, err := getGQLClient(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil } var q struct { Repository struct { Discussion struct { Number githubv4.Int Title githubv4.String Body githubv4.String CreatedAt githubv4.DateTime URL githubv4.String `graphql:"url"` Category struct { Name githubv4.String } `graphql:"category"` } `graphql:"discussion(number: $discussionNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ "owner": githubv4.String(params.Owner), "repo": githubv4.String(params.Repo), "discussionNumber": githubv4.Int(params.DiscussionNumber), } if err := client.Query(ctx, &q, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil } d := q.Repository.Discussion discussion := &github.Discussion{ Number: github.Ptr(int(d.Number)), Title: github.Ptr(string(d.Title)), Body: github.Ptr(string(d.Body)), HTMLURL: github.Ptr(string(d.URL)), CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time}, DiscussionCategory: &github.DiscussionCategory{ Name: github.Ptr(string(d.Category.Name)), }, } out, err := json.Marshal(discussion) if err != nil { return nil, fmt.Errorf("failed to marshal discussion: %w", err) } return mcp.NewToolResultText(string(out)), nil } } func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_discussion_comments", mcp.WithDescription(t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")), WithCursorPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Decode params var params struct { Owner string Repo string DiscussionNumber int32 } if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { return mcp.NewToolResultError(err.Error()), nil } // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(request) if err != nil { return nil, err } // 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 } var q struct { Repository struct { Discussion struct { Comments struct { Nodes []struct { Body githubv4.String } PageInfo struct { HasNextPage githubv4.Boolean HasPreviousPage githubv4.Boolean StartCursor githubv4.String EndCursor githubv4.String } TotalCount int } `graphql:"comments(first: $first, after: $after)"` } `graphql:"discussion(number: $discussionNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ "owner": githubv4.String(params.Owner), "repo": githubv4.String(params.Repo), "discussionNumber": githubv4.Int(params.DiscussionNumber), "first": githubv4.Int(*paginationParams.First), } if paginationParams.After != nil { vars["after"] = githubv4.String(*paginationParams.After) } else { vars["after"] = (*githubv4.String)(nil) } if err := client.Query(ctx, &q, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil } var comments []*github.IssueComment for _, c := range q.Repository.Discussion.Comments.Nodes { comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))}) } // Create response with pagination info response := map[string]interface{}{ "comments": comments, "pageInfo": map[string]interface{}{ "hasNextPage": q.Repository.Discussion.Comments.PageInfo.HasNextPage, "hasPreviousPage": q.Repository.Discussion.Comments.PageInfo.HasPreviousPage, "startCursor": string(q.Repository.Discussion.Comments.PageInfo.StartCursor), "endCursor": string(q.Repository.Discussion.Comments.PageInfo.EndCursor), }, "totalCount": q.Repository.Discussion.Comments.TotalCount, } out, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal comments: %w", err) } return mcp.NewToolResultText(string(out)), nil } } func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_discussion_categories", mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), ), mcp.WithString("repo", mcp.Description("Repository name. If not provided, discussion categories will be queried at the organisation level."), ), ), func(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 := OptionalParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } // when not provided, default to the .github repository // this will query discussion categories at the organisation level if repo == "" { repo = ".github" } client, err := getGQLClient(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil } var q struct { Repository struct { DiscussionCategories struct { Nodes []struct { ID githubv4.ID Name githubv4.String } PageInfo struct { HasNextPage githubv4.Boolean HasPreviousPage githubv4.Boolean StartCursor githubv4.String EndCursor githubv4.String } TotalCount int } `graphql:"discussionCategories(first: $first)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "first": githubv4.Int(25), } if err := client.Query(ctx, &q, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil } var categories []map[string]string for _, c := range q.Repository.DiscussionCategories.Nodes { categories = append(categories, map[string]string{ "id": fmt.Sprint(c.ID), "name": string(c.Name), }) } // Create response with pagination info response := map[string]interface{}{ "categories": categories, "pageInfo": map[string]interface{}{ "hasNextPage": q.Repository.DiscussionCategories.PageInfo.HasNextPage, "hasPreviousPage": q.Repository.DiscussionCategories.PageInfo.HasPreviousPage, "startCursor": string(q.Repository.DiscussionCategories.PageInfo.StartCursor), "endCursor": string(q.Repository.DiscussionCategories.PageInfo.EndCursor), }, "totalCount": q.Repository.DiscussionCategories.TotalCount, } out, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal discussion categories: %w", err) } return mcp.NewToolResultText(string(out)), nil } }