Skip to content

Add support for Repository Discussions #459

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ The following sets of tools are available (all are on by default):
| ----------------------- | ------------------------------------------------------------- |
| `repos` | Repository-related tools (file operations, branches, commits) |
| `issues` | Issue-related tools (create, read, update, comment) |
| `discussions` | GitHub Discussions tools (list, get, comments, categories) |
| `users` | Anything relating to GitHub Users |
| `pull_requests` | Pull request operations (create, merge, review) |
| `code_security` | Code scanning alerts and security features |
Expand Down Expand Up @@ -614,6 +615,32 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `repo`: The name of the repository (string, required)
- `action`: Action to perform: `ignore`, `watch`, or `delete` (string, required)

### Discussions

- **list_discussions** - List discussions for a repository
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `categoryId`: Filter by category ID (string, optional)
- `since`: Filter by date (ISO 8601 timestamp) (string, optional)
- `first`: Pagination - Number of records to retrieve (number, optional)
- `after`: Pagination - Cursor to start with (string, optional)
- `sort`: Sort by ('CREATED_AT', 'UPDATED_AT') (string, optional)
- `direction`: Sort direction ('ASC', 'DESC') (string, optional)

- **get_discussion** - Get a specific discussion by ID
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `discussionNumber`: Discussion number (required)

- **get_discussion_comments** - Get comments from a discussion
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `discussionNumber`: Discussion number (required)

- **list_discussion_categories** - List discussion categories for a repository, with their IDs and names
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)

## Resources

### Repository Content
Expand Down
333 changes: 333 additions & 0 deletions pkg/github/discussions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
package github

import (
"context"
"encoding/json"
"fmt"
"time"

"github.com/github/github-mcp-server/pkg/translations"
"github.com/go-viper/mapstructure/v2"
"github.com/google/go-github/v69/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/shurcooL/githubv4"
)

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")),
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.Required(),
mcp.Description("Repository name"),
),
mcp.WithString("categoryId",
mcp.Description("Category ID filter"),
),
mcp.WithString("since",
mcp.Description("Filter by date (ISO 8601 timestamp)"),
),
mcp.WithString("sort",
mcp.Description("Sort field"),
mcp.DefaultString("CREATED_AT"),
mcp.Enum("CREATED_AT", "UPDATED_AT"),
),
mcp.WithString("direction",
mcp.Description("Sort direction"),
mcp.DefaultString("DESC"),
mcp.Enum("ASC", "DESC"),
),
mcp.WithNumber("first",
mcp.Description("Number of discussions to return per page (min 1, max 100)"),
mcp.Min(1),
mcp.Max(100),
),
mcp.WithString("after",
mcp.Description("Cursor for pagination, use the 'after' field from the previous response"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Decode params
var params struct {
Owner string
Repo string
CategoryID string
Since string
Sort string
Direction string
First int32
After string
}
if err := mapstructure.Decode(request.Params.Arguments, &params); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
// Get GraphQL client
client, err := getGQLClient(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
}
// Prepare GraphQL query
var q struct {
Repository struct {
Discussions struct {
Nodes []struct {
Number githubv4.Int
Title githubv4.String
CreatedAt githubv4.DateTime
Category struct {
Name githubv4.String
} `graphql:"category"`
URL githubv4.String `graphql:"url"`
}
} `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
// Build query variables
vars := map[string]interface{}{
"owner": githubv4.String(params.Owner),
"repo": githubv4.String(params.Repo),
"categoryId": githubv4.ID(params.CategoryID),
"sort": githubv4.DiscussionOrderField(params.Sort),
"direction": githubv4.OrderDirection(params.Direction),
"first": githubv4.Int(params.First),
"after": githubv4.String(params.After),
}
// Execute query
if err := client.Query(ctx, &q, vars); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
// Map nodes to GitHub Issue objects - there is no discussion type in the GitHub API, so we use Issue to benefit from existing code
var discussions []*github.Issue
for _, n := range q.Repository.Discussions.Nodes {
di := &github.Issue{
Number: github.Ptr(int(n.Number)),
Title: github.Ptr(string(n.Title)),
HTMLURL: github.Ptr(string(n.URL)),
CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time},
}
discussions = append(discussions, di)
}

// Post filtering discussions based on 'since' parameter
if params.Since != "" {
sinceTime, err := time.Parse(time.RFC3339, params.Since)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid 'since' timestamp: %v", err)), nil
}
var filteredDiscussions []*github.Issue
for _, d := range discussions {
if d.CreatedAt.Time.After(sinceTime) {
filteredDiscussions = append(filteredDiscussions, d)
}
}
discussions = filteredDiscussions
}

// Marshal and return
out, err := json.Marshal(discussions)
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
Body githubv4.String
State githubv4.String
CreatedAt githubv4.DateTime
URL githubv4.String `graphql:"url"`
} `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.Issue{
Number: github.Ptr(int(d.Number)),
Body: github.Ptr(string(d.Body)),
State: github.Ptr(string(d.State)),
HTMLURL: github.Ptr(string(d.URL)),
CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time},
}
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")),
),
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 {
Comments struct {
Nodes []struct {
Body githubv4.String
}
} `graphql:"comments(first:100)"`
} `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
}
var comments []*github.IssueComment
for _, c := range q.Repository.Discussion.Comments.Nodes {
comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))})
}

out, err := json.Marshal(comments)
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")),
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.Required(),
mcp.Description("Repository name"),
),
),
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 := requiredParam[string](request, "repo")
if 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 {
DiscussionCategories struct {
Nodes []struct {
ID githubv4.ID
Name githubv4.String
}
} `graphql:"discussionCategories(first: 30)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]interface{}{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
}
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),
})
}
out, err := json.Marshal(categories)
if err != nil {
return nil, fmt.Errorf("failed to marshal discussion categories: %w", err)
}
return mcp.NewToolResultText(string(out)), nil
}
}
Loading