github-mcp-server / pkg /github /discussions.go
Gemini
Initial commit
fce10de
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, &params); 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, &params); 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
}
}