From 72009399f849a659978d4e90f24944f7066d104f Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Wed, 2 Jul 2025 08:37:43 +0100 Subject: [PATCH 01/18] add DS_Store to .gitignore (#626) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index df489c390..0ad709cbf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ __debug_bin* # Go vendor bin/ + +# macOS +.DS_Store \ No newline at end of file From 39109b3e5d775ee9828935c04853ac4d62485e23 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Wed, 2 Jul 2025 15:09:04 +0200 Subject: [PATCH 02/18] Add discussion tools (#624) --- README.md | 30 +++ docs/remote-server.md | 1 + pkg/github/discussions.go | 441 +++++++++++++++++++++++++++++++++ pkg/github/discussions_test.go | 400 ++++++++++++++++++++++++++++++ pkg/github/tools.go | 9 + script/get-discussions | 5 + 6 files changed, 886 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 44a829601..b4c326c0e 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,7 @@ The following sets of tools are available (all are on by default): | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in | | `actions` | GitHub Actions workflows and CI/CD operations | | `code_security` | Code security related tools, such as GitHub Code Scanning | +| `discussions` | GitHub Discussions related tools | | `experiments` | Experimental features that are not considered stable yet | | `issues` | GitHub Issues related tools | | `notifications` | GitHub Notifications related tools | @@ -554,6 +555,35 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
+Discussions + +- **get_discussion** - Get discussion + - `discussionNumber`: Discussion Number (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + +- **get_discussion_comments** - Get discussion comments + - `discussionNumber`: Discussion Number (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + +- **list_discussion_categories** - List discussion categories + - `after`: Cursor for pagination, use the 'after' field from the previous response (string, optional) + - `before`: Cursor for pagination, use the 'before' field from the previous response (string, optional) + - `first`: Number of categories to return per page (min 1, max 100) (number, optional) + - `last`: Number of categories to return from the end (min 1, max 100) (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + +- **list_discussions** - List discussions + - `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + +
+ +
+ Issues - **add_issue_comment** - Add comment to issue diff --git a/docs/remote-server.md b/docs/remote-server.md index 50404ec85..7b5f2c0d4 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -20,6 +20,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | +| Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | | Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) | | Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | | Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go new file mode 100644 index 000000000..d61fe969d --- /dev/null +++ b/pkg/github/discussions.go @@ -0,0 +1,441 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/go-viper/mapstructure/v2" + "github.com/google/go-github/v72/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" +) + +// GetAllDiscussionCategories retrieves all discussion categories for a repository +// by paginating through all pages and returns them as a map where the key is the +// category name and the value is the category ID. +func GetAllDiscussionCategories(ctx context.Context, client *githubv4.Client, owner, repo string) (map[string]string, error) { + categories := make(map[string]string) + var after string + hasNextPage := true + + for hasNextPage { + // Prepare GraphQL query with pagination + var q struct { + Repository struct { + DiscussionCategories struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + } + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"discussionCategories(first: 100, after: $after)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "after": githubv4.String(after), + } + + if err := client.Query(ctx, &q, vars); err != nil { + return nil, fmt.Errorf("failed to query discussion categories: %w", err) + } + + // Add categories to the map + for _, category := range q.Repository.DiscussionCategories.Nodes { + categories[string(category.Name)] = fmt.Sprint(category.ID) + } + + // Check if there are more pages + hasNextPage = bool(q.Repository.DiscussionCategories.PageInfo.HasNextPage) + if hasNextPage { + after = string(q.Repository.DiscussionCategories.PageInfo.EndCursor) + } + } + + return categories, nil +} + +func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_discussions", + mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("category", + mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Required params + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Optional params + category, err := OptionalParam[string](request, "category") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + } + + // If category filter is specified, use it as the category ID for server-side filtering + var categoryID *githubv4.ID + if category != "" { + id := githubv4.ID(category) + categoryID = &id + } + + // Now execute the discussions query + var discussions []*github.Issue + if categoryID != nil { + // Query with category filter (server-side filtering) + var query struct { + Repository struct { + Discussions struct { + Nodes []struct { + Number githubv4.Int + Title githubv4.String + CreatedAt githubv4.DateTime + Category struct { + Name githubv4.String + } `graphql:"category"` + URL githubv4.String `graphql:"url"` + } + } `graphql:"discussions(first: 100, categoryId: $categoryId)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "categoryId": *categoryID, + } + if err := client.Query(ctx, &query, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Map nodes to GitHub Issue objects + for _, n := range query.Repository.Discussions.Nodes { + di := &github.Issue{ + Number: github.Ptr(int(n.Number)), + Title: github.Ptr(string(n.Title)), + HTMLURL: github.Ptr(string(n.URL)), + CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time}, + Labels: []*github.Label{ + { + Name: github.Ptr(fmt.Sprintf("category:%s", string(n.Category.Name))), + }, + }, + } + discussions = append(discussions, di) + } + } else { + // Query without category filter + var query struct { + Repository struct { + Discussions struct { + Nodes []struct { + Number githubv4.Int + Title githubv4.String + CreatedAt githubv4.DateTime + Category struct { + Name githubv4.String + } `graphql:"category"` + URL githubv4.String `graphql:"url"` + } + } `graphql:"discussions(first: 100)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } + if err := client.Query(ctx, &query, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Map nodes to GitHub Issue objects + for _, n := range query.Repository.Discussions.Nodes { + di := &github.Issue{ + Number: github.Ptr(int(n.Number)), + Title: github.Ptr(string(n.Title)), + HTMLURL: github.Ptr(string(n.URL)), + CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time}, + Labels: []*github.Label{ + { + Name: github.Ptr(fmt.Sprintf("category:%s", string(n.Category.Name))), + }, + }, + } + discussions = append(discussions, di) + } + } + + // Marshal and return + out, err := json.Marshal(discussions) + if err != nil { + return nil, fmt.Errorf("failed to marshal discussions: %w", err) + } + return mcp.NewToolResultText(string(out)), nil + } +} + +func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_discussion", + mcp.WithDescription(t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("discussionNumber", + mcp.Required(), + mcp.Description("Discussion Number"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Decode params + var params struct { + Owner string + Repo string + DiscussionNumber int32 + } + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + } + + var q struct { + Repository struct { + Discussion struct { + Number githubv4.Int + Body githubv4.String + State githubv4.String + CreatedAt githubv4.DateTime + URL githubv4.String `graphql:"url"` + Category struct { + Name githubv4.String + } `graphql:"category"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(params.Owner), + "repo": githubv4.String(params.Repo), + "discussionNumber": githubv4.Int(params.DiscussionNumber), + } + if err := client.Query(ctx, &q, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + d := q.Repository.Discussion + discussion := &github.Issue{ + Number: github.Ptr(int(d.Number)), + Body: github.Ptr(string(d.Body)), + State: github.Ptr(string(d.State)), + HTMLURL: github.Ptr(string(d.URL)), + CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time}, + Labels: []*github.Label{ + { + Name: github.Ptr(fmt.Sprintf("category:%s", string(d.Category.Name))), + }, + }, + } + out, err := json.Marshal(discussion) + if err != nil { + return nil, fmt.Errorf("failed to marshal discussion: %w", err) + } + + return mcp.NewToolResultText(string(out)), nil + } +} + +func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_discussion_comments", + mcp.WithDescription(t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), + mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Decode params + var params struct { + Owner string + Repo string + DiscussionNumber int32 + } + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + } + + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + Body githubv4.String + } + } `graphql:"comments(first:100)"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(params.Owner), + "repo": githubv4.String(params.Repo), + "discussionNumber": githubv4.Int(params.DiscussionNumber), + } + if err := client.Query(ctx, &q, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + var comments []*github.IssueComment + for _, c := range q.Repository.Discussion.Comments.Nodes { + comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))}) + } + + out, err := json.Marshal(comments) + if err != nil { + return nil, fmt.Errorf("failed to marshal comments: %w", err) + } + + return mcp.NewToolResultText(string(out)), nil + } +} + +func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_discussion_categories", + mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("first", + mcp.Description("Number of categories to return per page (min 1, max 100)"), + mcp.Min(1), + mcp.Max(100), + ), + mcp.WithNumber("last", + mcp.Description("Number of categories to return from the end (min 1, max 100)"), + mcp.Min(1), + mcp.Max(100), + ), + mcp.WithString("after", + mcp.Description("Cursor for pagination, use the 'after' field from the previous response"), + ), + mcp.WithString("before", + mcp.Description("Cursor for pagination, use the 'before' field from the previous response"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Decode params + var params struct { + Owner string + Repo string + First int32 + Last int32 + After string + Before string + } + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Validate pagination parameters + if params.First != 0 && params.Last != 0 { + return mcp.NewToolResultError("only one of 'first' or 'last' may be specified"), nil + } + if params.After != "" && params.Before != "" { + return mcp.NewToolResultError("only one of 'after' or 'before' may be specified"), nil + } + if params.After != "" && params.Last != 0 { + return mcp.NewToolResultError("'after' cannot be used with 'last'. Did you mean to use 'before' instead?"), nil + } + if params.Before != "" && params.First != 0 { + return mcp.NewToolResultError("'before' cannot be used with 'first'. Did you mean to use 'after' instead?"), nil + } + + client, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + } + var q struct { + Repository struct { + DiscussionCategories struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + } + } `graphql:"discussionCategories(first: 100)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(params.Owner), + "repo": githubv4.String(params.Repo), + } + if err := client.Query(ctx, &q, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + var categories []map[string]string + for _, c := range q.Repository.DiscussionCategories.Nodes { + categories = append(categories, map[string]string{ + "id": fmt.Sprint(c.ID), + "name": string(c.Name), + }) + } + out, err := json.Marshal(categories) + if err != nil { + return nil, fmt.Errorf("failed to marshal discussion categories: %w", err) + } + return mcp.NewToolResultText(string(out)), nil + } +} diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go new file mode 100644 index 000000000..545d604f9 --- /dev/null +++ b/pkg/github/discussions_test.go @@ -0,0 +1,400 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v72/github" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + discussionsGeneral = []map[string]any{ + {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}}, + {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}}, + } + discussionsAll = []map[string]any{ + {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}}, + {"number": 2, "title": "Discussion 2 title", "createdAt": "2023-02-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/2", "category": map[string]any{"name": "Questions"}}, + {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}}, + } + mockResponseListAll = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{"nodes": discussionsAll}, + }, + }) + mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussions": map[string]any{"nodes": discussionsGeneral}, + }, + }) + mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found") +) + +func Test_ListDiscussions(t *testing.T) { + mockClient := githubv4.NewClient(nil) + // Verify tool definition and schema + toolDef, _ := ListDiscussions(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + assert.Equal(t, "list_discussions", toolDef.Name) + assert.NotEmpty(t, toolDef.Description) + assert.Contains(t, toolDef.InputSchema.Properties, "owner") + assert.Contains(t, toolDef.InputSchema.Properties, "repo") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"}) + + // mock for the call to ListDiscussions without category filter + var qDiscussions struct { + Repository struct { + Discussions struct { + Nodes []struct { + Number githubv4.Int + Title githubv4.String + CreatedAt githubv4.DateTime + Category struct { + Name githubv4.String + } `graphql:"category"` + URL githubv4.String `graphql:"url"` + } + } `graphql:"discussions(first: 100)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + // mock for the call to get discussions with category filter + var qDiscussionsFiltered struct { + Repository struct { + Discussions struct { + Nodes []struct { + Number githubv4.Int + Title githubv4.String + CreatedAt githubv4.DateTime + Category struct { + Name githubv4.String + } `graphql:"category"` + URL githubv4.String `graphql:"url"` + } + } `graphql:"discussions(first: 100, categoryId: $categoryId)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + varsListAll := map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + } + + varsRepoNotFound := map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("nonexistent-repo"), + } + + varsDiscussionsFiltered := map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "categoryId": githubv4.ID("DIC_kwDOABC123"), + } + + tests := []struct { + name string + reqParams map[string]interface{} + expectError bool + errContains string + expectedCount int + }{ + { + name: "list all discussions without category filter", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedCount: 3, // All discussions + }, + { + name: "filter by category ID", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "category": "DIC_kwDOABC123", + }, + expectError: false, + expectedCount: 2, // Only General discussions (matching the category ID) + }, + { + name: "repository not found error", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + }, + expectError: true, + errContains: "repository not found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var httpClient *http.Client + + switch tc.name { + case "list all discussions without category filter": + // Simple case - no category filter + matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsListAll, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by category ID": + // Simple case - category filter using category ID directly + matcher := githubv4mock.NewQueryMatcher(qDiscussionsFiltered, varsDiscussionsFiltered, mockResponseListGeneral) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "repository not found error": + matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsRepoNotFound, mockErrorRepoNotFound) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + } + + gqlClient := githubv4.NewClient(httpClient) + _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + req := createMCPRequest(tc.reqParams) + res, err := handler(context.Background(), req) + text := getTextResult(t, res).Text + + if tc.expectError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) + return + } + require.NoError(t, err) + + var returnedDiscussions []*github.Issue + err = json.Unmarshal([]byte(text), &returnedDiscussions) + require.NoError(t, err) + + assert.Len(t, returnedDiscussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(returnedDiscussions)) + + // Verify that all returned discussions have a category label if filtered + if _, hasCategory := tc.reqParams["category"]; hasCategory { + for _, discussion := range returnedDiscussions { + require.NotEmpty(t, discussion.Labels, "Discussion should have category label") + assert.True(t, strings.HasPrefix(*discussion.Labels[0].Name, "category:"), "Discussion should have category label prefix") + } + } + }) + } +} + +func Test_GetDiscussion(t *testing.T) { + // Verify tool definition and schema + toolDef, _ := GetDiscussion(nil, translations.NullTranslationHelper) + assert.Equal(t, "get_discussion", toolDef.Name) + assert.NotEmpty(t, toolDef.Description) + assert.Contains(t, toolDef.InputSchema.Properties, "owner") + assert.Contains(t, toolDef.InputSchema.Properties, "repo") + assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) + + var q struct { + Repository struct { + Discussion struct { + Number githubv4.Int + Body githubv4.String + State githubv4.String + CreatedAt githubv4.DateTime + URL githubv4.String `graphql:"url"` + Category struct { + Name githubv4.String + } `graphql:"category"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionNumber": githubv4.Int(1), + } + tests := []struct { + name string + response githubv4mock.GQLResponse + expectError bool + expected *github.Issue + errContains string + }{ + { + name: "successful retrieval", + response: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{"discussion": map[string]any{ + "number": 1, + "body": "This is a test discussion", + "state": "open", + "url": "https://github.com/owner/repo/discussions/1", + "createdAt": "2025-04-25T12:00:00Z", + "category": map[string]any{"name": "General"}, + }}, + }), + expectError: false, + expected: &github.Issue{ + HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"), + Number: github.Ptr(1), + Body: github.Ptr("This is a test discussion"), + State: github.Ptr("open"), + CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)}, + Labels: []*github.Label{ + { + Name: github.Ptr("category:General"), + }, + }, + }, + }, + { + name: "discussion not found", + response: githubv4mock.ErrorResponse("discussion not found"), + expectError: true, + errContains: "discussion not found", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(q, vars, tc.response) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)}) + res, err := handler(context.Background(), req) + text := getTextResult(t, res).Text + + if tc.expectError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) + return + } + + require.NoError(t, err) + var out github.Issue + require.NoError(t, json.Unmarshal([]byte(text), &out)) + assert.Equal(t, *tc.expected.HTMLURL, *out.HTMLURL) + assert.Equal(t, *tc.expected.Number, *out.Number) + assert.Equal(t, *tc.expected.Body, *out.Body) + assert.Equal(t, *tc.expected.State, *out.State) + // Check category label + require.Len(t, out.Labels, 1) + assert.Equal(t, *tc.expected.Labels[0].Name, *out.Labels[0].Name) + }) + } +} + +func Test_GetDiscussionComments(t *testing.T) { + // Verify tool definition and schema + toolDef, _ := GetDiscussionComments(nil, translations.NullTranslationHelper) + assert.Equal(t, "get_discussion_comments", toolDef.Name) + assert.NotEmpty(t, toolDef.Description) + assert.Contains(t, toolDef.InputSchema.Properties, "owner") + assert.Contains(t, toolDef.InputSchema.Properties, "repo") + assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) + + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + Body githubv4.String + } + } `graphql:"comments(first:100)"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionNumber": githubv4.Int(1), + } + mockResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "comments": map[string]any{ + "nodes": []map[string]any{ + {"body": "This is the first comment"}, + {"body": "This is the second comment"}, + }, + }, + }, + }, + }) + matcher := githubv4mock.NewQueryMatcher(q, vars, mockResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + request := createMCPRequest(map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + var returnedComments []*github.IssueComment + err = json.Unmarshal([]byte(textContent.Text), &returnedComments) + require.NoError(t, err) + assert.Len(t, returnedComments, 2) + expectedBodies := []string{"This is the first comment", "This is the second comment"} + for i, comment := range returnedComments { + assert.Equal(t, expectedBodies[i], *comment.Body) + } +} + +func Test_ListDiscussionCategories(t *testing.T) { + var q struct { + Repository struct { + DiscussionCategories struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + } + } `graphql:"discussionCategories(first: 100)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + } + mockResp := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussionCategories": map[string]any{ + "nodes": []map[string]any{ + {"id": "123", "name": "CategoryOne"}, + {"id": "456", "name": "CategoryTwo"}, + }, + }, + }, + }) + matcher := githubv4mock.NewQueryMatcher(q, vars, mockResp) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + + tool, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + assert.Equal(t, "list_discussion_categories", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + request := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo"}) + result, err := handler(context.Background(), request) + require.NoError(t, err) + + text := getTextResult(t, result).Text + var categories []map[string]string + require.NoError(t, json.Unmarshal([]byte(text), &categories)) + assert.Len(t, categories, 2) + assert.Equal(t, "123", categories[0]["id"]) + assert.Equal(t, "CategoryOne", categories[0]["name"]) + assert.Equal(t, "456", categories[1]["id"]) + assert.Equal(t, "CategoryTwo", categories[1]["name"]) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 76b31d477..9f36cfc3d 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -116,6 +116,14 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ManageRepositoryNotificationSubscription(getClient, t)), ) + discussions := toolsets.NewToolset("discussions", "GitHub Discussions related tools"). + AddReadTools( + toolsets.NewServerTool(ListDiscussions(getGQLClient, t)), + toolsets.NewServerTool(GetDiscussion(getGQLClient, t)), + toolsets.NewServerTool(GetDiscussionComments(getGQLClient, t)), + toolsets.NewServerTool(ListDiscussionCategories(getGQLClient, t)), + ) + actions := toolsets.NewToolset("actions", "GitHub Actions workflows and CI/CD operations"). AddReadTools( toolsets.NewServerTool(ListWorkflows(getClient, t)), @@ -156,6 +164,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(secretProtection) tsg.AddToolset(notifications) tsg.AddToolset(experiments) + tsg.AddToolset(discussions) return tsg } diff --git a/script/get-discussions b/script/get-discussions new file mode 100755 index 000000000..3e68abf24 --- /dev/null +++ b/script/get-discussions @@ -0,0 +1,5 @@ +#!/bin/bash + +# echo '{"jsonrpc":"2.0","id":3,"params":{"name":"list_discussions","arguments": {"owner": "github", "repo": "securitylab", "first": 10, "since": "2025-04-01T00:00:00Z"}},"method":"tools/call"}' | go run cmd/github-mcp-server/main.go stdio | jq . +echo '{"jsonrpc":"2.0","id":3,"params":{"name":"list_discussions","arguments": {"owner": "github", "repo": "securitylab", "first": 10, "since": "2025-04-01T00:00:00Z", "sort": "CREATED_AT", "direction": "DESC"}},"method":"tools/call"}' | go run cmd/github-mcp-server/main.go stdio | jq . + From 6043bec223ef0de6b05fd3bdc40ed11ca4011ebb Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Wed, 2 Jul 2025 18:12:16 +0200 Subject: [PATCH 03/18] Cleanup (#628) * Remove unused function and add test script * Call test from the workflow --- .github/workflows/go.yml | 2 +- pkg/github/discussions.go | 50 --------------------------------------- script/lint | 1 - script/test | 3 +++ 4 files changed, 4 insertions(+), 52 deletions(-) create mode 100755 script/test diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 0a45569ec..e3ef25022 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -26,7 +26,7 @@ jobs: run: go mod download - name: Run unit tests - run: go test -race ./... + run: script/test - name: Build run: go build -v ./cmd/github-mcp-server diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index d61fe969d..a7ec8e20f 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -13,56 +13,6 @@ 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")), diff --git a/script/lint b/script/lint index 58884e3a0..e6ea9da89 100755 --- a/script/lint +++ b/script/lint @@ -7,7 +7,6 @@ BINDIR="$(git rev-parse --show-toplevel)"/bin BINARY=$BINDIR/golangci-lint GOLANGCI_LINT_VERSION=v2.2.1 - if [ ! -f "$BINARY" ]; then curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s "$GOLANGCI_LINT_VERSION" fi diff --git a/script/test b/script/test new file mode 100755 index 000000000..7f0dd0c20 --- /dev/null +++ b/script/test @@ -0,0 +1,3 @@ +set -eu + +go test -race ./... \ No newline at end of file From f88456f9897fb9eb1252b2502598761e53c9731a Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Wed, 2 Jul 2025 20:07:59 +0200 Subject: [PATCH 04/18] Update list commits tool description (#629) --- README.md | 2 +- pkg/github/__toolsnaps__/list_commits.snap | 4 ++-- pkg/github/repositories.go | 12 ++++++++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b4c326c0e..8a0364932 100644 --- a/README.md +++ b/README.md @@ -903,7 +903,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - - `sha`: SHA or Branch name (string, optional) + - `sha`: The commit SHA, branch name, or tag name to list commits from. If not specified, defaults to the repository's default branch. (string, optional) - **list_tags** - List tags - `owner`: Repository owner (string, required) diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap index 1e769c718..c43f7b0cd 100644 --- a/pkg/github/__toolsnaps__/list_commits.snap +++ b/pkg/github/__toolsnaps__/list_commits.snap @@ -3,7 +3,7 @@ "title": "List commits", "readOnlyHint": true }, - "description": "Get list of commits of a branch in a GitHub repository", + "description": "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).", "inputSchema": { "properties": { "author": { @@ -30,7 +30,7 @@ "type": "string" }, "sha": { - "description": "SHA or Branch name", + "description": "The commit SHA, branch name, or tag name to list commits from. If not specified, defaults to the repository's default branch.", "type": "string" } }, diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 5b116745e..29f776a05 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -97,7 +97,7 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too // ListCommits creates a tool to get commits of a branch in a repository. func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_commits", - mcp.WithDescription(t("TOOL_LIST_COMMITS_DESCRIPTION", "Get list of commits of a branch in a GitHub repository")), + mcp.WithDescription(t("TOOL_LIST_COMMITS_DESCRIPTION", "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_COMMITS_USER_TITLE", "List commits"), ReadOnlyHint: ToBoolPtr(true), @@ -111,7 +111,7 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t mcp.Description("Repository name"), ), mcp.WithString("sha", - mcp.Description("SHA or Branch name"), + mcp.Description("The commit SHA, branch name, or tag name to list commits from. If not specified, defaults to the repository's default branch."), ), mcp.WithString("author", mcp.Description("Author username or email address"), @@ -139,13 +139,17 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t if err != nil { return mcp.NewToolResultError(err.Error()), nil } - + // Set default perPage to 30 if not provided + perPage := pagination.perPage + if perPage == 0 { + perPage = 30 + } opts := &github.CommitsListOptions{ SHA: sha, Author: author, ListOptions: github.ListOptions{ Page: pagination.page, - PerPage: pagination.perPage, + PerPage: perPage, }, } From 23f6f3a7780ab9c5d3a38703b555911fad1dc24a Mon Sep 17 00:00:00 2001 From: Lulu <59149422+LuluBeatson@users.noreply.github.com> Date: Thu, 3 Jul 2025 12:26:57 +0100 Subject: [PATCH 05/18] Add Dependabot Alert Tools (#631) * add dependabit get and list tools * add toolsnaps * add unit tests for new tools * generate-docs --- README.md | 18 ++ docs/remote-server.md | 1 + .../__toolsnaps__/get_dependabot_alert.snap | 30 ++ .../__toolsnaps__/list_dependabot_alerts.snap | 46 +++ pkg/github/dependabot.go | 161 ++++++++++ pkg/github/dependabot_test.go | 276 ++++++++++++++++++ pkg/github/tools.go | 15 + 7 files changed, 547 insertions(+) create mode 100644 pkg/github/__toolsnaps__/get_dependabot_alert.snap create mode 100644 pkg/github/__toolsnaps__/list_dependabot_alerts.snap create mode 100644 pkg/github/dependabot.go create mode 100644 pkg/github/dependabot_test.go diff --git a/README.md b/README.md index 8a0364932..0bb054355 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,7 @@ The following sets of tools are available (all are on by default): | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in | | `actions` | GitHub Actions workflows and CI/CD operations | | `code_security` | Code security related tools, such as GitHub Code Scanning | +| `dependabot` | Dependabot tools | | `discussions` | GitHub Discussions related tools | | `experiments` | Experimental features that are not considered stable yet | | `issues` | GitHub Issues related tools | @@ -555,6 +556,23 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
+Dependabot + +- **get_dependabot_alert** - Get dependabot alert + - `alertNumber`: The number of the alert. (number, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + +- **list_dependabot_alerts** - List dependabot alerts + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + - `severity`: Filter dependabot alerts by severity (string, optional) + - `state`: Filter dependabot alerts by state. Defaults to open (string, optional) + +
+ +
+ Discussions - **get_discussion** - Get discussion diff --git a/docs/remote-server.md b/docs/remote-server.md index 7b5f2c0d4..c36124ecc 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -20,6 +20,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | +| Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%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) | diff --git a/pkg/github/__toolsnaps__/get_dependabot_alert.snap b/pkg/github/__toolsnaps__/get_dependabot_alert.snap new file mode 100644 index 000000000..76b5ef126 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_dependabot_alert.snap @@ -0,0 +1,30 @@ +{ + "annotations": { + "title": "Get dependabot alert", + "readOnlyHint": true + }, + "description": "Get details of a specific dependabot alert in a GitHub repository.", + "inputSchema": { + "properties": { + "alertNumber": { + "description": "The number of the alert.", + "type": "number" + }, + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "alertNumber" + ], + "type": "object" + }, + "name": "get_dependabot_alert" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap new file mode 100644 index 000000000..681d640b7 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap @@ -0,0 +1,46 @@ +{ + "annotations": { + "title": "List dependabot alerts", + "readOnlyHint": true + }, + "description": "List dependabot alerts in a GitHub repository.", + "inputSchema": { + "properties": { + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + }, + "severity": { + "description": "Filter dependabot alerts by severity", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + "state": { + "default": "open", + "description": "Filter dependabot alerts by state. Defaults to open", + "enum": [ + "open", + "fixed", + "dismissed", + "auto_dismissed" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "list_dependabot_alerts" +} \ No newline at end of file diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go new file mode 100644 index 000000000..af21b83d1 --- /dev/null +++ b/pkg/github/dependabot.go @@ -0,0 +1,161 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v72/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func GetDependabotAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool( + "get_dependabot_alert", + mcp.WithDescription(t("TOOL_GET_DEPENDABOT_ALERT_DESCRIPTION", "Get details of a specific dependabot alert in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_DEPENDABOT_ALERT_USER_TITLE", "Get dependabot alert"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithNumber("alertNumber", + mcp.Required(), + mcp.Description("The number of the alert."), + ), + ), + 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 + } + alertNumber, err := RequiredInt(request, "alertNumber") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + alert, resp, err := client.Dependabot.GetRepoAlert(ctx, owner, repo, alertNumber) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get alert with number '%d'", alertNumber), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get alert: %s", string(body))), nil + } + + r, err := json.Marshal(alert) + if err != nil { + return nil, fmt.Errorf("failed to marshal alert: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func ListDependabotAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool( + "list_dependabot_alerts", + mcp.WithDescription(t("TOOL_LIST_DEPENDABOT_ALERTS_DESCRIPTION", "List dependabot alerts in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithString("state", + mcp.Description("Filter dependabot alerts by state. Defaults to open"), + mcp.DefaultString("open"), + mcp.Enum("open", "fixed", "dismissed", "auto_dismissed"), + ), + mcp.WithString("severity", + mcp.Description("Filter dependabot alerts by severity"), + mcp.Enum("low", "medium", "high", "critical"), + ), + ), + 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 + } + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + severity, err := OptionalParam[string](request, "severity") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{ + State: ToStringPtr(state), + Severity: ToStringPtr(severity), + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list alerts: %s", string(body))), nil + } + + r, err := json.Marshal(alerts) + if err != nil { + return nil, fmt.Errorf("failed to marshal alerts: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go new file mode 100644 index 000000000..f7c091981 --- /dev/null +++ b/pkg/github/dependabot_test.go @@ -0,0 +1,276 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v72/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetDependabotAlert(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := GetDependabotAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + // Validate tool schema + assert.Equal(t, "get_dependabot_alert", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "alertNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) + + // Setup mock alert for success case + mockAlert := &github.DependabotAlert{ + Number: github.Ptr(42), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/42"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAlert *github.DependabotAlert + expectedErrMsg string + }{ + { + name: "successful alert fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber, + mockAlert, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(42), + }, + expectError: false, + expectedAlert: mockAlert, + }, + { + name: "alert fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(9999), + }, + expectError: true, + expectedErrMsg: "failed to get alert", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetDependabotAlert(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedAlert github.DependabotAlert + err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) + assert.NoError(t, err) + assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) + assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) + assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) + }) + } +} + +func Test_ListDependabotAlerts(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListDependabotAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_dependabot_alerts", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "state") + assert.Contains(t, tool.InputSchema.Properties, "severity") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock alerts for success case + criticalAlert := github.DependabotAlert{ + Number: github.Ptr(1), + HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/1"), + State: github.Ptr("open"), + SecurityAdvisory: &github.DependabotSecurityAdvisory{ + Severity: github.Ptr("critical"), + }, + } + highSeverityAlert := github.DependabotAlert{ + Number: github.Ptr(2), + HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/2"), + State: github.Ptr("fixed"), + SecurityAdvisory: &github.DependabotSecurityAdvisory{ + Severity: github.Ptr("high"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAlerts []*github.DependabotAlert + expectedErrMsg string + }{ + { + name: "successful open alerts listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepo, + expectQueryParams(t, map[string]string{ + "state": "open", + }).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "state": "open", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert}, + }, + { + name: "successful severity filtered listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepo, + expectQueryParams(t, map[string]string{ + "severity": "high", + }).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "severity": "high", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&highSeverityAlert}, + }, + { + name: "successful all alerts listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepo, + expectQueryParams(t, map[string]string{}).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}, + }, + { + name: "alerts listing fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list alerts", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ListDependabotAlerts(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedAlerts []*github.DependabotAlert + err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) + assert.NoError(t, err) + assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) + for i, alert := range returnedAlerts { + assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) + assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) + assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) + if tc.expectedAlerts[i].SecurityAdvisory != nil && tc.expectedAlerts[i].SecurityAdvisory.Severity != nil && + alert.SecurityAdvisory != nil && alert.SecurityAdvisory.Severity != nil { + assert.Equal(t, *tc.expectedAlerts[i].SecurityAdvisory.Severity, *alert.SecurityAdvisory.Severity) + } + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 9f36cfc3d..a469b7678 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -103,6 +103,11 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetSecretScanningAlert(getClient, t)), toolsets.NewServerTool(ListSecretScanningAlerts(getClient, t)), ) + dependabot := toolsets.NewToolset("dependabot", "Dependabot tools"). + AddReadTools( + toolsets.NewServerTool(GetDependabotAlert(getClient, t)), + toolsets.NewServerTool(ListDependabotAlerts(getClient, t)), + ) notifications := toolsets.NewToolset("notifications", "GitHub Notifications related tools"). AddReadTools( @@ -162,6 +167,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(actions) tsg.AddToolset(codeSecurity) tsg.AddToolset(secretProtection) + tsg.AddToolset(dependabot) tsg.AddToolset(notifications) tsg.AddToolset(experiments) tsg.AddToolset(discussions) @@ -188,3 +194,12 @@ func InitDynamicToolset(s *server.MCPServer, tsg *toolsets.ToolsetGroup, t trans func ToBoolPtr(b bool) *bool { return &b } + +// ToStringPtr converts a string to a *string pointer. +// Returns nil if the string is empty. +func ToStringPtr(s string) *string { + if s == "" { + return nil + } + return &s +} From 08a49b0f81f0c26a0240044d5bfe2e10de5f18ce Mon Sep 17 00:00:00 2001 From: Lulu <59149422+LuluBeatson@users.noreply.github.com> Date: Thu, 3 Jul 2025 15:33:21 +0100 Subject: [PATCH 06/18] use WithPagination tool option (#632) --- README.md | 20 +++--- .../__toolsnaps__/get_issue_comments.snap | 9 ++- pkg/github/actions.go | 68 +++++-------------- pkg/github/actions_test.go | 4 +- pkg/github/issues.go | 17 ++--- pkg/github/issues_test.go | 4 +- 6 files changed, 40 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 0bb054355..68742752f 100644 --- a/README.md +++ b/README.md @@ -478,15 +478,15 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - **list_workflow_jobs** - List workflow jobs - `filter`: Filters jobs by their completed_at timestamp (string, optional) - `owner`: Repository owner (string, required) - - `page`: The page number of the results to fetch (number, optional) - - `per_page`: The number of results per page (max 100) (number, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **list_workflow_run_artifacts** - List workflow artifacts - `owner`: Repository owner (string, required) - - `page`: The page number of the results to fetch (number, optional) - - `per_page`: The number of results per page (max 100) (number, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) @@ -495,16 +495,16 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `branch`: Returns workflow runs associated with a branch. Use the name of the branch. (string, optional) - `event`: Returns workflow runs for a specific event type (string, optional) - `owner`: Repository owner (string, required) - - `page`: The page number of the results to fetch (number, optional) - - `per_page`: The number of results per page (max 100) (number, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `status`: Returns workflow runs with the check run status (string, optional) - `workflow_id`: The workflow ID or workflow file name (string, required) - **list_workflows** - List workflows - `owner`: Repository owner (string, required) - - `page`: The page number of the results to fetch (number, optional) - - `per_page`: The number of results per page (max 100) (number, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - **rerun_failed_jobs** - Rerun failed jobs @@ -632,8 +632,8 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - **get_issue_comments** - Get issue comments - `issue_number`: Issue number (number, required) - `owner`: Repository owner (string, required) - - `page`: Page number (number, optional) - - `per_page`: Number of records per page (number, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - **list_issues** - List issues diff --git a/pkg/github/__toolsnaps__/get_issue_comments.snap b/pkg/github/__toolsnaps__/get_issue_comments.snap index fa1fb0d6c..b28f45204 100644 --- a/pkg/github/__toolsnaps__/get_issue_comments.snap +++ b/pkg/github/__toolsnaps__/get_issue_comments.snap @@ -15,11 +15,14 @@ "type": "string" }, "page": { - "description": "Page number", + "description": "Page number for pagination (min 1)", + "minimum": 1, "type": "number" }, - "per_page": { - "description": "Number of records per page", + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, "type": "number" }, "repo": { diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 8c7b08a85..95b1ec7ba 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -37,12 +37,7 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) mcp.Required(), mcp.Description(DescriptionRepositoryName), ), - mcp.WithNumber("per_page", - mcp.Description("The number of results per page (max 100)"), - ), - mcp.WithNumber("page", - mcp.Description("The page number of the results to fetch"), - ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -55,11 +50,7 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) } // Get optional pagination parameters - perPage, err := OptionalIntParam(request, "per_page") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - page, err := OptionalIntParam(request, "page") + pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -71,8 +62,8 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) // Set up list options opts := &github.ListOptions{ - PerPage: perPage, - Page: page, + PerPage: pagination.perPage, + Page: pagination.page, } workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) @@ -157,12 +148,7 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun mcp.Description("Returns workflow runs with the check run status"), mcp.Enum("queued", "in_progress", "completed", "requested", "waiting"), ), - mcp.WithNumber("per_page", - mcp.Description("The number of results per page (max 100)"), - ), - mcp.WithNumber("page", - mcp.Description("The page number of the results to fetch"), - ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -197,11 +183,7 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun } // Get optional pagination parameters - perPage, err := OptionalIntParam(request, "per_page") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - page, err := OptionalIntParam(request, "page") + pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -218,8 +200,8 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun Event: event, Status: status, ListOptions: github.ListOptions{ - PerPage: perPage, - Page: page, + PerPage: pagination.perPage, + Page: pagination.page, }, } @@ -483,12 +465,7 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun mcp.Description("Filters jobs by their completed_at timestamp"), mcp.Enum("latest", "all"), ), - mcp.WithNumber("per_page", - mcp.Description("The number of results per page (max 100)"), - ), - mcp.WithNumber("page", - mcp.Description("The page number of the results to fetch"), - ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -512,11 +489,7 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun } // Get optional pagination parameters - perPage, err := OptionalIntParam(request, "per_page") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - page, err := OptionalIntParam(request, "page") + pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -530,8 +503,8 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun opts := &github.ListWorkflowJobsOptions{ Filter: filter, ListOptions: github.ListOptions{ - PerPage: perPage, - Page: page, + PerPage: pagination.perPage, + Page: pagination.page, }, } @@ -1022,12 +995,7 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH mcp.Required(), mcp.Description("The unique identifier of the workflow run"), ), - mcp.WithNumber("per_page", - mcp.Description("The number of results per page (max 100)"), - ), - mcp.WithNumber("page", - mcp.Description("The page number of the results to fetch"), - ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -1045,11 +1013,7 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH runID := int64(runIDInt) // Get optional pagination parameters - perPage, err := OptionalIntParam(request, "per_page") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - page, err := OptionalIntParam(request, "page") + pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -1061,8 +1025,8 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH // Set up list options opts := &github.ListOptions{ - PerPage: perPage, - Page: page, + PerPage: pagination.perPage, + Page: pagination.page, } artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts) diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 1b904b9b1..f885ec5b9 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -23,7 +23,7 @@ func Test_ListWorkflows(t *testing.T) { assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") assert.Contains(t, tool.InputSchema.Properties, "page") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) @@ -393,7 +393,7 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "run_id") - assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") assert.Contains(t, tool.InputSchema.Properties, "page") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 6121786d2..9d51aeb50 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -608,12 +608,7 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun mcp.Required(), mcp.Description("Issue number"), ), - mcp.WithNumber("page", - mcp.Description("Page number"), - ), - mcp.WithNumber("per_page", - mcp.Description("Number of records per page"), - ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -628,19 +623,15 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } - page, err := OptionalIntParamWithDefault(request, "page", 1) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - perPage, err := OptionalIntParamWithDefault(request, "per_page", 30) + pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } opts := &github.IssueListCommentsOptions{ ListOptions: github.ListOptions{ - Page: page, - PerPage: perPage, + Page: pagination.page, + PerPage: pagination.perPage, }, } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 056fa7ed8..a6facbe2f 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1087,7 +1087,7 @@ func Test_GetIssueComments(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) // Setup mock comments for success case @@ -1152,7 +1152,7 @@ func Test_GetIssueComments(t *testing.T) { "repo": "repo", "issue_number": float64(42), "page": float64(2), - "per_page": float64(10), + "perPage": float64(10), }, expectError: false, expectedComments: mockComments, From 6c0453a9c141a3491f9a3c5f26dbff3284d2d910 Mon Sep 17 00:00:00 2001 From: LuluBeatson Date: Fri, 4 Jul 2025 10:32:43 +0100 Subject: [PATCH 07/18] omit site_admin from get_me output --- pkg/github/context_tools.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index bed2f4a39..280420b91 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -38,6 +38,9 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too ), nil } + // Set nil to omit from output + user.SiteAdmin = nil + return MarshalledTextResult(user), nil }) From 37d1ed6fd8e4937a2ea40d68c9a7684bb76a8a1b Mon Sep 17 00:00:00 2001 From: LuluBeatson Date: Fri, 4 Jul 2025 12:05:05 +0100 Subject: [PATCH 08/18] return MinimalUser --- pkg/github/context_tools.go | 15 ++++++++++++--- pkg/github/context_tools_test.go | 12 ++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 280420b91..43a5ce726 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -38,10 +38,19 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too ), nil } - // Set nil to omit from output - user.SiteAdmin = nil + // Create minimal user representation instead of returning full user object + minimalUser := MinimalUser{ + Login: user.GetLogin(), + ID: user.GetID(), + } + if user.HTMLURL != nil { + minimalUser.ProfileURL = *user.HTMLURL + } + if user.AvatarURL != nil { + minimalUser.AvatarURL = *user.AvatarURL + } - return MarshalledTextResult(user), nil + return MarshalledTextResult(minimalUser), nil }) return tool, handler diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 0d9193976..675e04dce 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -117,17 +117,13 @@ func Test_GetMe(t *testing.T) { } // Unmarshal and verify the result - var returnedUser github.User + var returnedUser MinimalUser err = json.Unmarshal([]byte(textContent.Text), &returnedUser) require.NoError(t, err) - // Verify user details - assert.Equal(t, *tc.expectedUser.Login, *returnedUser.Login) - assert.Equal(t, *tc.expectedUser.Name, *returnedUser.Name) - assert.Equal(t, *tc.expectedUser.Email, *returnedUser.Email) - assert.Equal(t, *tc.expectedUser.Bio, *returnedUser.Bio) - assert.Equal(t, *tc.expectedUser.HTMLURL, *returnedUser.HTMLURL) - assert.Equal(t, *tc.expectedUser.Type, *returnedUser.Type) + // Verify minimal user details + assert.Equal(t, *tc.expectedUser.Login, returnedUser.Login) + assert.Equal(t, *tc.expectedUser.HTMLURL, returnedUser.ProfileURL) }) } } From 1d057c975d3821d3d6cec17c01df56fdecb87fbc Mon Sep 17 00:00:00 2001 From: LuluBeatson Date: Fri, 4 Jul 2025 12:29:29 +0100 Subject: [PATCH 09/18] refactor: user get methods to avoid nil checks --- pkg/github/context_tools.go | 12 ++++-------- pkg/github/search.go | 14 +++++--------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 43a5ce726..cf859f8bd 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -40,14 +40,10 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too // Create minimal user representation instead of returning full user object minimalUser := MinimalUser{ - Login: user.GetLogin(), - ID: user.GetID(), - } - if user.HTMLURL != nil { - minimalUser.ProfileURL = *user.HTMLURL - } - if user.AvatarURL != nil { - minimalUser.AvatarURL = *user.AvatarURL + Login: user.GetLogin(), + ID: user.GetID(), + ProfileURL: user.GetHTMLURL(), + AvatarURL: user.GetAvatarURL(), } return MarshalledTextResult(minimalUser), nil diff --git a/pkg/github/search.go b/pkg/github/search.go index 5106b84d8..82f920351 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -224,15 +224,11 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand for _, user := range result.Users { if user.Login != nil { - mu := MinimalUser{Login: *user.Login} - if user.ID != nil { - mu.ID = *user.ID - } - if user.HTMLURL != nil { - mu.ProfileURL = *user.HTMLURL - } - if user.AvatarURL != nil { - mu.AvatarURL = *user.AvatarURL + mu := MinimalUser{ + Login: user.GetLogin(), + ID: user.GetID(), + ProfileURL: user.GetHTMLURL(), + AvatarURL: user.GetAvatarURL(), } minimalUsers = append(minimalUsers, mu) } From e43fca195b1655d5c9fb63d1bca404098f3db7f4 Mon Sep 17 00:00:00 2001 From: LuluBeatson Date: Fri, 4 Jul 2025 13:20:28 +0100 Subject: [PATCH 10/18] embed optional UserDetails in MinimalUser --- pkg/github/context_tools.go | 42 ++++++++++++++++++++++++++++++++ pkg/github/context_tools_test.go | 30 ++++++++++++++++------- pkg/github/search.go | 10 +++++--- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index cf859f8bd..3525277fe 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -2,6 +2,7 @@ package github import ( "context" + "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/translations" @@ -9,6 +10,28 @@ import ( "github.com/mark3labs/mcp-go/server" ) +// UserDetails contains additional fields about a GitHub user not already +// present in MinimalUser. Used by get_me context tool but omitted from search_users. +type UserDetails struct { + Name string `json:"name,omitempty"` + Company string `json:"company,omitempty"` + Blog string `json:"blog,omitempty"` + Location string `json:"location,omitempty"` + Email string `json:"email,omitempty"` + Hireable bool `json:"hireable,omitempty"` + Bio string `json:"bio,omitempty"` + TwitterUsername string `json:"twitter_username,omitempty"` + PublicRepos int `json:"public_repos"` + PublicGists int `json:"public_gists"` + Followers int `json:"followers"` + Following int `json:"following"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PrivateGists int `json:"private_gists,omitempty"` + TotalPrivateRepos int64 `json:"total_private_repos,omitempty"` + OwnedPrivateRepos int64 `json:"owned_private_repos,omitempty"` +} + // GetMe creates a tool to get details of the authenticated user. func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { tool := mcp.NewTool("get_me", @@ -44,6 +67,25 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too ID: user.GetID(), ProfileURL: user.GetHTMLURL(), AvatarURL: user.GetAvatarURL(), + Details: &UserDetails{ + Name: user.GetName(), + Company: user.GetCompany(), + Blog: user.GetBlog(), + Location: user.GetLocation(), + Email: user.GetEmail(), + Hireable: user.GetHireable(), + Bio: user.GetBio(), + TwitterUsername: user.GetTwitterUsername(), + PublicRepos: user.GetPublicRepos(), + PublicGists: user.GetPublicGists(), + Followers: user.GetFollowers(), + Following: user.GetFollowing(), + CreatedAt: user.GetCreatedAt().Time, + UpdatedAt: user.GetUpdatedAt().Time, + PrivateGists: user.GetPrivateGists(), + TotalPrivateRepos: user.GetTotalPrivateRepos(), + OwnedPrivateRepos: user.GetOwnedPrivateRepos(), + }, } return MarshalledTextResult(minimalUser), nil diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 675e04dce..03af4175d 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -26,15 +26,17 @@ func Test_GetMe(t *testing.T) { // Setup mock user response mockUser := &github.User{ - Login: github.Ptr("testuser"), - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), - Bio: github.Ptr("GitHub user for testing"), - Company: github.Ptr("Test Company"), - Location: github.Ptr("Test Location"), - HTMLURL: github.Ptr("https://github.com/testuser"), - CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)}, - Type: github.Ptr("User"), + Login: github.Ptr("testuser"), + Name: github.Ptr("Test User"), + Email: github.Ptr("test@example.com"), + Bio: github.Ptr("GitHub user for testing"), + Company: github.Ptr("Test Company"), + Location: github.Ptr("Test Location"), + HTMLURL: github.Ptr("https://github.com/testuser"), + CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)}, + Type: github.Ptr("User"), + Hireable: github.Ptr(true), + TwitterUsername: github.Ptr("testuser_twitter"), Plan: &github.Plan{ Name: github.Ptr("pro"), }, @@ -124,6 +126,16 @@ func Test_GetMe(t *testing.T) { // Verify minimal user details assert.Equal(t, *tc.expectedUser.Login, returnedUser.Login) assert.Equal(t, *tc.expectedUser.HTMLURL, returnedUser.ProfileURL) + + // Verify user details + require.NotNil(t, returnedUser.Details) + assert.Equal(t, *tc.expectedUser.Name, returnedUser.Details.Name) + assert.Equal(t, *tc.expectedUser.Email, returnedUser.Details.Email) + assert.Equal(t, *tc.expectedUser.Bio, returnedUser.Details.Bio) + assert.Equal(t, *tc.expectedUser.Company, returnedUser.Details.Company) + assert.Equal(t, *tc.expectedUser.Location, returnedUser.Details.Location) + assert.Equal(t, *tc.expectedUser.Hireable, returnedUser.Details.Hireable) + assert.Equal(t, *tc.expectedUser.TwitterUsername, returnedUser.Details.TwitterUsername) }) } } diff --git a/pkg/github/search.go b/pkg/github/search.go index 82f920351..a72b38bc6 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -155,11 +155,13 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to } } +// MinimalUser is the output type for user and organization search results. type MinimalUser struct { - Login string `json:"login"` - ID int64 `json:"id,omitempty"` - ProfileURL string `json:"profile_url,omitempty"` - AvatarURL string `json:"avatar_url,omitempty"` + Login string `json:"login"` + ID int64 `json:"id,omitempty"` + ProfileURL string `json:"profile_url,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Details *UserDetails `json:"details,omitempty"` // Optional field for additional user details } type MinimalSearchUsersResult struct { From ea7304769f9d48c1e603cac589dfebbc2c641ef4 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 4 Jul 2025 16:49:44 +0200 Subject: [PATCH 11/18] fix: stale information in CONTRIBUTING.md --- CONTRIBUTING.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6fa9c2ebe..314e4e0b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,14 +19,12 @@ These are one time installations required to be able to test your changes locall ## Submitting a pull request -> **Important**: Please open your pull request against the `next` branch, not `main`. The `next` branch is where we integrate new features and changes before they are merged to `main`. - 1. [Fork][fork] and clone the repository 1. Make sure the tests pass on your machine: `go test -v ./...` 1. Make sure linter passes on your machine: `golangci-lint run` 1. Create a new branch: `git checkout -b my-branch-name` 1. Make your change, add tests, and make sure the tests and linter still pass -1. Push to your fork and [submit a pull request][pr] targeting the `next` branch +1. Push to your fork and [submit a pull request][pr] targeting the `main` branch 1. Pat yourself on the back and wait for your pull request to be reviewed and merged. Here are a few things you can do that will increase the likelihood of your pull request being accepted: From fc117136812d9ff5e24407206d0b983828363c3e Mon Sep 17 00:00:00 2001 From: John Wesley Walker III <81404201+jww3@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:23:09 +0200 Subject: [PATCH 12/18] Updated links to MCP Specification in `docs/host-integration.md` (#641) --- docs/host-integration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/host-integration.md b/docs/host-integration.md index d9f6d9050..9a1d9396f 100644 --- a/docs/host-integration.md +++ b/docs/host-integration.md @@ -64,7 +64,7 @@ flowchart LR - **Local MCP Server**: An MCP Server running locally, side-by-side with the Application. - **Remote MCP Server**: An MCP Server running remotely, accessed via the internet. Most Remote MCP Servers require authentication via OAuth. -For more detail, see the [official MCP specification](https://modelcontextprotocol.io/specification/draft). +For more detail, see the [official MCP specification](https://modelcontextprotocol.io/specification/2025-06-18). > [!NOTE] > GitHub offers both a Local MCP Server and a Remote MCP Server. @@ -84,7 +84,7 @@ For the Remote GitHub MCP Server, the recommended way to obtain a valid access t > The Remote GitHub MCP Server itself does not provide Authentication services. > Your client application must obtain valid GitHub access tokens through one of the supported methods. -The expected flow for obtaining a valid access token via OAuth is depicted in the [MCP Specification](https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-flow-steps). For convenience, we've embedded a copy of the authorization flow below. Please study it carefully as the remainder of this document is written with this flow in mind. +The expected flow for obtaining a valid access token via OAuth is depicted in the [MCP Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-flow-steps). For convenience, we've embedded a copy of the authorization flow below. Please study it carefully as the remainder of this document is written with this flow in mind. ```mermaid sequenceDiagram From 39d7fec500a623320176613e24c4bcceeac90213 Mon Sep 17 00:00:00 2001 From: Lulu <59149422+LuluBeatson@users.noreply.github.com> Date: Mon, 7 Jul 2025 12:59:49 +0100 Subject: [PATCH 13/18] Update `list_commits` Filtering Descriptions (#634) * update sha arg description for list_commits, get_file_contents * update perPage description for pagination to inform of default 30 * toolsnaps, docs * revert perPage description --- README.md | 6 +++--- pkg/github/__toolsnaps__/get_file_contents.snap | 2 +- pkg/github/__toolsnaps__/list_commits.snap | 4 ++-- pkg/github/repositories.go | 6 +++--- pkg/github/server.go | 4 +++- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 68742752f..b281ad042 100644 --- a/README.md +++ b/README.md @@ -902,7 +902,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `path`: Path to file/directory (directories must end with a slash '/') (string, required) - `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional) - `repo`: Repository name (string, required) - - `sha`: Accepts optional git sha, if sha is specified it will be used instead of ref (string, optional) + - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional) - **get_tag** - Get tag details - `owner`: Repository owner (string, required) @@ -916,12 +916,12 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `repo`: Repository name (string, required) - **list_commits** - List commits - - `author`: Author username or email address (string, optional) + - `author`: Author username or email address to filter commits by (string, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - - `sha`: The commit SHA, branch name, or tag name to list commits from. If not specified, defaults to the repository's default branch. (string, optional) + - `sha`: Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA. (string, optional) - **list_tags** - List tags - `owner`: Repository owner (string, required) diff --git a/pkg/github/__toolsnaps__/get_file_contents.snap b/pkg/github/__toolsnaps__/get_file_contents.snap index b3975abbc..e550e8db8 100644 --- a/pkg/github/__toolsnaps__/get_file_contents.snap +++ b/pkg/github/__toolsnaps__/get_file_contents.snap @@ -23,7 +23,7 @@ "type": "string" }, "sha": { - "description": "Accepts optional git sha, if sha is specified it will be used instead of ref", + "description": "Accepts optional commit SHA. If specified, it will be used instead of ref", "type": "string" } }, diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap index c43f7b0cd..a802436c2 100644 --- a/pkg/github/__toolsnaps__/list_commits.snap +++ b/pkg/github/__toolsnaps__/list_commits.snap @@ -7,7 +7,7 @@ "inputSchema": { "properties": { "author": { - "description": "Author username or email address", + "description": "Author username or email address to filter commits by", "type": "string" }, "owner": { @@ -30,7 +30,7 @@ "type": "string" }, "sha": { - "description": "The commit SHA, branch name, or tag name to list commits from. If not specified, defaults to the repository's default branch.", + "description": "Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA.", "type": "string" } }, diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 29f776a05..cf71a5839 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -111,10 +111,10 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t mcp.Description("Repository name"), ), mcp.WithString("sha", - mcp.Description("The commit SHA, branch name, or tag name to list commits from. If not specified, defaults to the repository's default branch."), + mcp.Description("Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA."), ), mcp.WithString("author", - mcp.Description("Author username or email address"), + mcp.Description("Author username or email address to filter commits by"), ), WithPagination(), ), @@ -470,7 +470,7 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t mcp.Description("Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`"), ), mcp.WithString("sha", - mcp.Description("Accepts optional git sha, if sha is specified it will be used instead of ref"), + mcp.Description("Accepts optional commit SHA. If specified, it will be used instead of ref"), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { diff --git a/pkg/github/server.go b/pkg/github/server.go index 85d078f1b..e7b831791 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -175,7 +175,9 @@ func OptionalStringArrayParam(r mcp.CallToolRequest, p string) ([]string, error) } // WithPagination returns a ToolOption that adds "page" and "perPage" parameters to the tool. -// The "page" parameter is optional, min 1. The "perPage" parameter is optional, min 1, max 100. +// The "page" parameter is optional, min 1. +// The "perPage" parameter is optional, min 1, max 100. If unset, defaults to 30. +// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api func WithPagination() mcp.ToolOption { return func(tool *mcp.Tool) { mcp.WithNumber("page", From 3730b840fea960643d3e3ee2e506619a0bf7ab62 Mon Sep 17 00:00:00 2001 From: Lulu <59149422+LuluBeatson@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:43:05 +0100 Subject: [PATCH 14/18] fix: get_discussion graphQL invalid field (#648) * rm State which does not exist on type Discussion * update Test_GetDiscussion * use Discussion object instead of Issue --- pkg/github/discussions.go | 32 ++++++++++++-------------------- pkg/github/discussions_test.go | 28 ++++++++++------------------ 2 files changed, 22 insertions(+), 38 deletions(-) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index a7ec8e20f..3e53a633b 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -62,7 +62,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp } // Now execute the discussions query - var discussions []*github.Issue + var discussions []*github.Discussion if categoryID != nil { // Query with category filter (server-side filtering) var query struct { @@ -89,17 +89,15 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp return mcp.NewToolResultError(err.Error()), nil } - // Map nodes to GitHub Issue objects + // Map nodes to GitHub Discussion objects for _, n := range query.Repository.Discussions.Nodes { - di := &github.Issue{ + di := &github.Discussion{ 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))), - }, + DiscussionCategory: &github.DiscussionCategory{ + Name: github.Ptr(string(n.Category.Name)), }, } discussions = append(discussions, di) @@ -129,17 +127,15 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp return mcp.NewToolResultError(err.Error()), nil } - // Map nodes to GitHub Issue objects + // Map nodes to GitHub Discussion objects for _, n := range query.Repository.Discussions.Nodes { - di := &github.Issue{ + di := &github.Discussion{ 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))), - }, + DiscussionCategory: &github.DiscussionCategory{ + Name: github.Ptr(string(n.Category.Name)), }, } discussions = append(discussions, di) @@ -195,7 +191,6 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper Discussion struct { Number githubv4.Int Body githubv4.String - State githubv4.String CreatedAt githubv4.DateTime URL githubv4.String `graphql:"url"` Category struct { @@ -213,16 +208,13 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper return mcp.NewToolResultError(err.Error()), nil } d := q.Repository.Discussion - discussion := &github.Issue{ + discussion := &github.Discussion{ 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))), - }, + DiscussionCategory: &github.DiscussionCategory{ + Name: github.Ptr(string(d.Category.Name)), }, } out, err := json.Marshal(discussion) diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 545d604f9..5132c6ce0 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "net/http" - "strings" "testing" "time" @@ -168,17 +167,17 @@ func Test_ListDiscussions(t *testing.T) { } require.NoError(t, err) - var returnedDiscussions []*github.Issue + var returnedDiscussions []*github.Discussion 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 + // Verify that all returned discussions have a category 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") + require.NotNil(t, discussion.DiscussionCategory, "Discussion should have category") + assert.NotEmpty(t, *discussion.DiscussionCategory.Name, "Discussion should have category name") } } }) @@ -200,7 +199,6 @@ func Test_GetDiscussion(t *testing.T) { Discussion struct { Number githubv4.Int Body githubv4.String - State githubv4.String CreatedAt githubv4.DateTime URL githubv4.String `graphql:"url"` Category struct { @@ -218,7 +216,7 @@ func Test_GetDiscussion(t *testing.T) { name string response githubv4mock.GQLResponse expectError bool - expected *github.Issue + expected *github.Discussion errContains string }{ { @@ -227,23 +225,19 @@ func Test_GetDiscussion(t *testing.T) { "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{ + expected: &github.Discussion{ 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"), - }, + DiscussionCategory: &github.DiscussionCategory{ + Name: github.Ptr("General"), }, }, }, @@ -272,15 +266,13 @@ func Test_GetDiscussion(t *testing.T) { } require.NoError(t, err) - var out github.Issue + var out github.Discussion 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) + assert.Equal(t, *tc.expected.DiscussionCategory.Name, *out.DiscussionCategory.Name) }) } } From 0cf70ebdb5486293095848844620cc01e4f2bedf Mon Sep 17 00:00:00 2001 From: Tony Truong Date: Mon, 7 Jul 2025 18:07:48 +0200 Subject: [PATCH 15/18] Remove redundant param for get_me and update contribution guide (#649) * remove reason param for get_me * updating toolsnap * update contributing * updating tool get_me * add small changes * update snapshots --- CONTRIBUTING.md | 19 +++++++++++-------- README.md | 2 +- pkg/github/__toolsnaps__/get_me.snap | 9 ++------- pkg/github/context_tools.go | 5 +---- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 314e4e0b2..b4012f0b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,18 +14,21 @@ Please note that this project is released with a [Contributor Code of Conduct](C These are one time installations required to be able to test your changes locally as part of the pull request (PR) submission process. -1. install Go [through download](https://go.dev/doc/install) | [through Homebrew](https://formulae.brew.sh/formula/go) -1. [install golangci-lint v2](https://golangci-lint.run/welcome/install/#local-installation) +1. Install Go [through download](https://go.dev/doc/install) | [through Homebrew](https://formulae.brew.sh/formula/go) +2. [Install golangci-lint v2](https://golangci-lint.run/welcome/install/#local-installation) ## Submitting a pull request 1. [Fork][fork] and clone the repository -1. Make sure the tests pass on your machine: `go test -v ./...` -1. Make sure linter passes on your machine: `golangci-lint run` -1. Create a new branch: `git checkout -b my-branch-name` -1. Make your change, add tests, and make sure the tests and linter still pass -1. Push to your fork and [submit a pull request][pr] targeting the `main` branch -1. Pat yourself on the back and wait for your pull request to be reviewed and merged. +2. Make sure the tests pass on your machine: `go test -v ./...` +3. Make sure linter passes on your machine: `golangci-lint run` +4. Create a new branch: `git checkout -b my-branch-name` +5. Add your changes and tests, and make sure the Action workflows still pass + - Run linter: `script/lint` + - Update snapshots and run tests: `UPDATE_TOOLSNAPS=true go test ./...` + - Update readme documentation: `script/generate-docs` +6. Push to your fork and [submit a pull request][pr] targeting the `main` branch +7. Pat yourself on the back and wait for your pull request to be reviewed and merged. Here are a few things you can do that will increase the likelihood of your pull request being accepted: diff --git a/README.md b/README.md index b281ad042..8ba842a46 100644 --- a/README.md +++ b/README.md @@ -550,7 +550,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description Context - **get_me** - Get my user profile - - `reason`: Optional: the reason for requesting the user information (string, optional) + - No parameters required
diff --git a/pkg/github/__toolsnaps__/get_me.snap b/pkg/github/__toolsnaps__/get_me.snap index fc098f9d1..13b061741 100644 --- a/pkg/github/__toolsnaps__/get_me.snap +++ b/pkg/github/__toolsnaps__/get_me.snap @@ -3,14 +3,9 @@ "title": "Get my user profile", "readOnlyHint": true }, - "description": "Get details of the authenticated GitHub user. Use this when a request includes \"me\", \"my\". The output will not change unless the user changes their profile, so only call this once.", + "description": "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls.", "inputSchema": { - "properties": { - "reason": { - "description": "Optional: the reason for requesting the user information", - "type": "string" - } - }, + "properties": {}, "type": "object" }, "name": "get_me" diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 3525277fe..9817fea7b 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -35,14 +35,11 @@ type UserDetails struct { // GetMe creates a tool to get details of the authenticated user. func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { tool := mcp.NewTool("get_me", - mcp.WithDescription(t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request includes \"me\", \"my\". The output will not change unless the user changes their profile, so only call this once.")), + mcp.WithDescription(t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_GET_ME_USER_TITLE", "Get my user profile"), ReadOnlyHint: ToBoolPtr(true), }), - mcp.WithString("reason", - mcp.Description("Optional: the reason for requesting the user information"), - ), ) type args struct{} From 3341e6bc461b461f0789518879f97bbd86ef7ee9 Mon Sep 17 00:00:00 2001 From: Lulu <59149422+LuluBeatson@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:51:24 +0100 Subject: [PATCH 16/18] Update `create_or_update_file` SHA Arg Description (#651) * sha arg prompt as required if updating file * generate docs and toolsnaps * shorten --- README.md | 2 +- pkg/github/__toolsnaps__/create_or_update_file.snap | 2 +- pkg/github/repositories.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8ba842a46..70e8c3ca1 100644 --- a/README.md +++ b/README.md @@ -870,7 +870,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `owner`: Repository owner (username or organization) (string, required) - `path`: Path where to create/update the file (string, required) - `repo`: Repository name (string, required) - - `sha`: SHA of file being replaced (for updates) (string, optional) + - `sha`: Required if updating an existing file. The blob SHA of the file being replaced. (string, optional) - **create_repository** - Create repository - `autoInit`: Initialize with README (boolean, optional) diff --git a/pkg/github/__toolsnaps__/create_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap index dfbb34423..61adef72c 100644 --- a/pkg/github/__toolsnaps__/create_or_update_file.snap +++ b/pkg/github/__toolsnaps__/create_or_update_file.snap @@ -31,7 +31,7 @@ "type": "string" }, "sha": { - "description": "SHA of file being replaced (for updates)", + "description": "Required if updating an existing file. The blob SHA of the file being replaced.", "type": "string" } }, diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index cf71a5839..8a7a8af4a 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -288,7 +288,7 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF mcp.Description("Branch to create/update the file in"), ), mcp.WithString("sha", - mcp.Description("SHA of file being replaced (for updates)"), + mcp.Description("Required if updating an existing file. The blob SHA of the file being replaced."), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { From 89bb9286ca5b758df8b95fccf90e94efbf9d25da Mon Sep 17 00:00:00 2001 From: Nhu Do Date: Wed, 9 Jul 2025 15:04:48 -0400 Subject: [PATCH 17/18] Include Copilot coding agent tool on the remote GitHub MCP server (#656) * Update README.md * Update remote-server.md --- README.md | 15 +++++++++++++++ docs/remote-server.md | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/README.md b/README.md index 70e8c3ca1..8cff2e138 100644 --- a/README.md +++ b/README.md @@ -982,6 +982,21 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
+### Additional Tools in Remote Github MCP Server + +
+ +Copilot coding agent + +- **create_pull_request_with_copilot** - Perform task with GitHub Copilot coding agent + - `owner`: Repository owner. You can guess the owner, but confirm it with the user before proceeding. (string, required) + - `repo`: Repository name. You can guess the repository name, but confirm it with the user before proceeding. (string, required) + - `problem_statement`: Detailed description of the task to be performed (e.g., 'Implement a feature that does X', 'Fix bug Y', etc.) (string, required) + - `title`: Title for the pull request that will be created (string, required) + - `base_ref`: Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch (string, optional) + +
+ ## Library Usage The exported Go API of this module should currently be considered unstable, and subject to breaking changes. In the future, we may offer stability; please file an issue if there is a use case where this would be valuable. diff --git a/docs/remote-server.md b/docs/remote-server.md index c36124ecc..49794c605 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -10,6 +10,8 @@ Easily connect to the GitHub MCP Server using the hosted version – no local se The remote GitHub MCP server is built using this repository as a library, and binding it into GitHub server infrastructure with an internal repository. You can open issues and propose changes in this repository, and we regularly update the remote server to include the latest version of this code. +The remote server has [additional tools](#toolsets-only-available-in-the-remote-mcp-server) that are not available in the local MCP server, such as the `create_pull_request_with_copilot` tool for invoking Copilot coding agent. + ## Remote MCP Toolsets Below is a table of available toolsets for the remote GitHub MCP Server. Each toolset is provided as a distinct URL so you can mix and match to create the perfect combination of tools for your use-case. Add `/readonly` to the end of any URL to restrict the tools in the toolset to only those that enable read access. We also provide the option to use [headers](#headers) instead. @@ -33,6 +35,14 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to +### Additional _Remote_ Server Toolsets + +These toolsets are only available in the remote GitHub MCP Server and are not included in the local MCP server. + +| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | +| -------------------- | --------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Copilot coding agent | Perform task with GitHub Copilot coding agent | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | + ### Headers You can configure toolsets and readonly mode by providing HTTP headers in your server configuration. From 42e5ce9b88ee289bb8d7a297c1d8a580e06c9e86 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Fri, 11 Jul 2025 09:21:45 +0100 Subject: [PATCH 18/18] Tommy/(Bug-fix): adjust tool description to account for author in prompt (#658) * adjust tool description * removed dead code * improve desription * update description for tests --- pkg/github/__toolsnaps__/list_pull_requests.snap | 2 +- pkg/github/pullrequests.go | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/github/__toolsnaps__/list_pull_requests.snap b/pkg/github/__toolsnaps__/list_pull_requests.snap index b8369784d..fee7e2ff1 100644 --- a/pkg/github/__toolsnaps__/list_pull_requests.snap +++ b/pkg/github/__toolsnaps__/list_pull_requests.snap @@ -3,7 +3,7 @@ "title": "List pull requests", "readOnlyHint": true }, - "description": "List pull requests in a GitHub repository.", + "description": "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.", "inputSchema": { "properties": { "base": { diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index bad822b13..32c7e850c 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -330,7 +330,7 @@ func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu // ListPullRequests creates a tool to list and filter repository pull requests. func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { return mcp.NewTool("list_pull_requests", - mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository.")), + mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_PULL_REQUESTS_USER_TITLE", "List pull requests"), ReadOnlyHint: ToBoolPtr(true), @@ -396,7 +396,6 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } - opts := &github.PullRequestListOptions{ State: state, Head: head, pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy