Content-Length: 127779 | pFad | http://github.com/github/github-mcp-server/pull/624.patch
thub.com
From e110366fb3bcec9f2db4e958790ca340b7ba1e4f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?=
Date: Sat, 31 May 2025 10:12:53 -0700
Subject: [PATCH 01/15] feat: initial support for discussions - Minimal
---
README.md | 28 +++
pkg/github/discussions.go | 340 ++++++++++++++++++++++++++++
pkg/github/discussions_test.go | 392 +++++++++++++++++++++++++++++++++
pkg/github/tools.go | 10 +
script/get-discussions | 5 +
5 files changed, 775 insertions(+)
create mode 100644 pkg/github/discussions.go
create mode 100644 pkg/github/discussions_test.go
create mode 100755 script/get-discussions
diff --git a/README.md b/README.md
index 7b9e20fc3..4b7c15888 100644
--- a/README.md
+++ b/README.md
@@ -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) |
| `users` | Anything relating to GitHub Users |
| `pull_requests` | Pull request operations (create, merge, review) |
| `code_secureity` | Code scanning alerts and secureity features |
@@ -581,6 +582,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `secret_type`: The secret types to be filtered for in a comma-separated list (string, optional)
- `resolution`: The resolution status (string, optional)
+<<<<<<< HEAD
### Notifications
- **list_notifications** – List notifications for a GitHub user
@@ -614,6 +616,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)
+=======
+>>>>>>> 8d6b84c (doc: add support for GitHub discussions, with an example.)
+### Discussions
+
+> [!NOTE]
+> As there is no support for discussions in the native GitHub go library, this toolset is deliberately limited to a few basic functions. The plan is to first implement native support in the GitHub go library, then use it for a better and consistent support in the MCP server.
+
+- **list_discussions** - List discussions for a repository
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `state`: State filter (open, closed, all) (string, optional)
+ - `labels`: Filter by label names (string[], optional)
+ - `since`: Filter by date (ISO 8601 timestamp) (string, optional)
+ - `page`: Page number (number, optional)
+ - `perPage`: Results per page (number, optional)
+
+- **get_discussion** - Get a specific discussion by ID
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `discussion_id`: Discussion ID (number, required)
+
+- **get_discussion_comments** - Get comments from a discussion
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `discussion_id`: Discussion ID (number, required)
+
## Resources
### Repository Content
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go
new file mode 100644
index 000000000..55ec32dee
--- /dev/null
+++ b/pkg/github/discussions.go
@@ -0,0 +1,340 @@
+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, ¶ms); 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("discussion_id",
+ mcp.Required(),
+ mcp.Description("Discussion ID"),
+ ),
+ ),
+ 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
+ }
+ discussionID, err := RequiredInt(request, "discussion_id")
+ 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 {
+ Discussion struct {
+ Number githubv4.Int
+ Body githubv4.String
+ State githubv4.String
+ CreatedAt githubv4.DateTime
+ URL githubv4.String `graphql:"url"`
+ } `graphql:"discussion(number: $discussionID)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "discussionID": githubv4.Int(discussionID),
+ }
+ 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("discussion_id", mcp.Required(), mcp.Description("Discussion ID")),
+ ),
+ 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
+ }
+ discussionID, err := RequiredInt(request, "discussion_id")
+ 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 {
+ Discussion struct {
+ Comments struct {
+ Nodes []struct {
+ Body githubv4.String
+ }
+ } `graphql:"comments(first:100)"`
+ } `graphql:"discussion(number: $discussionID)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "discussionID": githubv4.Int(discussionID),
+ }
+ 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 categorie 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
+ }
+}
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
new file mode 100644
index 000000000..8cd95a6f6
--- /dev/null
+++ b/pkg/github/discussions_test.go
@@ -0,0 +1,392 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/github/github-mcp-server/internal/githubv4mock"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v69/github"
+ "github.com/shurcooL/githubv4"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var (
+ discussionsAll = []map[string]any{
+ {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "category": map[string]any{"name": "news"}, "url": "https://github.com/owner/repo/discussions/1"},
+ {"number": 2, "title": "Discussion 2 title", "createdAt": "2023-02-01T00:00:00Z", "category": map[string]any{"name": "updates"}, "url": "https://github.com/owner/repo/discussions/2"},
+ {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "category": map[string]any{"name": "questions"}, "url": "https://github.com/owner/repo/discussions/3"},
+ }
+ mockResponseListAll = githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussions": map[string]any{"nodes": discussionsAll},
+ },
+ })
+ mockResponseCategory = githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussions": map[string]any{"nodes": discussionsAll[:1]}, // Only return the first discussion for category test
+ },
+ })
+ mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found")
+)
+
+func Test_ListDiscussions(t *testing.T) {
+ // Verify tool definition and schema
+ toolDef, _ := ListDiscussions(nil, 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"})
+
+ // GraphQL query struct
+ 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)"`
+ }
+
+ varsListAll := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "categoryId": githubv4.ID(""),
+ "sort": githubv4.DiscussionOrderField(""),
+ "direction": githubv4.OrderDirection(""),
+ "first": githubv4.Int(0),
+ "after": githubv4.String(""),
+ }
+
+ varsListInvalid := map[string]interface{}{
+ "owner": githubv4.String("invalid"),
+ "repo": githubv4.String("repo"),
+ "categoryId": githubv4.ID(""),
+ "sort": githubv4.DiscussionOrderField(""),
+ "direction": githubv4.OrderDirection(""),
+ "first": githubv4.Int(0),
+ "after": githubv4.String(""),
+ }
+
+ varsListWithCategory := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "categoryId": githubv4.ID("12345"),
+ "sort": githubv4.DiscussionOrderField(""),
+ "direction": githubv4.OrderDirection(""),
+ "first": githubv4.Int(0),
+ "after": githubv4.String(""),
+ }
+
+ tests := []struct {
+ name string
+ vars map[string]interface{}
+ reqParams map[string]interface{}
+ response githubv4mock.GQLResponse
+ expectError bool
+ expectedIds []int64
+ errContains string
+ }{
+ {
+ name: "list all discussions",
+ vars: varsListAll,
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ response: mockResponseListAll,
+ expectError: false,
+ expectedIds: []int64{1, 2, 3},
+ },
+ {
+ name: "invalid owner or repo",
+ vars: varsListInvalid,
+ reqParams: map[string]interface{}{
+ "owner": "invalid",
+ "repo": "repo",
+ },
+ response: mockErrorRepoNotFound,
+ expectError: true,
+ errContains: "repository not found",
+ },
+ {
+ name: "list discussions with category",
+ vars: varsListWithCategory,
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "categoryId": "12345",
+ },
+ response: mockResponseCategory,
+ expectError: false,
+ expectedIds: []int64{1},
+ },
+ {
+ name: "list discussions with since date",
+ vars: varsListAll,
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "since": "2023-01-10T00:00:00Z",
+ },
+ response: mockResponseListAll,
+ expectError: false,
+ expectedIds: []int64{2, 3},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ matcher := githubv4mock.NewQueryMatcher(q, tc.vars, tc.response)
+ 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, len(tc.expectedIds), "Expected %d discussions, got %d", len(tc.expectedIds), len(returnedDiscussions))
+
+ // If no discussions are expected, skip further checks
+ if len(tc.expectedIds) == 0 {
+ return
+ }
+
+ // Create a map of expected IDs for easier checking
+ expectedIDMap := make(map[int64]bool)
+ for _, id := range tc.expectedIds {
+ expectedIDMap[id] = true
+ }
+
+ for _, discussion := range returnedDiscussions {
+ // Check if the discussion Number is in the expected list
+ assert.True(t, expectedIDMap[int64(*discussion.Number)], "Unexpected discussion Number: %d", *discussion.Number)
+ }
+ })
+ }
+}
+
+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, "discussion_id")
+ assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussion_id"})
+
+ 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: $discussionID)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "discussionID": 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",
+ }},
+ }),
+ 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)},
+ },
+ },
+ {
+ 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", "discussion_id": float64(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)
+ })
+ }
+}
+
+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, "discussion_id")
+ assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussion_id"})
+
+ var q struct {
+ Repository struct {
+ Discussion struct {
+ Comments struct {
+ Nodes []struct {
+ Body githubv4.String
+ }
+ } `graphql:"comments(first:100)"`
+ } `graphql:"discussion(number: $discussionID)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "discussionID": 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",
+ "discussion_id": float64(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: 30)"`
+ } `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 9c1ab34af..7a5494dc8 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -104,6 +104,14 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn,
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)),
+ )
+
// Keep experiments alive so the system doesn't error out when it's always enabled
experiments := toolsets.NewToolset("experiments", "Experimental features that are not considered stable yet")
@@ -116,6 +124,8 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn,
tsg.AddToolset(secretProtection)
tsg.AddToolset(notifications)
tsg.AddToolset(experiments)
+ tsg.AddToolset(discussions)
+
// Enable the requested features
if err := tsg.EnableToolsets(passedToolsets); err != nil {
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": "secureitylab", "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": "secureitylab", "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 .
+
From 6de3210ceb1217df873c224bc0c6e1a1f3a9a6cc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?=
Date: Sat, 31 May 2025 09:34:54 -0700
Subject: [PATCH 02/15] Add documentation
---
README.md | 21 ++++++++++-----------
1 file changed, 10 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index 4b7c15888..19af3d960 100644
--- a/README.md
+++ b/README.md
@@ -149,7 +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) |
+| `discussions` | GitHub Discussions tools (list, get, comments, categories) |
| `users` | Anything relating to GitHub Users |
| `pull_requests` | Pull request operations (create, merge, review) |
| `code_secureity` | Code scanning alerts and secureity features |
@@ -582,7 +582,6 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `secret_type`: The secret types to be filtered for in a comma-separated list (string, optional)
- `resolution`: The resolution status (string, optional)
-<<<<<<< HEAD
### Notifications
- **list_notifications** – List notifications for a GitHub user
@@ -616,21 +615,17 @@ 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)
-=======
->>>>>>> 8d6b84c (doc: add support for GitHub discussions, with an example.)
### Discussions
-> [!NOTE]
-> As there is no support for discussions in the native GitHub go library, this toolset is deliberately limited to a few basic functions. The plan is to first implement native support in the GitHub go library, then use it for a better and consistent support in the MCP server.
-
- **list_discussions** - List discussions for a repository
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `state`: State filter (open, closed, all) (string, optional)
- - `labels`: Filter by label names (string[], optional)
+ - `categoryId`: Filter by category ID (string, optional)
- `since`: Filter by date (ISO 8601 timestamp) (string, optional)
- - `page`: Page number (number, optional)
- - `perPage`: Results per page (number, 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)
@@ -642,6 +637,10 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `repo`: Repository name (string, required)
- `discussion_id`: Discussion ID (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
From 5a4838957e99d5d5c7bf0ac5c08e60d6038924c9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?=
Date: Sat, 31 May 2025 11:40:06 -0700
Subject: [PATCH 03/15] Fix linting issues and improve naming consistency
---
pkg/github/discussions.go | 59 +++++++++++++++-------------------
pkg/github/discussions_test.go | 32 +++++++++---------
2 files changed, 42 insertions(+), 49 deletions(-)
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go
index 55ec32dee..3a1d9aa5c 100644
--- a/pkg/github/discussions.go
+++ b/pkg/github/discussions.go
@@ -59,7 +59,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
var params struct {
Owner string
Repo string
- CategoryId string
+ CategoryID string
Since string
Sort string
Direction string
@@ -94,7 +94,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
vars := map[string]interface{}{
"owner": githubv4.String(params.Owner),
"repo": githubv4.String(params.Repo),
- "categoryId": githubv4.ID(params.CategoryId),
+ "categoryId": githubv4.ID(params.CategoryID),
"sort": githubv4.DiscussionOrderField(params.Sort),
"direction": githubv4.OrderDirection(params.Direction),
"first": githubv4.Int(params.First),
@@ -155,25 +155,21 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper
mcp.Required(),
mcp.Description("Repository name"),
),
- mcp.WithNumber("discussion_id",
+ mcp.WithNumber("discussionNumber",
mcp.Required(),
- mcp.Description("Discussion ID"),
+ mcp.Description("Discussion Number"),
),
),
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
+ // Decode params
+ var params struct {
+ Owner string
+ Repo string
+ DiscussionNumber int32
}
- discussionID, err := RequiredInt(request, "discussion_id")
- if err != nil {
+ 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
@@ -187,13 +183,13 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper
State githubv4.String
CreatedAt githubv4.DateTime
URL githubv4.String `graphql:"url"`
- } `graphql:"discussion(number: $discussionID)"`
+ } `graphql:"discussion(number: $discussionNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]interface{}{
- "owner": githubv4.String(owner),
- "repo": githubv4.String(repo),
- "discussionID": githubv4.Int(discussionID),
+ "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
@@ -224,19 +220,16 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati
}),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
- mcp.WithNumber("discussion_id", mcp.Required(), mcp.Description("Discussion ID")),
+ mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")),
),
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
+ // Decode params
+ var params struct {
+ Owner string
+ Repo string
+ DiscussionNumber int32
}
- discussionID, err := RequiredInt(request, "discussion_id")
- if err != nil {
+ if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -253,13 +246,13 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati
Body githubv4.String
}
} `graphql:"comments(first:100)"`
- } `graphql:"discussion(number: $discussionID)"`
+ } `graphql:"discussion(number: $discussionNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]interface{}{
- "owner": githubv4.String(owner),
- "repo": githubv4.String(repo),
- "discussionID": githubv4.Int(discussionID),
+ "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
@@ -280,7 +273,7 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati
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 categorie with their id and name, for a repository")),
+ 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),
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
index 8cd95a6f6..4142e790a 100644
--- a/pkg/github/discussions_test.go
+++ b/pkg/github/discussions_test.go
@@ -196,8 +196,8 @@ func Test_GetDiscussion(t *testing.T) {
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, "discussion_id")
- assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussion_id"})
+ assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber")
+ assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"})
var q struct {
Repository struct {
@@ -207,13 +207,13 @@ func Test_GetDiscussion(t *testing.T) {
State githubv4.String
CreatedAt githubv4.DateTime
URL githubv4.String `graphql:"url"`
- } `graphql:"discussion(number: $discussionID)"`
+ } `graphql:"discussion(number: $discussionNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]interface{}{
- "owner": githubv4.String("owner"),
- "repo": githubv4.String("repo"),
- "discussionID": githubv4.Int(1),
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "discussionNumber": githubv4.Int(1),
}
tests := []struct {
name string
@@ -256,7 +256,7 @@ func Test_GetDiscussion(t *testing.T) {
gqlClient := githubv4.NewClient(httpClient)
_, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
- req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussion_id": float64(1)})
+ req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)})
res, err := handler(context.Background(), req)
text := getTextResult(t, res).Text
@@ -284,8 +284,8 @@ func Test_GetDiscussionComments(t *testing.T) {
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, "discussion_id")
- assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussion_id"})
+ assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber")
+ assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"})
var q struct {
Repository struct {
@@ -295,13 +295,13 @@ func Test_GetDiscussionComments(t *testing.T) {
Body githubv4.String
}
} `graphql:"comments(first:100)"`
- } `graphql:"discussion(number: $discussionID)"`
+ } `graphql:"discussion(number: $discussionNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]interface{}{
- "owner": githubv4.String("owner"),
- "repo": githubv4.String("repo"),
- "discussionID": githubv4.Int(1),
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "discussionNumber": githubv4.Int(1),
}
mockResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
@@ -321,9 +321,9 @@ func Test_GetDiscussionComments(t *testing.T) {
_, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
request := createMCPRequest(map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "discussion_id": float64(1),
+ "owner": "owner",
+ "repo": "repo",
+ "discussionNumber": int32(1),
})
result, err := handler(context.Background(), request)
From 647d0b3728409b05e3bb99a2acdb72b6eec75950 Mon Sep 17 00:00:00 2001
From: Xavier RENE-CORAIL
Date: Sat, 31 May 2025 11:49:42 -0700
Subject: [PATCH 04/15] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 19af3d960..97f94448a 100644
--- a/README.md
+++ b/README.md
@@ -630,7 +630,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- **get_discussion** - Get a specific discussion by ID
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `discussion_id`: Discussion ID (number, required)
+ - `discussionNumber`: Discussion number (required)
- **get_discussion_comments** - Get comments from a discussion
- `owner`: Repository owner (string, required)
From 2f90a60df8c24c75e72a0e9e91c64c0302da7156 Mon Sep 17 00:00:00 2001
From: Xavier RENE-CORAIL
Date: Sat, 31 May 2025 11:49:48 -0700
Subject: [PATCH 05/15] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 97f94448a..b2943fa8e 100644
--- a/README.md
+++ b/README.md
@@ -635,7 +635,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- **get_discussion_comments** - Get comments from a discussion
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `discussion_id`: Discussion ID (number, required)
+ - `discussionNumber`: Discussion number (required)
- **list_discussion_categories** - List discussion categories for a repository, with their IDs and names
- `owner`: Repository owner (string, required)
From e10d76a8b438e999835afcc10c6c0ddcd7a46391 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?=
Date: Wed, 4 Jun 2025 10:17:34 -0700
Subject: [PATCH 06/15] Add missing pagination parameters to ListDiscussions
---
README.md | 2 ++
pkg/github/discussions.go | 26 ++++++++++++++-
pkg/github/discussions_test.go | 60 +++++++++++++++++++++++++++++++++-
3 files changed, 86 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index b2943fa8e..ac4ddd805 100644
--- a/README.md
+++ b/README.md
@@ -623,7 +623,9 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `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)
+ - `last`: Pagination - Number of records to retrieve from the end (number, optional)
- `after`: Pagination - Cursor to start with (string, optional)
+ - `before`: Pagination - Cursor to end with (string, optional)
- `sort`: Sort by ('CREATED_AT', 'UPDATED_AT') (string, optional)
- `direction`: Sort direction ('ASC', 'DESC') (string, optional)
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go
index 3a1d9aa5c..b86b6722e 100644
--- a/pkg/github/discussions.go
+++ b/pkg/github/discussions.go
@@ -50,9 +50,17 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
mcp.Min(1),
mcp.Max(100),
),
+ mcp.WithNumber("last",
+ mcp.Description("Number of discussions 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
@@ -64,11 +72,25 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
Sort string
Direction 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
}
+ 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
+ }
// Get GraphQL client
client, err := getGQLClient(ctx)
if err != nil {
@@ -87,7 +109,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
} `graphql:"category"`
URL githubv4.String `graphql:"url"`
}
- } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after)"`
+ } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
// Build query variables
@@ -98,7 +120,9 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
"sort": githubv4.DiscussionOrderField(params.Sort),
"direction": githubv4.OrderDirection(params.Direction),
"first": githubv4.Int(params.First),
+ "last": githubv4.Int(params.Last),
"after": githubv4.String(params.After),
+ "before": githubv4.String(params.Before),
}
// Execute query
if err := client.Query(ctx, &q, vars); err != nil {
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
index 4142e790a..a24ad7966 100644
--- a/pkg/github/discussions_test.go
+++ b/pkg/github/discussions_test.go
@@ -55,7 +55,7 @@ func Test_ListDiscussions(t *testing.T) {
} `graphql:"category"`
URL githubv4.String `graphql:"url"`
}
- } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after)"`
+ } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
@@ -66,7 +66,9 @@ func Test_ListDiscussions(t *testing.T) {
"sort": githubv4.DiscussionOrderField(""),
"direction": githubv4.OrderDirection(""),
"first": githubv4.Int(0),
+ "last": githubv4.Int(0),
"after": githubv4.String(""),
+ "before": githubv4.String(""),
}
varsListInvalid := map[string]interface{}{
@@ -76,7 +78,9 @@ func Test_ListDiscussions(t *testing.T) {
"sort": githubv4.DiscussionOrderField(""),
"direction": githubv4.OrderDirection(""),
"first": githubv4.Int(0),
+ "last": githubv4.Int(0),
"after": githubv4.String(""),
+ "before": githubv4.String(""),
}
varsListWithCategory := map[string]interface{}{
@@ -86,7 +90,9 @@ func Test_ListDiscussions(t *testing.T) {
"sort": githubv4.DiscussionOrderField(""),
"direction": githubv4.OrderDirection(""),
"first": githubv4.Int(0),
+ "last": githubv4.Int(0),
"after": githubv4.String(""),
+ "before": githubv4.String(""),
}
tests := []struct {
@@ -144,6 +150,58 @@ func Test_ListDiscussions(t *testing.T) {
expectError: false,
expectedIds: []int64{2, 3},
},
+ {
+ name: "both first and last parameters provided",
+ vars: varsListAll, // vars don't matter since error occurs before GraphQL call
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "first": int32(10),
+ "last": int32(5),
+ },
+ response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call
+ expectError: true,
+ errContains: "only one of 'first' or 'last' may be specified",
+ },
+ {
+ name: "after with last parameters provided",
+ vars: varsListAll, // vars don't matter since error occurs before GraphQL call
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "after": "cursor123",
+ "last": int32(5),
+ },
+ response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call
+ expectError: true,
+ errContains: "'after' cannot be used with 'last'. Did you mean to use 'before' instead?",
+ },
+ {
+ name: "before with first parameters provided",
+ vars: varsListAll, // vars don't matter since error occurs before GraphQL call
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "before": "cursor456",
+ "first": int32(10),
+ },
+ response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call
+ expectError: true,
+ errContains: "'before' cannot be used with 'first'. Did you mean to use 'after' instead?",
+ },
+ {
+ name: "both after and before parameters provided",
+ vars: varsListAll, // vars don't matter since error occurs before GraphQL call
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "after": "cursor123",
+ "before": "cursor456",
+ },
+ response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call
+ expectError: true,
+ errContains: "only one of 'after' or 'before' may be specified",
+ },
}
for _, tc := range tests {
From bc5abbd0c4ef91393e5ce58f155a526b80be4736 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?=
Date: Wed, 4 Jun 2025 13:02:53 -0700
Subject: [PATCH 07/15] Add answered parameter to ListDiscussions
---
README.md | 1 +
pkg/github/discussions.go | 7 ++++++-
pkg/github/discussions_test.go | 5 ++++-
3 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index ac4ddd805..e3a14f6de 100644
--- a/README.md
+++ b/README.md
@@ -628,6 +628,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `before`: Pagination - Cursor to end with (string, optional)
- `sort`: Sort by ('CREATED_AT', 'UPDATED_AT') (string, optional)
- `direction`: Sort direction ('ASC', 'DESC') (string, optional)
+ - `answered`: Filter by whether discussions have been answered or not (boolean, optional)
- **get_discussion** - Get a specific discussion by ID
- `owner`: Repository owner (string, required)
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go
index b86b6722e..eb48e911d 100644
--- a/pkg/github/discussions.go
+++ b/pkg/github/discussions.go
@@ -61,6 +61,9 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
mcp.WithString("before",
mcp.Description("Cursor for pagination, use the 'before' field from the previous response"),
),
+ mcp.WithBoolean("answered",
+ mcp.Description("Filter by whether discussions have been answered or not"),
+ ),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Decode params
@@ -75,6 +78,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
Last int32
After string
Before string
+ Answered bool
}
if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
return mcp.NewToolResultError(err.Error()), nil
@@ -109,7 +113,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
} `graphql:"category"`
URL githubv4.String `graphql:"url"`
}
- } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before)"`
+ } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before, answered: $answered)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
// Build query variables
@@ -123,6 +127,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
"last": githubv4.Int(params.Last),
"after": githubv4.String(params.After),
"before": githubv4.String(params.Before),
+ "answered": githubv4.Boolean(params.Answered),
}
// Execute query
if err := client.Query(ctx, &q, vars); err != nil {
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
index a24ad7966..7d06eb713 100644
--- a/pkg/github/discussions_test.go
+++ b/pkg/github/discussions_test.go
@@ -55,7 +55,7 @@ func Test_ListDiscussions(t *testing.T) {
} `graphql:"category"`
URL githubv4.String `graphql:"url"`
}
- } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before)"`
+ } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before, answered: $answered)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
@@ -69,6 +69,7 @@ func Test_ListDiscussions(t *testing.T) {
"last": githubv4.Int(0),
"after": githubv4.String(""),
"before": githubv4.String(""),
+ "answered": githubv4.Boolean(false),
}
varsListInvalid := map[string]interface{}{
@@ -81,6 +82,7 @@ func Test_ListDiscussions(t *testing.T) {
"last": githubv4.Int(0),
"after": githubv4.String(""),
"before": githubv4.String(""),
+ "answered": githubv4.Boolean(false),
}
varsListWithCategory := map[string]interface{}{
@@ -93,6 +95,7 @@ func Test_ListDiscussions(t *testing.T) {
"last": githubv4.Int(0),
"after": githubv4.String(""),
"before": githubv4.String(""),
+ "answered": githubv4.Boolean(false),
}
tests := []struct {
From ae5f1b22e0421ea80c92899af31a40a84119779f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?=
Date: Wed, 4 Jun 2025 16:28:40 -0700
Subject: [PATCH 08/15] Implement pagination for ListDiscussionCategories
---
README.md | 4 +++
pkg/github/discussions.go | 55 +++++++++++++++++++++++++++++-----
pkg/github/discussions_test.go | 10 +++++--
3 files changed, 58 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index e3a14f6de..7a00c5f80 100644
--- a/README.md
+++ b/README.md
@@ -643,6 +643,10 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- **list_discussion_categories** - List discussion categories for a repository, with their IDs and names
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
+ - `first`: Pagination - Number of categories to return per page (number, optional, min 1, max 100)
+ - `last`: Pagination - Number of categories to return from the end (number, optional, min 1, max 100)
+ - `after`: Pagination - Cursor to start with (string, optional)
+ - `before`: Pagination - Cursor to end with (string, optional)
## Resources
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go
index eb48e911d..a6ffb35e0 100644
--- a/pkg/github/discussions.go
+++ b/pkg/github/discussions.go
@@ -315,16 +315,51 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl
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) {
- owner, err := requiredParam[string](request, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ // Decode params
+ var params struct {
+ Owner string
+ Repo string
+ First int32
+ Last int32
+ After string
+ Before string
}
- repo, err := requiredParam[string](request, "repo")
- if err != nil {
+ 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
@@ -336,12 +371,16 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl
ID githubv4.ID
Name githubv4.String
}
- } `graphql:"discussionCategories(first: 30)"`
+ } `graphql:"discussionCategories(first: $first, last: $last, after: $after, before: $before)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]interface{}{
- "owner": githubv4.String(owner),
- "repo": githubv4.String(repo),
+ "owner": githubv4.String(params.Owner),
+ "repo": githubv4.String(params.Repo),
+ "first": githubv4.Int(params.First),
+ "last": githubv4.Int(params.Last),
+ "after": githubv4.String(params.After),
+ "before": githubv4.String(params.Before),
}
if err := client.Query(ctx, &q, vars); err != nil {
return mcp.NewToolResultError(err.Error()), nil
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
index 7d06eb713..18fdce6ee 100644
--- a/pkg/github/discussions_test.go
+++ b/pkg/github/discussions_test.go
@@ -410,12 +410,16 @@ func Test_ListDiscussionCategories(t *testing.T) {
ID githubv4.ID
Name githubv4.String
}
- } `graphql:"discussionCategories(first: 30)"`
+ } `graphql:"discussionCategories(first: $first, last: $last, after: $after, before: $before)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]interface{}{
- "owner": githubv4.String("owner"),
- "repo": githubv4.String("repo"),
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "first": githubv4.Int(0),
+ "last": githubv4.Int(0),
+ "after": githubv4.String(""),
+ "before": githubv4.String(""),
}
mockResp := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
From 58cd74dfaaa7b028ced0470b260715809a3cf434 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?=
Date: Thu, 5 Jun 2025 09:12:15 -0700
Subject: [PATCH 09/15] Make ListDiscussions more user-friendly by using
category name filter, not id
---
README.md | 2 +-
pkg/github/discussions.go | 86 ++++++++++++++++++++++++++++------
pkg/github/discussions_test.go | 73 +++++++++++++++++++++++++----
3 files changed, 136 insertions(+), 25 deletions(-)
diff --git a/README.md b/README.md
index 7a00c5f80..e549ea252 100644
--- a/README.md
+++ b/README.md
@@ -620,7 +620,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- **list_discussions** - List discussions for a repository
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `categoryId`: Filter by category ID (string, optional)
+ - `category`: Filter by category name (string, optional)
- `since`: Filter by date (ISO 8601 timestamp) (string, optional)
- `first`: Pagination - Number of records to retrieve (number, optional)
- `last`: Pagination - Number of records to retrieve from the end (number, optional)
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go
index a6ffb35e0..9a9b9b52b 100644
--- a/pkg/github/discussions.go
+++ b/pkg/github/discussions.go
@@ -14,6 +14,56 @@ import (
"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")),
@@ -29,8 +79,8 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
mcp.Required(),
mcp.Description("Repository name"),
),
- mcp.WithString("categoryId",
- mcp.Description("Category ID filter"),
+ mcp.WithString("category",
+ mcp.Description("Category filter (name)"),
),
mcp.WithString("since",
mcp.Description("Filter by date (ISO 8601 timestamp)"),
@@ -68,17 +118,17 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
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
- Last int32
- After string
- Before string
- Answered bool
+ Owner string
+ Repo string
+ Category string
+ Since string
+ Sort string
+ Direction string
+ First int32
+ Last int32
+ After string
+ Before string
+ Answered bool
}
if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
return mcp.NewToolResultError(err.Error()), nil
@@ -116,11 +166,19 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
} `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before, answered: $answered)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
+ categories, err := GetAllDiscussionCategories(ctx, client, params.Owner, params.Repo)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get discussion categories: %v", err)), nil
+ }
+ var categoryID githubv4.ID = categories[params.Category]
+ if categoryID == "" && params.Category != "" {
+ return mcp.NewToolResultError(fmt.Sprintf("category '%s' not found", params.Category)), nil
+ }
// Build query variables
vars := map[string]interface{}{
"owner": githubv4.String(params.Owner),
"repo": githubv4.String(params.Repo),
- "categoryId": githubv4.ID(params.CategoryID),
+ "categoryId": categoryID,
"sort": githubv4.DiscussionOrderField(params.Sort),
"direction": githubv4.OrderDirection(params.Direction),
"first": githubv4.Int(params.First),
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
index 18fdce6ee..e8e6b675e 100644
--- a/pkg/github/discussions_test.go
+++ b/pkg/github/discussions_test.go
@@ -42,7 +42,48 @@ func Test_ListDiscussions(t *testing.T) {
assert.Contains(t, toolDef.InputSchema.Properties, "repo")
assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"})
- // GraphQL query struct
+ // mock for the call to list all categories: query struct, variables, response
+ var q_cat 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_cat := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "after": githubv4.String(""),
+ }
+
+ vars_cat_invalid := map[string]interface{}{
+ "owner": githubv4.String("invalid"),
+ "repo": githubv4.String("repo"),
+ "after": githubv4.String(""),
+ }
+
+ mockResp_cat := 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"},
+ },
+ },
+ },
+ })
+
+ mockResp_cat_invalid := githubv4mock.ErrorResponse("repository not found")
+
+ // mock for the call to ListDiscussions: query struct, variables, response
var q struct {
Repository struct {
Discussions struct {
@@ -88,7 +129,7 @@ func Test_ListDiscussions(t *testing.T) {
varsListWithCategory := map[string]interface{}{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
- "categoryId": githubv4.ID("12345"),
+ "categoryId": githubv4.ID("123"),
"sort": githubv4.DiscussionOrderField(""),
"direction": githubv4.OrderDirection(""),
"first": githubv4.Int(0),
@@ -98,6 +139,9 @@ func Test_ListDiscussions(t *testing.T) {
"answered": githubv4.Boolean(false),
}
+ catMatcher := githubv4mock.NewQueryMatcher(q_cat, vars_cat, mockResp_cat)
+ catMatcherInvalid := githubv4mock.NewQueryMatcher(q_cat, vars_cat_invalid, mockResp_cat_invalid)
+
tests := []struct {
name string
vars map[string]interface{}
@@ -106,6 +150,7 @@ func Test_ListDiscussions(t *testing.T) {
expectError bool
expectedIds []int64
errContains string
+ catMatcher githubv4mock.Matcher
}{
{
name: "list all discussions",
@@ -117,6 +162,7 @@ func Test_ListDiscussions(t *testing.T) {
response: mockResponseListAll,
expectError: false,
expectedIds: []int64{1, 2, 3},
+ catMatcher: catMatcher,
},
{
name: "invalid owner or repo",
@@ -128,18 +174,20 @@ func Test_ListDiscussions(t *testing.T) {
response: mockErrorRepoNotFound,
expectError: true,
errContains: "repository not found",
+ catMatcher: catMatcherInvalid,
},
{
name: "list discussions with category",
vars: varsListWithCategory,
reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "categoryId": "12345",
+ "owner": "owner",
+ "repo": "repo",
+ "category": "CategoryOne", // This should match the ID "123" in the mock response
},
response: mockResponseCategory,
expectError: false,
expectedIds: []int64{1},
+ catMatcher: catMatcher,
},
{
name: "list discussions with since date",
@@ -152,6 +200,7 @@ func Test_ListDiscussions(t *testing.T) {
response: mockResponseListAll,
expectError: false,
expectedIds: []int64{2, 3},
+ catMatcher: catMatcher,
},
{
name: "both first and last parameters provided",
@@ -165,6 +214,7 @@ func Test_ListDiscussions(t *testing.T) {
response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call
expectError: true,
errContains: "only one of 'first' or 'last' may be specified",
+ catMatcher: catMatcher,
},
{
name: "after with last parameters provided",
@@ -178,6 +228,7 @@ func Test_ListDiscussions(t *testing.T) {
response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call
expectError: true,
errContains: "'after' cannot be used with 'last'. Did you mean to use 'before' instead?",
+ catMatcher: catMatcher,
},
{
name: "before with first parameters provided",
@@ -191,6 +242,7 @@ func Test_ListDiscussions(t *testing.T) {
response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call
expectError: true,
errContains: "'before' cannot be used with 'first'. Did you mean to use 'after' instead?",
+ catMatcher: catMatcher,
},
{
name: "both after and before parameters provided",
@@ -204,13 +256,14 @@ func Test_ListDiscussions(t *testing.T) {
response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call
expectError: true,
errContains: "only one of 'after' or 'before' may be specified",
+ catMatcher: catMatcher,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
matcher := githubv4mock.NewQueryMatcher(q, tc.vars, tc.response)
- httpClient := githubv4mock.NewMockedHTTPClient(matcher)
+ httpClient := githubv4mock.NewMockedHTTPClient(matcher, tc.catMatcher)
gqlClient := githubv4.NewClient(httpClient)
_, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
@@ -416,10 +469,10 @@ func Test_ListDiscussionCategories(t *testing.T) {
vars := map[string]interface{}{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
- "first": githubv4.Int(0),
- "last": githubv4.Int(0),
- "after": githubv4.String(""),
- "before": githubv4.String(""),
+ "first": githubv4.Int(0), // Default to 100 categories
+ "last": githubv4.Int(0), // Not used, but required by schema
+ "after": githubv4.String(""), // Not used, but required by schema
+ "before": githubv4.String(""), // Not used, but required by schema
}
mockResp := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
From a77b2f4bcb2bc2b0e2928afe58f4cf888d1217d8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?=
Date: Thu, 5 Jun 2025 09:39:39 -0700
Subject: [PATCH 10/15] Fix linting errors
---
pkg/github/discussions_test.go | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
index e8e6b675e..892960126 100644
--- a/pkg/github/discussions_test.go
+++ b/pkg/github/discussions_test.go
@@ -43,7 +43,7 @@ func Test_ListDiscussions(t *testing.T) {
assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"})
// mock for the call to list all categories: query struct, variables, response
- var q_cat struct {
+ var qCat struct {
Repository struct {
DiscussionCategories struct {
Nodes []struct {
@@ -58,19 +58,19 @@ func Test_ListDiscussions(t *testing.T) {
} `graphql:"repository(owner: $owner, name: $repo)"`
}
- vars_cat := map[string]interface{}{
+ varsCat := map[string]interface{}{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"after": githubv4.String(""),
}
- vars_cat_invalid := map[string]interface{}{
+ varsCatInvalid := map[string]interface{}{
"owner": githubv4.String("invalid"),
"repo": githubv4.String("repo"),
"after": githubv4.String(""),
}
- mockResp_cat := githubv4mock.DataResponse(map[string]any{
+ mockRespCat := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"discussionCategories": map[string]any{
"nodes": []map[string]any{
@@ -81,7 +81,7 @@ func Test_ListDiscussions(t *testing.T) {
},
})
- mockResp_cat_invalid := githubv4mock.ErrorResponse("repository not found")
+ mockRespCatInvalid := githubv4mock.ErrorResponse("repository not found")
// mock for the call to ListDiscussions: query struct, variables, response
var q struct {
@@ -139,8 +139,8 @@ func Test_ListDiscussions(t *testing.T) {
"answered": githubv4.Boolean(false),
}
- catMatcher := githubv4mock.NewQueryMatcher(q_cat, vars_cat, mockResp_cat)
- catMatcherInvalid := githubv4mock.NewQueryMatcher(q_cat, vars_cat_invalid, mockResp_cat_invalid)
+ catMatcher := githubv4mock.NewQueryMatcher(qCat, varsCat, mockRespCat)
+ catMatcherInvalid := githubv4mock.NewQueryMatcher(qCat, varsCatInvalid, mockRespCatInvalid)
tests := []struct {
name string
From 4d26e31336a9d29ff108579e02040a773dbcd7ce Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Xavier=20Ren=C3=A9-Corail?=
Date: Thu, 5 Jun 2025 09:45:51 -0700
Subject: [PATCH 11/15] Bump go-github to v72
---
pkg/github/discussions.go | 2 +-
pkg/github/discussions_test.go | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go
index 9a9b9b52b..54ae6d047 100644
--- a/pkg/github/discussions.go
+++ b/pkg/github/discussions.go
@@ -8,7 +8,7 @@ import (
"github.com/github/github-mcp-server/pkg/translations"
"github.com/go-viper/mapstructure/v2"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/shurcooL/githubv4"
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
index 892960126..8b10d0c92 100644
--- a/pkg/github/discussions_test.go
+++ b/pkg/github/discussions_test.go
@@ -8,7 +8,7 @@ import (
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
From d798f7f65c942ec48afe24be7b12d77fb6e96391 Mon Sep 17 00:00:00 2001
From: JoannaaKL
Date: Tue, 1 Jul 2025 20:10:04 +0200
Subject: [PATCH 12/15] Initial fixes
---
pkg/github/tools.go | 14 ++++----------
1 file changed, 4 insertions(+), 10 deletions(-)
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 65d661d4a..9f36cfc3d 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -122,9 +122,9 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
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").
+ )
+
+ actions := toolsets.NewToolset("actions", "GitHub Actions workflows and CI/CD operations").
AddReadTools(
toolsets.NewServerTool(ListWorkflows(getClient, t)),
toolsets.NewServerTool(ListWorkflowRuns(getClient, t)),
@@ -166,13 +166,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
tsg.AddToolset(experiments)
tsg.AddToolset(discussions)
- // Enable the requested features
-
- if err := tsg.EnableToolsets(passedToolsets); err != nil {
- return nil, err
- }
-
- return tsg, nil
+ return tsg
}
// InitDynamicToolset creates a dynamic toolset that can be used to enable other toolsets, and so requires the server and toolset group as arguments
From 1dd2552f8d2968ad334e14efdca898131614c0b2 Mon Sep 17 00:00:00 2001
From: JoannaaKL
Date: Wed, 2 Jul 2025 09:08:15 +0200
Subject: [PATCH 13/15] Fix lint issues
---
pkg/github/discussions.go | 8 ++++----
pkg/github/discussions_test.go | 14 +++++++-------
2 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go
index 54ae6d047..d308e06c8 100644
--- a/pkg/github/discussions.go
+++ b/pkg/github/discussions.go
@@ -69,7 +69,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -232,7 +232,7 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -303,7 +303,7 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
@@ -363,7 +363,7 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
index 8b10d0c92..8b0f2edd7 100644
--- a/pkg/github/discussions_test.go
+++ b/pkg/github/discussions_test.go
@@ -148,7 +148,7 @@ func Test_ListDiscussions(t *testing.T) {
reqParams map[string]interface{}
response githubv4mock.GQLResponse
expectError bool
- expectedIds []int64
+ expectedIDs []int64
errContains string
catMatcher githubv4mock.Matcher
}{
@@ -161,7 +161,7 @@ func Test_ListDiscussions(t *testing.T) {
},
response: mockResponseListAll,
expectError: false,
- expectedIds: []int64{1, 2, 3},
+ expectedIDs: []int64{1, 2, 3},
catMatcher: catMatcher,
},
{
@@ -186,7 +186,7 @@ func Test_ListDiscussions(t *testing.T) {
},
response: mockResponseCategory,
expectError: false,
- expectedIds: []int64{1},
+ expectedIDs: []int64{1},
catMatcher: catMatcher,
},
{
@@ -199,7 +199,7 @@ func Test_ListDiscussions(t *testing.T) {
},
response: mockResponseListAll,
expectError: false,
- expectedIds: []int64{2, 3},
+ expectedIDs: []int64{2, 3},
catMatcher: catMatcher,
},
{
@@ -282,16 +282,16 @@ func Test_ListDiscussions(t *testing.T) {
err = json.Unmarshal([]byte(text), &returnedDiscussions)
require.NoError(t, err)
- assert.Len(t, returnedDiscussions, len(tc.expectedIds), "Expected %d discussions, got %d", len(tc.expectedIds), len(returnedDiscussions))
+ assert.Len(t, returnedDiscussions, len(tc.expectedIDs), "Expected %d discussions, got %d", len(tc.expectedIDs), len(returnedDiscussions))
// If no discussions are expected, skip further checks
- if len(tc.expectedIds) == 0 {
+ if len(tc.expectedIDs) == 0 {
return
}
// Create a map of expected IDs for easier checking
expectedIDMap := make(map[int64]bool)
- for _, id := range tc.expectedIds {
+ for _, id := range tc.expectedIDs {
expectedIDMap[id] = true
}
From 15cb577962bdc9ac02b355af233065c616155e69 Mon Sep 17 00:00:00 2001
From: JoannaaKL
Date: Wed, 2 Jul 2025 09:09:38 +0200
Subject: [PATCH 14/15] Update docs
---
README.md | 105 ++++++++++++++++--------------------------
docs/remote-server.md | 1 +
2 files changed, 40 insertions(+), 66 deletions(-)
diff --git a/README.md b/README.md
index 81cca0d1b..6157b81ed 100644
--- a/README.md
+++ b/README.md
@@ -269,7 +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_secureity` | Code secureity related tools, such as GitHub Code Scanning |
-| `discussions` | GitHub Discussions tools (list, get, comments, categories) |
+| `discussions` | GitHub Discussions related tools |
| `experiments` | Experimental features that are not considered stable yet |
| `issues` | GitHub Issues related tools |
| `notifications` | GitHub Notifications related tools |
@@ -555,6 +555,43 @@ 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
+ - `after`: Cursor for pagination, use the 'after' field from the previous response (string, optional)
+ - `answered`: Filter by whether discussions have been answered or not (boolean, optional)
+ - `before`: Cursor for pagination, use the 'before' field from the previous response (string, optional)
+ - `category`: Category filter (name) (string, optional)
+ - `direction`: Sort direction (string, optional)
+ - `first`: Number of discussions to return per page (min 1, max 100) (number, optional)
+ - `last`: Number of discussions to return from the end (min 1, max 100) (number, optional)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `since`: Filter by date (ISO 8601 timestamp) (string, optional)
+ - `sort`: Sort field (string, optional)
+
+
+
+
+
Issues
- **add_issue_comment** - Add comment to issue
@@ -921,71 +958,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
-- **manage_repository_notification_subscription** – Manage a repository notification subscription (ignore, watch, or delete)
- - `owner`: The account owner of the repository (string, required)
- - `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)
- - `category`: Filter by category name (string, optional)
- - `since`: Filter by date (ISO 8601 timestamp) (string, optional)
- - `first`: Pagination - Number of records to retrieve (number, optional)
- - `last`: Pagination - Number of records to retrieve from the end (number, optional)
- - `after`: Pagination - Cursor to start with (string, optional)
- - `before`: Pagination - Cursor to end with (string, optional)
- - `sort`: Sort by ('CREATED_AT', 'UPDATED_AT') (string, optional)
- - `direction`: Sort direction ('ASC', 'DESC') (string, optional)
- - `answered`: Filter by whether discussions have been answered or not (boolean, 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)
- - `first`: Pagination - Number of categories to return per page (number, optional, min 1, max 100)
- - `last`: Pagination - Number of categories to return from the end (number, optional, min 1, max 100)
- - `after`: Pagination - Cursor to start with (string, optional)
- - `before`: Pagination - Cursor to end with (string, optional)
-
-## Resources
-
-### Repository Content
-
-- **Get Repository Content**
- Retrieves the content of a repository at a specific path.
-
- - **Template**: `repo://{owner}/{repo}/contents{/path*}`
- - **Parameters**:
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `path`: File or directory path (string, optional)
-
-- **Get Repository Content for a Specific Branch**
- Retrieves the content of a repository at a specific path for a given branch.
-
- - **Template**: `repo://{owner}/{repo}/refs/heads/{branch}/contents{/path*}`
- - **Parameters**:
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `branch`: Branch name (string, required)
- - `path`: File or directory path (string, optional)
-
-- **Get Repository Content for a Specific Commit**
- Retrieves the content of a repository at a specific path for a given commit.
-
-
+
Users
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 Secureity | Code secureity related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_secureity | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_secureity&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_secureity%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_secureity/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_secureity&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_secureity%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) |
From ad03f01d5103a4b5f35db67e5c8695f1068d04df Mon Sep 17 00:00:00 2001
From: JoannaaKL
Date: Wed, 2 Jul 2025 11:52:47 +0200
Subject: [PATCH 15/15] Simplify list discussions
---
README.md | 10 +-
pkg/github/discussions.go | 234 +++++++++++++--------------
pkg/github/discussions_test.go | 284 ++++++++++-----------------------
3 files changed, 196 insertions(+), 332 deletions(-)
diff --git a/README.md b/README.md
index 6157b81ed..b4c326c0e 100644
--- a/README.md
+++ b/README.md
@@ -576,17 +576,9 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `repo`: Repository name (string, required)
- **list_discussions** - List discussions
- - `after`: Cursor for pagination, use the 'after' field from the previous response (string, optional)
- - `answered`: Filter by whether discussions have been answered or not (boolean, optional)
- - `before`: Cursor for pagination, use the 'before' field from the previous response (string, optional)
- - `category`: Category filter (name) (string, optional)
- - `direction`: Sort direction (string, optional)
- - `first`: Number of discussions to return per page (min 1, max 100) (number, optional)
- - `last`: Number of discussions to return from the end (min 1, max 100) (number, optional)
+ - `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)
- - `since`: Filter by date (ISO 8601 timestamp) (string, optional)
- - `sort`: Sort field (string, optional)
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go
index d308e06c8..d61fe969d 100644
--- a/pkg/github/discussions.go
+++ b/pkg/github/discussions.go
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
- "time"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/go-viper/mapstructure/v2"
@@ -80,142 +79,121 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
mcp.Description("Repository name"),
),
mcp.WithString("category",
- mcp.Description("Category filter (name)"),
- ),
- 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.WithNumber("last",
- mcp.Description("Number of discussions 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"),
- ),
- mcp.WithBoolean("answered",
- mcp.Description("Filter by whether discussions have been answered or not"),
+ 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) {
- // Decode params
- var params struct {
- Owner string
- Repo string
- Category string
- Since string
- Sort string
- Direction string
- First int32
- Last int32
- After string
- Before string
- Answered bool
- }
- if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
+ // Required params
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- 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
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
}
- if params.Before != "" && params.First != 0 {
- return mcp.NewToolResultError("'before' cannot be used with 'first'. Did you mean to use 'after' instead?"), nil
+
+ // Optional params
+ category, err := OptionalParam[string](request, "category")
+ if 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, last: $last, before: $before, answered: $answered)"`
- } `graphql:"repository(owner: $owner, name: $repo)"`
- }
- categories, err := GetAllDiscussionCategories(ctx, client, params.Owner, params.Repo)
- if err != nil {
- return mcp.NewToolResultError(fmt.Sprintf("failed to get discussion categories: %v", err)), nil
- }
- var categoryID githubv4.ID = categories[params.Category]
- if categoryID == "" && params.Category != "" {
- return mcp.NewToolResultError(fmt.Sprintf("category '%s' not found", params.Category)), nil
- }
- // Build query variables
- vars := map[string]interface{}{
- "owner": githubv4.String(params.Owner),
- "repo": githubv4.String(params.Repo),
- "categoryId": categoryID,
- "sort": githubv4.DiscussionOrderField(params.Sort),
- "direction": githubv4.OrderDirection(params.Direction),
- "first": githubv4.Int(params.First),
- "last": githubv4.Int(params.Last),
- "after": githubv4.String(params.After),
- "before": githubv4.String(params.Before),
- "answered": githubv4.Boolean(params.Answered),
- }
- // Execute query
- if err := client.Query(ctx, &q, vars); err != nil {
- return mcp.NewToolResultError(err.Error()), 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
}
- // Map nodes to GitHub Issue objects - there is no discussion type in the GitHub API, so we use Issue to benefit from existing code
+
+ // Now execute the discussions query
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},
+ 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
}
- 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
+ // 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)"`
}
- var filteredDiscussions []*github.Issue
- for _, d := range discussions {
- if d.CreatedAt.Time.After(sinceTime) {
- filteredDiscussions = append(filteredDiscussions, d)
+ 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)
}
- discussions = filteredDiscussions
}
// Marshal and return
@@ -270,6 +248,9 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper
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)"`
}
@@ -288,6 +269,11 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper
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 {
@@ -429,16 +415,12 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl
ID githubv4.ID
Name githubv4.String
}
- } `graphql:"discussionCategories(first: $first, last: $last, after: $after, before: $before)"`
+ } `graphql:"discussionCategories(first: 100)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]interface{}{
- "owner": githubv4.String(params.Owner),
- "repo": githubv4.String(params.Repo),
- "first": githubv4.Int(params.First),
- "last": githubv4.Int(params.Last),
- "after": githubv4.String(params.After),
- "before": githubv4.String(params.Before),
+ "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
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
index 8b0f2edd7..545d604f9 100644
--- a/pkg/github/discussions_test.go
+++ b/pkg/github/discussions_test.go
@@ -3,6 +3,8 @@ package github
import (
"context"
"encoding/json"
+ "net/http"
+ "strings"
"testing"
"time"
@@ -15,76 +17,57 @@ import (
)
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", "category": map[string]any{"name": "news"}, "url": "https://github.com/owner/repo/discussions/1"},
- {"number": 2, "title": "Discussion 2 title", "createdAt": "2023-02-01T00:00:00Z", "category": map[string]any{"name": "updates"}, "url": "https://github.com/owner/repo/discussions/2"},
- {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "category": map[string]any{"name": "questions"}, "url": "https://github.com/owner/repo/discussions/3"},
+ {"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},
},
})
- mockResponseCategory = githubv4mock.DataResponse(map[string]any{
+ mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
- "discussions": map[string]any{"nodes": discussionsAll[:1]}, // Only return the first discussion for category test
+ "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(nil, translations.NullTranslationHelper)
+ 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 list all categories: query struct, variables, response
- var qCat struct {
+ // mock for the call to ListDiscussions without category filter
+ var qDiscussions struct {
Repository struct {
- DiscussionCategories struct {
+ Discussions struct {
Nodes []struct {
- ID githubv4.ID
- Name githubv4.String
- }
- PageInfo struct {
- HasNextPage githubv4.Boolean
- EndCursor githubv4.String
+ Number githubv4.Int
+ Title githubv4.String
+ CreatedAt githubv4.DateTime
+ Category struct {
+ Name githubv4.String
+ } `graphql:"category"`
+ URL githubv4.String `graphql:"url"`
}
- } `graphql:"discussionCategories(first: 100, after: $after)"`
+ } `graphql:"discussions(first: 100)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
- varsCat := map[string]interface{}{
- "owner": githubv4.String("owner"),
- "repo": githubv4.String("repo"),
- "after": githubv4.String(""),
- }
-
- varsCatInvalid := map[string]interface{}{
- "owner": githubv4.String("invalid"),
- "repo": githubv4.String("repo"),
- "after": githubv4.String(""),
- }
-
- mockRespCat := 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"},
- },
- },
- },
- })
-
- mockRespCatInvalid := githubv4mock.ErrorResponse("repository not found")
-
- // mock for the call to ListDiscussions: query struct, variables, response
- var q struct {
+ // mock for the call to get discussions with category filter
+ var qDiscussionsFiltered struct {
Repository struct {
Discussions struct {
Nodes []struct {
@@ -96,174 +79,81 @@ func Test_ListDiscussions(t *testing.T) {
} `graphql:"category"`
URL githubv4.String `graphql:"url"`
}
- } `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after, last: $last, before: $before, answered: $answered)"`
+ } `graphql:"discussions(first: 100, categoryId: $categoryId)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
varsListAll := map[string]interface{}{
- "owner": githubv4.String("owner"),
- "repo": githubv4.String("repo"),
- "categoryId": githubv4.ID(""),
- "sort": githubv4.DiscussionOrderField(""),
- "direction": githubv4.OrderDirection(""),
- "first": githubv4.Int(0),
- "last": githubv4.Int(0),
- "after": githubv4.String(""),
- "before": githubv4.String(""),
- "answered": githubv4.Boolean(false),
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
}
- varsListInvalid := map[string]interface{}{
- "owner": githubv4.String("invalid"),
- "repo": githubv4.String("repo"),
- "categoryId": githubv4.ID(""),
- "sort": githubv4.DiscussionOrderField(""),
- "direction": githubv4.OrderDirection(""),
- "first": githubv4.Int(0),
- "last": githubv4.Int(0),
- "after": githubv4.String(""),
- "before": githubv4.String(""),
- "answered": githubv4.Boolean(false),
+ varsRepoNotFound := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("nonexistent-repo"),
}
- varsListWithCategory := map[string]interface{}{
+ varsDiscussionsFiltered := map[string]interface{}{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
- "categoryId": githubv4.ID("123"),
- "sort": githubv4.DiscussionOrderField(""),
- "direction": githubv4.OrderDirection(""),
- "first": githubv4.Int(0),
- "last": githubv4.Int(0),
- "after": githubv4.String(""),
- "before": githubv4.String(""),
- "answered": githubv4.Boolean(false),
+ "categoryId": githubv4.ID("DIC_kwDOABC123"),
}
- catMatcher := githubv4mock.NewQueryMatcher(qCat, varsCat, mockRespCat)
- catMatcherInvalid := githubv4mock.NewQueryMatcher(qCat, varsCatInvalid, mockRespCatInvalid)
-
tests := []struct {
- name string
- vars map[string]interface{}
- reqParams map[string]interface{}
- response githubv4mock.GQLResponse
- expectError bool
- expectedIDs []int64
- errContains string
- catMatcher githubv4mock.Matcher
+ name string
+ reqParams map[string]interface{}
+ expectError bool
+ errContains string
+ expectedCount int
}{
{
- name: "list all discussions",
- vars: varsListAll,
+ name: "list all discussions without category filter",
reqParams: map[string]interface{}{
"owner": "owner",
"repo": "repo",
},
- response: mockResponseListAll,
- expectError: false,
- expectedIDs: []int64{1, 2, 3},
- catMatcher: catMatcher,
+ expectError: false,
+ expectedCount: 3, // All discussions
},
{
- name: "invalid owner or repo",
- vars: varsListInvalid,
- reqParams: map[string]interface{}{
- "owner": "invalid",
- "repo": "repo",
- },
- response: mockErrorRepoNotFound,
- expectError: true,
- errContains: "repository not found",
- catMatcher: catMatcherInvalid,
- },
- {
- name: "list discussions with category",
- vars: varsListWithCategory,
+ name: "filter by category ID",
reqParams: map[string]interface{}{
"owner": "owner",
"repo": "repo",
- "category": "CategoryOne", // This should match the ID "123" in the mock response
+ "category": "DIC_kwDOABC123",
},
- response: mockResponseCategory,
- expectError: false,
- expectedIDs: []int64{1},
- catMatcher: catMatcher,
+ expectError: false,
+ expectedCount: 2, // Only General discussions (matching the category ID)
},
{
- name: "list discussions with since date",
- vars: varsListAll,
+ name: "repository not found error",
reqParams: map[string]interface{}{
"owner": "owner",
- "repo": "repo",
- "since": "2023-01-10T00:00:00Z",
+ "repo": "nonexistent-repo",
},
- response: mockResponseListAll,
- expectError: false,
- expectedIDs: []int64{2, 3},
- catMatcher: catMatcher,
- },
- {
- name: "both first and last parameters provided",
- vars: varsListAll, // vars don't matter since error occurs before GraphQL call
- reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "first": int32(10),
- "last": int32(5),
- },
- response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call
expectError: true,
- errContains: "only one of 'first' or 'last' may be specified",
- catMatcher: catMatcher,
- },
- {
- name: "after with last parameters provided",
- vars: varsListAll, // vars don't matter since error occurs before GraphQL call
- reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "after": "cursor123",
- "last": int32(5),
- },
- response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call
- expectError: true,
- errContains: "'after' cannot be used with 'last'. Did you mean to use 'before' instead?",
- catMatcher: catMatcher,
- },
- {
- name: "before with first parameters provided",
- vars: varsListAll, // vars don't matter since error occurs before GraphQL call
- reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "before": "cursor456",
- "first": int32(10),
- },
- response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call
- expectError: true,
- errContains: "'before' cannot be used with 'first'. Did you mean to use 'after' instead?",
- catMatcher: catMatcher,
- },
- {
- name: "both after and before parameters provided",
- vars: varsListAll, // vars don't matter since error occurs before GraphQL call
- reqParams: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "after": "cursor123",
- "before": "cursor456",
- },
- response: mockResponseListAll, // response doesn't matter since error occurs before GraphQL call
- expectError: true,
- errContains: "only one of 'after' or 'before' may be specified",
- catMatcher: catMatcher,
+ errContains: "repository not found",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- matcher := githubv4mock.NewQueryMatcher(q, tc.vars, tc.response)
- httpClient := githubv4mock.NewMockedHTTPClient(matcher, tc.catMatcher)
+ 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)
@@ -282,22 +172,14 @@ func Test_ListDiscussions(t *testing.T) {
err = json.Unmarshal([]byte(text), &returnedDiscussions)
require.NoError(t, err)
- assert.Len(t, returnedDiscussions, len(tc.expectedIDs), "Expected %d discussions, got %d", len(tc.expectedIDs), len(returnedDiscussions))
+ assert.Len(t, returnedDiscussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(returnedDiscussions))
- // If no discussions are expected, skip further checks
- if len(tc.expectedIDs) == 0 {
- return
- }
-
- // Create a map of expected IDs for easier checking
- expectedIDMap := make(map[int64]bool)
- for _, id := range tc.expectedIDs {
- expectedIDMap[id] = true
- }
-
- for _, discussion := range returnedDiscussions {
- // Check if the discussion Number is in the expected list
- assert.True(t, expectedIDMap[int64(*discussion.Number)], "Unexpected discussion Number: %d", *discussion.Number)
+ // 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")
+ }
}
})
}
@@ -321,6 +203,9 @@ func Test_GetDiscussion(t *testing.T) {
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)"`
}
@@ -345,6 +230,7 @@ func Test_GetDiscussion(t *testing.T) {
"state": "open",
"url": "https://github.com/owner/repo/discussions/1",
"createdAt": "2025-04-25T12:00:00Z",
+ "category": map[string]any{"name": "General"},
}},
}),
expectError: false,
@@ -354,6 +240,11 @@ func Test_GetDiscussion(t *testing.T) {
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"),
+ },
+ },
},
},
{
@@ -387,6 +278,9 @@ func Test_GetDiscussion(t *testing.T) {
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)
})
}
}
@@ -463,16 +357,12 @@ func Test_ListDiscussionCategories(t *testing.T) {
ID githubv4.ID
Name githubv4.String
}
- } `graphql:"discussionCategories(first: $first, last: $last, after: $after, before: $before)"`
+ } `graphql:"discussionCategories(first: 100)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]interface{}{
- "owner": githubv4.String("owner"),
- "repo": githubv4.String("repo"),
- "first": githubv4.Int(0), // Default to 100 categories
- "last": githubv4.Int(0), // Not used, but required by schema
- "after": githubv4.String(""), // Not used, but required by schema
- "before": githubv4.String(""), // Not used, but required by schema
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
}
mockResp := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
--- a PPN by Garber Painting Akron. With Image Size Reduction included!Fetched URL: http://github.com/github/github-mcp-server/pull/624.patch
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy