package github import ( "context" "encoding/json" "fmt" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" "github.com/google/go-github/v82/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "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 Closed githubv4.Boolean IsAnswered githubv4.Boolean AnswerChosenAt *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(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataDiscussions, mcp.Tool{ Name: "list_discussions", Description: t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"), ReadOnlyHint: true, }, InputSchema: WithCursorPagination(&jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name. If not provided, discussions will be queried at the organisation level.", }, "category": { Type: "string", Description: "Optional filter by discussion category ID. If provided, only discussions with this category are listed.", }, "orderBy": { Type: "string", Description: "Order discussions by field. If provided, the 'direction' also needs to be provided.", Enum: []any{"CREATED_AT", "UPDATED_AT"}, }, "direction": { Type: "string", Description: "Order direction.", Enum: []any{"ASC", "DESC"}, }, }, Required: []string{"owner"}, }), }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *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 := OptionalParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, 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](args, "category") 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 } // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(args) if err != nil { return nil, nil, err } paginationParams, err := pagination.ToGraphQLParams() if err != nil { return nil, nil, err } client, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } var categoryID *githubv4.ID if category != "" { id := githubv4.ID(category) categoryID = &id } vars := map[string]any{ "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 utils.NewToolResultError(err.Error()), nil, 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]any{ "discussions": discussions, "pageInfo": map[string]any{ "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, nil, fmt.Errorf("failed to marshal discussions: %w", err) } return utils.NewToolResultText(string(out)), nil, nil }, ) } func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataDiscussions, mcp.Tool{ Name: "get_discussion", Description: t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "discussionNumber": { Type: "number", Description: "Discussion Number", }, }, Required: []string{"owner", "repo", "discussionNumber"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Decode params var params struct { Owner string Repo string DiscussionNumber int32 } if err := mapstructure.WeakDecode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } var q struct { Repository struct { Discussion struct { Number githubv4.Int Title githubv4.String Body githubv4.String CreatedAt githubv4.DateTime Closed githubv4.Boolean IsAnswered githubv4.Boolean AnswerChosenAt *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]any{ "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 utils.NewToolResultError(err.Error()), nil, nil } d := q.Repository.Discussion // Build response as map to include fields not present in go-github's Discussion struct. // The go-github library's Discussion type lacks isAnswered and answerChosenAt fields, // so we use map[string]interface{} for the response (consistent with other functions // like ListDiscussions and GetDiscussionComments). response := map[string]any{ "number": int(d.Number), "title": string(d.Title), "body": string(d.Body), "url": string(d.URL), "closed": bool(d.Closed), "isAnswered": bool(d.IsAnswered), "createdAt": d.CreatedAt.Time, "category": map[string]any{ "name": string(d.Category.Name), }, } // Add optional timestamp fields if present if d.AnswerChosenAt != nil { response["answerChosenAt"] = d.AnswerChosenAt.Time } out, err := json.Marshal(response) if err != nil { return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) } return utils.NewToolResultText(string(out)), nil, nil }, ) } func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataDiscussions, mcp.Tool{ Name: "get_discussion_comments", Description: t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"), ReadOnlyHint: true, }, InputSchema: WithCursorPagination(&jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "discussionNumber": { Type: "number", Description: "Discussion Number", }, }, Required: []string{"owner", "repo", "discussionNumber"}, }), }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Decode params var params struct { Owner string Repo string DiscussionNumber int32 } if err := mapstructure.WeakDecode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(args) if err != nil { return nil, nil, err } // 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 := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, 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]any{ "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 utils.NewToolResultError(err.Error()), nil, 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]any{ "comments": comments, "pageInfo": map[string]any{ "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, nil, fmt.Errorf("failed to marshal comments: %w", err) } return utils.NewToolResultText(string(out)), nil, nil }, ) } func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataDiscussions, mcp.Tool{ Name: "list_discussion_categories", Description: t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name. If not provided, discussion categories will be queried at the organisation level.", }, }, Required: []string{"owner"}, }, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *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 := OptionalParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // when not provided, default to the .github repository // this will query discussion categories at the organisation level if repo == "" { repo = ".github" } client, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, 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]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "first": githubv4.Int(25), } if err := client.Query(ctx, &q, vars); err != nil { return utils.NewToolResultError(err.Error()), nil, 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]any{ "categories": categories, "pageInfo": map[string]any{ "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, nil, fmt.Errorf("failed to marshal discussion categories: %w", err) } return utils.NewToolResultText(string(out)), nil, nil }, ) }