+
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 .
+
pFad - Phonifier reborn
Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy