Add discussion tools (#624)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/github/github-mcp-server/pkg/translations"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/google/go-github/v72/github"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/shurcooL/githubv4"
|
||||
)
|
||||
|
||||
// GetAllDiscussionCategories retrieves all discussion categories for a repository
|
||||
// by paginating through all pages and returns them as a map where the key is the
|
||||
// category name and the value is the category ID.
|
||||
func GetAllDiscussionCategories(ctx context.Context, client *githubv4.Client, owner, repo string) (map[string]string, error) {
|
||||
categories := make(map[string]string)
|
||||
var after string
|
||||
hasNextPage := true
|
||||
|
||||
for hasNextPage {
|
||||
// Prepare GraphQL query with pagination
|
||||
var q struct {
|
||||
Repository struct {
|
||||
DiscussionCategories struct {
|
||||
Nodes []struct {
|
||||
ID githubv4.ID
|
||||
Name githubv4.String
|
||||
}
|
||||
PageInfo struct {
|
||||
HasNextPage githubv4.Boolean
|
||||
EndCursor githubv4.String
|
||||
}
|
||||
} `graphql:"discussionCategories(first: 100, after: $after)"`
|
||||
} `graphql:"repository(owner: $owner, name: $repo)"`
|
||||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"owner": githubv4.String(owner),
|
||||
"repo": githubv4.String(repo),
|
||||
"after": githubv4.String(after),
|
||||
}
|
||||
|
||||
if err := client.Query(ctx, &q, vars); err != nil {
|
||||
return nil, fmt.Errorf("failed to query discussion categories: %w", err)
|
||||
}
|
||||
|
||||
// Add categories to the map
|
||||
for _, category := range q.Repository.DiscussionCategories.Nodes {
|
||||
categories[string(category.Name)] = fmt.Sprint(category.ID)
|
||||
}
|
||||
|
||||
// Check if there are more pages
|
||||
hasNextPage = bool(q.Repository.DiscussionCategories.PageInfo.HasNextPage)
|
||||
if hasNextPage {
|
||||
after = string(q.Repository.DiscussionCategories.PageInfo.EndCursor)
|
||||
}
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
||||
return mcp.NewTool("list_discussions",
|
||||
mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository")),
|
||||
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
||||
Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"),
|
||||
ReadOnlyHint: ToBoolPtr(true),
|
||||
}),
|
||||
mcp.WithString("owner",
|
||||
mcp.Required(),
|
||||
mcp.Description("Repository owner"),
|
||||
),
|
||||
mcp.WithString("repo",
|
||||
mcp.Required(),
|
||||
mcp.Description("Repository name"),
|
||||
),
|
||||
mcp.WithString("category",
|
||||
mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."),
|
||||
),
|
||||
),
|
||||
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
// Required params
|
||||
owner, err := RequiredParam[string](request, "owner")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
repo, err := RequiredParam[string](request, "repo")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
// Optional params
|
||||
category, err := OptionalParam[string](request, "category")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
client, err := getGQLClient(ctx)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
|
||||
}
|
||||
|
||||
// If category filter is specified, use it as the category ID for server-side filtering
|
||||
var categoryID *githubv4.ID
|
||||
if category != "" {
|
||||
id := githubv4.ID(category)
|
||||
categoryID = &id
|
||||
}
|
||||
|
||||
// Now execute the discussions query
|
||||
var discussions []*github.Issue
|
||||
if categoryID != nil {
|
||||
// Query with category filter (server-side filtering)
|
||||
var query struct {
|
||||
Repository struct {
|
||||
Discussions struct {
|
||||
Nodes []struct {
|
||||
Number githubv4.Int
|
||||
Title githubv4.String
|
||||
CreatedAt githubv4.DateTime
|
||||
Category struct {
|
||||
Name githubv4.String
|
||||
} `graphql:"category"`
|
||||
URL githubv4.String `graphql:"url"`
|
||||
}
|
||||
} `graphql:"discussions(first: 100, categoryId: $categoryId)"`
|
||||
} `graphql:"repository(owner: $owner, name: $repo)"`
|
||||
}
|
||||
vars := map[string]interface{}{
|
||||
"owner": githubv4.String(owner),
|
||||
"repo": githubv4.String(repo),
|
||||
"categoryId": *categoryID,
|
||||
}
|
||||
if err := client.Query(ctx, &query, vars); err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
// Map nodes to GitHub Issue objects
|
||||
for _, n := range query.Repository.Discussions.Nodes {
|
||||
di := &github.Issue{
|
||||
Number: github.Ptr(int(n.Number)),
|
||||
Title: github.Ptr(string(n.Title)),
|
||||
HTMLURL: github.Ptr(string(n.URL)),
|
||||
CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time},
|
||||
Labels: []*github.Label{
|
||||
{
|
||||
Name: github.Ptr(fmt.Sprintf("category:%s", string(n.Category.Name))),
|
||||
},
|
||||
},
|
||||
}
|
||||
discussions = append(discussions, di)
|
||||
}
|
||||
} else {
|
||||
// Query without category filter
|
||||
var query struct {
|
||||
Repository struct {
|
||||
Discussions struct {
|
||||
Nodes []struct {
|
||||
Number githubv4.Int
|
||||
Title githubv4.String
|
||||
CreatedAt githubv4.DateTime
|
||||
Category struct {
|
||||
Name githubv4.String
|
||||
} `graphql:"category"`
|
||||
URL githubv4.String `graphql:"url"`
|
||||
}
|
||||
} `graphql:"discussions(first: 100)"`
|
||||
} `graphql:"repository(owner: $owner, name: $repo)"`
|
||||
}
|
||||
vars := map[string]interface{}{
|
||||
"owner": githubv4.String(owner),
|
||||
"repo": githubv4.String(repo),
|
||||
}
|
||||
if err := client.Query(ctx, &query, vars); err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
// Map nodes to GitHub Issue objects
|
||||
for _, n := range query.Repository.Discussions.Nodes {
|
||||
di := &github.Issue{
|
||||
Number: github.Ptr(int(n.Number)),
|
||||
Title: github.Ptr(string(n.Title)),
|
||||
HTMLURL: github.Ptr(string(n.URL)),
|
||||
CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time},
|
||||
Labels: []*github.Label{
|
||||
{
|
||||
Name: github.Ptr(fmt.Sprintf("category:%s", string(n.Category.Name))),
|
||||
},
|
||||
},
|
||||
}
|
||||
discussions = append(discussions, di)
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal and return
|
||||
out, err := json.Marshal(discussions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal discussions: %w", err)
|
||||
}
|
||||
return mcp.NewToolResultText(string(out)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
||||
return mcp.NewTool("get_discussion",
|
||||
mcp.WithDescription(t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID")),
|
||||
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
||||
Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"),
|
||||
ReadOnlyHint: ToBoolPtr(true),
|
||||
}),
|
||||
mcp.WithString("owner",
|
||||
mcp.Required(),
|
||||
mcp.Description("Repository owner"),
|
||||
),
|
||||
mcp.WithString("repo",
|
||||
mcp.Required(),
|
||||
mcp.Description("Repository name"),
|
||||
),
|
||||
mcp.WithNumber("discussionNumber",
|
||||
mcp.Required(),
|
||||
mcp.Description("Discussion Number"),
|
||||
),
|
||||
),
|
||||
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
// Decode params
|
||||
var params struct {
|
||||
Owner string
|
||||
Repo string
|
||||
DiscussionNumber int32
|
||||
}
|
||||
if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
client, err := getGQLClient(ctx)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
|
||||
}
|
||||
|
||||
var q struct {
|
||||
Repository struct {
|
||||
Discussion struct {
|
||||
Number githubv4.Int
|
||||
Body githubv4.String
|
||||
State githubv4.String
|
||||
CreatedAt githubv4.DateTime
|
||||
URL githubv4.String `graphql:"url"`
|
||||
Category struct {
|
||||
Name githubv4.String
|
||||
} `graphql:"category"`
|
||||
} `graphql:"discussion(number: $discussionNumber)"`
|
||||
} `graphql:"repository(owner: $owner, name: $repo)"`
|
||||
}
|
||||
vars := map[string]interface{}{
|
||||
"owner": githubv4.String(params.Owner),
|
||||
"repo": githubv4.String(params.Repo),
|
||||
"discussionNumber": githubv4.Int(params.DiscussionNumber),
|
||||
}
|
||||
if err := client.Query(ctx, &q, vars); err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
d := q.Repository.Discussion
|
||||
discussion := &github.Issue{
|
||||
Number: github.Ptr(int(d.Number)),
|
||||
Body: github.Ptr(string(d.Body)),
|
||||
State: github.Ptr(string(d.State)),
|
||||
HTMLURL: github.Ptr(string(d.URL)),
|
||||
CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time},
|
||||
Labels: []*github.Label{
|
||||
{
|
||||
Name: github.Ptr(fmt.Sprintf("category:%s", string(d.Category.Name))),
|
||||
},
|
||||
},
|
||||
}
|
||||
out, err := json.Marshal(discussion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal discussion: %w", err)
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(string(out)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
||||
return mcp.NewTool("get_discussion_comments",
|
||||
mcp.WithDescription(t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion")),
|
||||
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
||||
Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"),
|
||||
ReadOnlyHint: ToBoolPtr(true),
|
||||
}),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
|
||||
mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")),
|
||||
),
|
||||
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
// Decode params
|
||||
var params struct {
|
||||
Owner string
|
||||
Repo string
|
||||
DiscussionNumber int32
|
||||
}
|
||||
if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
client, err := getGQLClient(ctx)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
|
||||
}
|
||||
|
||||
var q struct {
|
||||
Repository struct {
|
||||
Discussion struct {
|
||||
Comments struct {
|
||||
Nodes []struct {
|
||||
Body githubv4.String
|
||||
}
|
||||
} `graphql:"comments(first:100)"`
|
||||
} `graphql:"discussion(number: $discussionNumber)"`
|
||||
} `graphql:"repository(owner: $owner, name: $repo)"`
|
||||
}
|
||||
vars := map[string]interface{}{
|
||||
"owner": githubv4.String(params.Owner),
|
||||
"repo": githubv4.String(params.Repo),
|
||||
"discussionNumber": githubv4.Int(params.DiscussionNumber),
|
||||
}
|
||||
if err := client.Query(ctx, &q, vars); err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
var comments []*github.IssueComment
|
||||
for _, c := range q.Repository.Discussion.Comments.Nodes {
|
||||
comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))})
|
||||
}
|
||||
|
||||
out, err := json.Marshal(comments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal comments: %w", err)
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(string(out)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
||||
return mcp.NewTool("list_discussion_categories",
|
||||
mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository")),
|
||||
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
||||
Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"),
|
||||
ReadOnlyHint: ToBoolPtr(true),
|
||||
}),
|
||||
mcp.WithString("owner",
|
||||
mcp.Required(),
|
||||
mcp.Description("Repository owner"),
|
||||
),
|
||||
mcp.WithString("repo",
|
||||
mcp.Required(),
|
||||
mcp.Description("Repository name"),
|
||||
),
|
||||
mcp.WithNumber("first",
|
||||
mcp.Description("Number of categories to return per page (min 1, max 100)"),
|
||||
mcp.Min(1),
|
||||
mcp.Max(100),
|
||||
),
|
||||
mcp.WithNumber("last",
|
||||
mcp.Description("Number of categories to return from the end (min 1, max 100)"),
|
||||
mcp.Min(1),
|
||||
mcp.Max(100),
|
||||
),
|
||||
mcp.WithString("after",
|
||||
mcp.Description("Cursor for pagination, use the 'after' field from the previous response"),
|
||||
),
|
||||
mcp.WithString("before",
|
||||
mcp.Description("Cursor for pagination, use the 'before' field from the previous response"),
|
||||
),
|
||||
),
|
||||
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
// Decode params
|
||||
var params struct {
|
||||
Owner string
|
||||
Repo string
|
||||
First int32
|
||||
Last int32
|
||||
After string
|
||||
Before string
|
||||
}
|
||||
if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
// Validate pagination parameters
|
||||
if params.First != 0 && params.Last != 0 {
|
||||
return mcp.NewToolResultError("only one of 'first' or 'last' may be specified"), nil
|
||||
}
|
||||
if params.After != "" && params.Before != "" {
|
||||
return mcp.NewToolResultError("only one of 'after' or 'before' may be specified"), nil
|
||||
}
|
||||
if params.After != "" && params.Last != 0 {
|
||||
return mcp.NewToolResultError("'after' cannot be used with 'last'. Did you mean to use 'before' instead?"), nil
|
||||
}
|
||||
if params.Before != "" && params.First != 0 {
|
||||
return mcp.NewToolResultError("'before' cannot be used with 'first'. Did you mean to use 'after' instead?"), nil
|
||||
}
|
||||
|
||||
client, err := getGQLClient(ctx)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
|
||||
}
|
||||
var q struct {
|
||||
Repository struct {
|
||||
DiscussionCategories struct {
|
||||
Nodes []struct {
|
||||
ID githubv4.ID
|
||||
Name githubv4.String
|
||||
}
|
||||
} `graphql:"discussionCategories(first: 100)"`
|
||||
} `graphql:"repository(owner: $owner, name: $repo)"`
|
||||
}
|
||||
vars := map[string]interface{}{
|
||||
"owner": githubv4.String(params.Owner),
|
||||
"repo": githubv4.String(params.Repo),
|
||||
}
|
||||
if err := client.Query(ctx, &q, vars); err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
var categories []map[string]string
|
||||
for _, c := range q.Repository.DiscussionCategories.Nodes {
|
||||
categories = append(categories, map[string]string{
|
||||
"id": fmt.Sprint(c.ID),
|
||||
"name": string(c.Name),
|
||||
})
|
||||
}
|
||||
out, err := json.Marshal(categories)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal discussion categories: %w", err)
|
||||
}
|
||||
return mcp.NewToolResultText(string(out)), nil
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Executable
+5
@@ -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 .
|
||||
|
||||
Reference in New Issue
Block a user