diff --git a/README.md b/README.md
index 44a829601..b4c326c0e 100644
--- a/README.md
+++ b/README.md
@@ -269,6 +269,7 @@ The following sets of tools are available (all are on by default):
| `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |
| `actions` | GitHub Actions workflows and CI/CD operations |
| `code_security` | Code security related tools, such as GitHub Code Scanning |
+| `discussions` | GitHub Discussions related tools |
| `experiments` | Experimental features that are not considered stable yet |
| `issues` | GitHub Issues related tools |
| `notifications` | GitHub Notifications related tools |
@@ -554,6 +555,35 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
+Discussions
+
+- **get_discussion** - Get discussion
+ - `discussionNumber`: Discussion Number (number, required)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+
+- **get_discussion_comments** - Get discussion comments
+ - `discussionNumber`: Discussion Number (number, required)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+
+- **list_discussion_categories** - List discussion categories
+ - `after`: Cursor for pagination, use the 'after' field from the previous response (string, optional)
+ - `before`: Cursor for pagination, use the 'before' field from the previous response (string, optional)
+ - `first`: Number of categories to return per page (min 1, max 100) (number, optional)
+ - `last`: Number of categories to return from the end (min 1, max 100) (number, optional)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+
+- **list_discussions** - List discussions
+ - `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+
+
+
+
+
Issues
- **add_issue_comment** - Add comment to issue
diff --git a/docs/remote-server.md b/docs/remote-server.md
index 50404ec85..7b5f2c0d4 100644
--- a/docs/remote-server.md
+++ b/docs/remote-server.md
@@ -20,6 +20,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to
| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) |
| Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) |
| Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) |
+| Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) |
| Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) |
| Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) |
| Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) |
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go
new file mode 100644
index 000000000..d61fe969d
--- /dev/null
+++ b/pkg/github/discussions.go
@@ -0,0 +1,441 @@
+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/v72/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/shurcooL/githubv4"
+)
+
+// GetAllDiscussionCategories retrieves all discussion categories for a repository
+// by paginating through all pages and returns them as a map where the key is the
+// category name and the value is the category ID.
+func GetAllDiscussionCategories(ctx context.Context, client *githubv4.Client, owner, repo string) (map[string]string, error) {
+ categories := make(map[string]string)
+ var after string
+ hasNextPage := true
+
+ for hasNextPage {
+ // Prepare GraphQL query with pagination
+ var q struct {
+ Repository struct {
+ DiscussionCategories struct {
+ Nodes []struct {
+ ID githubv4.ID
+ Name githubv4.String
+ }
+ PageInfo struct {
+ HasNextPage githubv4.Boolean
+ EndCursor githubv4.String
+ }
+ } `graphql:"discussionCategories(first: 100, after: $after)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+
+ vars := map[string]interface{}{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "after": githubv4.String(after),
+ }
+
+ if err := client.Query(ctx, &q, vars); err != nil {
+ return nil, fmt.Errorf("failed to query discussion categories: %w", err)
+ }
+
+ // Add categories to the map
+ for _, category := range q.Repository.DiscussionCategories.Nodes {
+ categories[string(category.Name)] = fmt.Sprint(category.ID)
+ }
+
+ // Check if there are more pages
+ hasNextPage = bool(q.Repository.DiscussionCategories.PageInfo.HasNextPage)
+ if hasNextPage {
+ after = string(q.Repository.DiscussionCategories.PageInfo.EndCursor)
+ }
+ }
+
+ return categories, nil
+}
+
+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("category",
+ mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ // Required params
+ 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
+ }
+
+ // Optional params
+ category, err := OptionalParam[string](request, "category")
+ 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
+ }
+
+ // If category filter is specified, use it as the category ID for server-side filtering
+ var categoryID *githubv4.ID
+ if category != "" {
+ id := githubv4.ID(category)
+ categoryID = &id
+ }
+
+ // Now execute the discussions query
+ var discussions []*github.Issue
+ if categoryID != nil {
+ // Query with category filter (server-side filtering)
+ var query 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(first: 100, categoryId: $categoryId)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "categoryId": *categoryID,
+ }
+ if err := client.Query(ctx, &query, vars); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Map nodes to GitHub Issue objects
+ for _, n := range query.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},
+ Labels: []*github.Label{
+ {
+ Name: github.Ptr(fmt.Sprintf("category:%s", string(n.Category.Name))),
+ },
+ },
+ }
+ discussions = append(discussions, di)
+ }
+ } else {
+ // Query without category filter
+ var query 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(first: 100)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ }
+ if err := client.Query(ctx, &query, vars); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Map nodes to GitHub Issue objects
+ for _, n := range query.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},
+ Labels: []*github.Label{
+ {
+ Name: github.Ptr(fmt.Sprintf("category:%s", string(n.Category.Name))),
+ },
+ },
+ }
+ discussions = append(discussions, di)
+ }
+ }
+
+ // 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, ¶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
+ Body githubv4.String
+ State 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.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},
+ Labels: []*github.Label{
+ {
+ Name: github.Ptr(fmt.Sprintf("category:%s", 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")),
+ ),
+ 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 {
+ 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"),
+ ),
+ mcp.WithNumber("first",
+ mcp.Description("Number of categories to return per page (min 1, max 100)"),
+ mcp.Min(1),
+ mcp.Max(100),
+ ),
+ mcp.WithNumber("last",
+ mcp.Description("Number of categories to return from the end (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"),
+ ),
+ mcp.WithString("before",
+ mcp.Description("Cursor for pagination, use the 'before' field from the previous response"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ // Decode params
+ var params struct {
+ Owner string
+ Repo string
+ First int32
+ Last int32
+ After string
+ Before string
+ }
+ if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Validate pagination parameters
+ if params.First != 0 && params.Last != 0 {
+ return mcp.NewToolResultError("only one of 'first' or 'last' may be specified"), nil
+ }
+ if params.After != "" && params.Before != "" {
+ return mcp.NewToolResultError("only one of 'after' or 'before' may be specified"), nil
+ }
+ if params.After != "" && params.Last != 0 {
+ return mcp.NewToolResultError("'after' cannot be used with 'last'. Did you mean to use 'before' instead?"), nil
+ }
+ if params.Before != "" && params.First != 0 {
+ return mcp.NewToolResultError("'before' cannot be used with 'first'. Did you mean to use 'after' instead?"), 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: 100)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String(params.Owner),
+ "repo": githubv4.String(params.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
+ }
+}
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
new file mode 100644
index 000000000..545d604f9
--- /dev/null
+++ b/pkg/github/discussions_test.go
@@ -0,0 +1,400 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/github/github-mcp-server/internal/githubv4mock"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v72/github"
+ "github.com/shurcooL/githubv4"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var (
+ discussionsGeneral = []map[string]any{
+ {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}},
+ {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}},
+ }
+ discussionsAll = []map[string]any{
+ {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}},
+ {"number": 2, "title": "Discussion 2 title", "createdAt": "2023-02-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/2", "category": map[string]any{"name": "Questions"}},
+ {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}},
+ }
+ mockResponseListAll = githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussions": map[string]any{"nodes": discussionsAll},
+ },
+ })
+ mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussions": map[string]any{"nodes": discussionsGeneral},
+ },
+ })
+ mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found")
+)
+
+func Test_ListDiscussions(t *testing.T) {
+ mockClient := githubv4.NewClient(nil)
+ // Verify tool definition and schema
+ toolDef, _ := ListDiscussions(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+ assert.Equal(t, "list_discussions", toolDef.Name)
+ assert.NotEmpty(t, toolDef.Description)
+ assert.Contains(t, toolDef.InputSchema.Properties, "owner")
+ assert.Contains(t, toolDef.InputSchema.Properties, "repo")
+ assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"})
+
+ // mock for the call to ListDiscussions without category filter
+ var qDiscussions 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(first: 100)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+
+ // mock for the call to get discussions with category filter
+ var qDiscussionsFiltered 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(first: 100, categoryId: $categoryId)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+
+ varsListAll := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ }
+
+ varsRepoNotFound := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("nonexistent-repo"),
+ }
+
+ varsDiscussionsFiltered := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "categoryId": githubv4.ID("DIC_kwDOABC123"),
+ }
+
+ tests := []struct {
+ name string
+ reqParams map[string]interface{}
+ expectError bool
+ errContains string
+ expectedCount int
+ }{
+ {
+ name: "list all discussions without category filter",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ expectedCount: 3, // All discussions
+ },
+ {
+ name: "filter by category ID",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "category": "DIC_kwDOABC123",
+ },
+ expectError: false,
+ expectedCount: 2, // Only General discussions (matching the category ID)
+ },
+ {
+ name: "repository not found error",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "nonexistent-repo",
+ },
+ expectError: true,
+ errContains: "repository not found",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ var httpClient *http.Client
+
+ switch tc.name {
+ case "list all discussions without category filter":
+ // Simple case - no category filter
+ matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsListAll, mockResponseListAll)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "filter by category ID":
+ // Simple case - category filter using category ID directly
+ matcher := githubv4mock.NewQueryMatcher(qDiscussionsFiltered, varsDiscussionsFiltered, mockResponseListGeneral)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "repository not found error":
+ matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsRepoNotFound, mockErrorRepoNotFound)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ }
+
+ gqlClient := githubv4.NewClient(httpClient)
+ _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
+
+ req := createMCPRequest(tc.reqParams)
+ res, err := handler(context.Background(), req)
+ text := getTextResult(t, res).Text
+
+ if tc.expectError {
+ require.True(t, res.IsError)
+ assert.Contains(t, text, tc.errContains)
+ return
+ }
+ require.NoError(t, err)
+
+ var returnedDiscussions []*github.Issue
+ err = json.Unmarshal([]byte(text), &returnedDiscussions)
+ require.NoError(t, err)
+
+ assert.Len(t, returnedDiscussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(returnedDiscussions))
+
+ // Verify that all returned discussions have a category label if filtered
+ if _, hasCategory := tc.reqParams["category"]; hasCategory {
+ for _, discussion := range returnedDiscussions {
+ require.NotEmpty(t, discussion.Labels, "Discussion should have category label")
+ assert.True(t, strings.HasPrefix(*discussion.Labels[0].Name, "category:"), "Discussion should have category label prefix")
+ }
+ }
+ })
+ }
+}
+
+func Test_GetDiscussion(t *testing.T) {
+ // Verify tool definition and schema
+ toolDef, _ := GetDiscussion(nil, translations.NullTranslationHelper)
+ assert.Equal(t, "get_discussion", toolDef.Name)
+ assert.NotEmpty(t, toolDef.Description)
+ assert.Contains(t, toolDef.InputSchema.Properties, "owner")
+ assert.Contains(t, toolDef.InputSchema.Properties, "repo")
+ assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber")
+ assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"})
+
+ var q struct {
+ Repository struct {
+ Discussion struct {
+ Number githubv4.Int
+ Body githubv4.String
+ State 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("owner"),
+ "repo": githubv4.String("repo"),
+ "discussionNumber": githubv4.Int(1),
+ }
+ tests := []struct {
+ name string
+ response githubv4mock.GQLResponse
+ expectError bool
+ expected *github.Issue
+ errContains string
+ }{
+ {
+ name: "successful retrieval",
+ response: githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{"discussion": map[string]any{
+ "number": 1,
+ "body": "This is a test discussion",
+ "state": "open",
+ "url": "https://github.com/owner/repo/discussions/1",
+ "createdAt": "2025-04-25T12:00:00Z",
+ "category": map[string]any{"name": "General"},
+ }},
+ }),
+ expectError: false,
+ expected: &github.Issue{
+ HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"),
+ Number: github.Ptr(1),
+ Body: github.Ptr("This is a test discussion"),
+ State: github.Ptr("open"),
+ CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)},
+ Labels: []*github.Label{
+ {
+ Name: github.Ptr("category:General"),
+ },
+ },
+ },
+ },
+ {
+ name: "discussion not found",
+ response: githubv4mock.ErrorResponse("discussion not found"),
+ expectError: true,
+ errContains: "discussion not found",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ matcher := githubv4mock.NewQueryMatcher(q, vars, tc.response)
+ httpClient := githubv4mock.NewMockedHTTPClient(matcher)
+ gqlClient := githubv4.NewClient(httpClient)
+ _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
+
+ req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)})
+ res, err := handler(context.Background(), req)
+ text := getTextResult(t, res).Text
+
+ if tc.expectError {
+ require.True(t, res.IsError)
+ assert.Contains(t, text, tc.errContains)
+ return
+ }
+
+ require.NoError(t, err)
+ var out github.Issue
+ require.NoError(t, json.Unmarshal([]byte(text), &out))
+ assert.Equal(t, *tc.expected.HTMLURL, *out.HTMLURL)
+ assert.Equal(t, *tc.expected.Number, *out.Number)
+ assert.Equal(t, *tc.expected.Body, *out.Body)
+ assert.Equal(t, *tc.expected.State, *out.State)
+ // Check category label
+ require.Len(t, out.Labels, 1)
+ assert.Equal(t, *tc.expected.Labels[0].Name, *out.Labels[0].Name)
+ })
+ }
+}
+
+func Test_GetDiscussionComments(t *testing.T) {
+ // Verify tool definition and schema
+ toolDef, _ := GetDiscussionComments(nil, translations.NullTranslationHelper)
+ assert.Equal(t, "get_discussion_comments", toolDef.Name)
+ assert.NotEmpty(t, toolDef.Description)
+ assert.Contains(t, toolDef.InputSchema.Properties, "owner")
+ assert.Contains(t, toolDef.InputSchema.Properties, "repo")
+ assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber")
+ assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"})
+
+ 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("owner"),
+ "repo": githubv4.String("repo"),
+ "discussionNumber": githubv4.Int(1),
+ }
+ mockResponse := githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussion": map[string]any{
+ "comments": map[string]any{
+ "nodes": []map[string]any{
+ {"body": "This is the first comment"},
+ {"body": "This is the second comment"},
+ },
+ },
+ },
+ },
+ })
+ matcher := githubv4mock.NewQueryMatcher(q, vars, mockResponse)
+ httpClient := githubv4mock.NewMockedHTTPClient(matcher)
+ gqlClient := githubv4.NewClient(httpClient)
+ _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
+
+ request := createMCPRequest(map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "discussionNumber": int32(1),
+ })
+
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+
+ var returnedComments []*github.IssueComment
+ err = json.Unmarshal([]byte(textContent.Text), &returnedComments)
+ require.NoError(t, err)
+ assert.Len(t, returnedComments, 2)
+ expectedBodies := []string{"This is the first comment", "This is the second comment"}
+ for i, comment := range returnedComments {
+ assert.Equal(t, expectedBodies[i], *comment.Body)
+ }
+}
+
+func Test_ListDiscussionCategories(t *testing.T) {
+ var q struct {
+ Repository struct {
+ DiscussionCategories struct {
+ Nodes []struct {
+ ID githubv4.ID
+ Name githubv4.String
+ }
+ } `graphql:"discussionCategories(first: 100)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ }
+ mockResp := githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussionCategories": map[string]any{
+ "nodes": []map[string]any{
+ {"id": "123", "name": "CategoryOne"},
+ {"id": "456", "name": "CategoryTwo"},
+ },
+ },
+ },
+ })
+ matcher := githubv4mock.NewQueryMatcher(q, vars, mockResp)
+ httpClient := githubv4mock.NewMockedHTTPClient(matcher)
+ gqlClient := githubv4.NewClient(httpClient)
+
+ tool, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
+ assert.Equal(t, "list_discussion_categories", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
+
+ request := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo"})
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+
+ text := getTextResult(t, result).Text
+ var categories []map[string]string
+ require.NoError(t, json.Unmarshal([]byte(text), &categories))
+ assert.Len(t, categories, 2)
+ assert.Equal(t, "123", categories[0]["id"])
+ assert.Equal(t, "CategoryOne", categories[0]["name"])
+ assert.Equal(t, "456", categories[1]["id"])
+ assert.Equal(t, "CategoryTwo", categories[1]["name"])
+}
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 76b31d477..9f36cfc3d 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -116,6 +116,14 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(ManageRepositoryNotificationSubscription(getClient, t)),
)
+ discussions := toolsets.NewToolset("discussions", "GitHub Discussions related tools").
+ AddReadTools(
+ toolsets.NewServerTool(ListDiscussions(getGQLClient, t)),
+ toolsets.NewServerTool(GetDiscussion(getGQLClient, t)),
+ toolsets.NewServerTool(GetDiscussionComments(getGQLClient, t)),
+ toolsets.NewServerTool(ListDiscussionCategories(getGQLClient, t)),
+ )
+
actions := toolsets.NewToolset("actions", "GitHub Actions workflows and CI/CD operations").
AddReadTools(
toolsets.NewServerTool(ListWorkflows(getClient, t)),
@@ -156,6 +164,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
tsg.AddToolset(secretProtection)
tsg.AddToolset(notifications)
tsg.AddToolset(experiments)
+ tsg.AddToolset(discussions)
return tsg
}
diff --git a/script/get-discussions b/script/get-discussions
new file mode 100755
index 000000000..3e68abf24
--- /dev/null
+++ b/script/get-discussions
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+# echo '{"jsonrpc":"2.0","id":3,"params":{"name":"list_discussions","arguments": {"owner": "github", "repo": "securitylab", "first": 10, "since": "2025-04-01T00:00:00Z"}},"method":"tools/call"}' | go run cmd/github-mcp-server/main.go stdio | jq .
+echo '{"jsonrpc":"2.0","id":3,"params":{"name":"list_discussions","arguments": {"owner": "github", "repo": "securitylab", "first": 10, "since": "2025-04-01T00:00:00Z", "sort": "CREATED_AT", "direction": "DESC"}},"method":"tools/call"}' | go run cmd/github-mcp-server/main.go stdio | jq .
+