Add discussion tools (#624)

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