From f04c137bdd1654980ffc2f86ad3e77f569ef0224 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:37:59 +0000 Subject: [PATCH 01/57] Reduce context usage for getting a Pull Request (#2017) * introduce minimal pr type * update to use time.RFC3339 * confine change to single PR --- pkg/github/minimal_types.go | 132 ++++++++++++++++++++++++++++++++ pkg/github/pullrequests.go | 7 +- pkg/github/pullrequests_test.go | 12 +-- 3 files changed, 140 insertions(+), 11 deletions(-) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index a33bcec7a..f1dcfe06e 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -1,6 +1,8 @@ package github import ( + "time" + "github.com/google/go-github/v82/github" ) @@ -134,8 +136,138 @@ type MinimalProject struct { OwnerType string `json:"owner_type,omitempty"` } +// MinimalPullRequest is the trimmed output type for pull request objects to reduce verbosity. +type MinimalPullRequest struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + State string `json:"state"` + Draft bool `json:"draft"` + Merged bool `json:"merged"` + MergeableState string `json:"mergeable_state,omitempty"` + HTMLURL string `json:"html_url"` + User *MinimalUser `json:"user,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` + RequestedReviewers []string `json:"requested_reviewers,omitempty"` + MergedBy string `json:"merged_by,omitempty"` + Head *MinimalPRBranch `json:"head,omitempty"` + Base *MinimalPRBranch `json:"base,omitempty"` + Additions int `json:"additions,omitempty"` + Deletions int `json:"deletions,omitempty"` + ChangedFiles int `json:"changed_files,omitempty"` + Commits int `json:"commits,omitempty"` + Comments int `json:"comments,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` + MergedAt string `json:"merged_at,omitempty"` + Milestone string `json:"milestone,omitempty"` +} + +// MinimalPRBranch is the trimmed output type for pull request branch references. +type MinimalPRBranch struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + Repo *MinimalPRBranchRepo `json:"repo,omitempty"` +} + +// MinimalPRBranchRepo is the trimmed repo info nested inside a PR branch. +type MinimalPRBranchRepo struct { + FullName string `json:"full_name"` + Description string `json:"description,omitempty"` +} + // Helper functions +func convertToMinimalPullRequest(pr *github.PullRequest) MinimalPullRequest { + m := MinimalPullRequest{ + Number: pr.GetNumber(), + Title: pr.GetTitle(), + Body: pr.GetBody(), + State: pr.GetState(), + Draft: pr.GetDraft(), + Merged: pr.GetMerged(), + MergeableState: pr.GetMergeableState(), + HTMLURL: pr.GetHTMLURL(), + User: convertToMinimalUser(pr.GetUser()), + Additions: pr.GetAdditions(), + Deletions: pr.GetDeletions(), + ChangedFiles: pr.GetChangedFiles(), + Commits: pr.GetCommits(), + Comments: pr.GetComments(), + } + + if pr.CreatedAt != nil { + m.CreatedAt = pr.CreatedAt.Format(time.RFC3339) + } + if pr.UpdatedAt != nil { + m.UpdatedAt = pr.UpdatedAt.Format(time.RFC3339) + } + if pr.ClosedAt != nil { + m.ClosedAt = pr.ClosedAt.Format(time.RFC3339) + } + if pr.MergedAt != nil { + m.MergedAt = pr.MergedAt.Format(time.RFC3339) + } + + for _, label := range pr.Labels { + if label != nil { + m.Labels = append(m.Labels, label.GetName()) + } + } + + for _, assignee := range pr.Assignees { + if assignee != nil { + m.Assignees = append(m.Assignees, assignee.GetLogin()) + } + } + + for _, reviewer := range pr.RequestedReviewers { + if reviewer != nil { + m.RequestedReviewers = append(m.RequestedReviewers, reviewer.GetLogin()) + } + } + + if mergedBy := pr.GetMergedBy(); mergedBy != nil { + m.MergedBy = mergedBy.GetLogin() + } + + if head := pr.Head; head != nil { + m.Head = convertToMinimalPRBranch(head) + } + + if base := pr.Base; base != nil { + m.Base = convertToMinimalPRBranch(base) + } + + if milestone := pr.GetMilestone(); milestone != nil { + m.Milestone = milestone.GetTitle() + } + + return m +} + +func convertToMinimalPRBranch(branch *github.PullRequestBranch) *MinimalPRBranch { + if branch == nil { + return nil + } + + b := &MinimalPRBranch{ + Ref: branch.GetRef(), + SHA: branch.GetSHA(), + } + + if repo := branch.GetRepo(); repo != nil { + b.Repo = &MinimalPRBranchRepo{ + FullName: repo.GetFullName(), + Description: repo.GetDescription(), + } + } + + return b +} + func convertToMinimalProject(fullProject *github.ProjectV2) *MinimalProject { if fullProject == nil { return nil diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 1043870f1..58edc07dc 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -186,12 +186,9 @@ func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDepende } } - r, err := json.Marshal(pr) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + minimalPR := convertToMinimalPullRequest(pr) - return utils.NewToolResultText(string(r)), nil + return MarshalledTextResult(minimalPR), nil } func GetPullRequestDiff(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 52dbb74a0..570b1906f 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -127,14 +127,14 @@ func Test_GetPullRequest(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedPR github.PullRequest + // Unmarshal and verify the minimal result + var returnedPR MinimalPullRequest err = json.Unmarshal([]byte(textContent.Text), &returnedPR) require.NoError(t, err) - assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number) - assert.Equal(t, *tc.expectedPR.Title, *returnedPR.Title) - assert.Equal(t, *tc.expectedPR.State, *returnedPR.State) - assert.Equal(t, *tc.expectedPR.HTMLURL, *returnedPR.HTMLURL) + assert.Equal(t, tc.expectedPR.GetNumber(), returnedPR.Number) + assert.Equal(t, tc.expectedPR.GetTitle(), returnedPR.Title) + assert.Equal(t, tc.expectedPR.GetState(), returnedPR.State) + assert.Equal(t, tc.expectedPR.GetHTMLURL(), returnedPR.HTMLURL) }) } } From dc7e789dc46fe5b603d8f6c9049cb8b9c93bc267 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:33:15 +0000 Subject: [PATCH 02/57] Optimize context usage for getting an issue using the `issue_read` tool (#2022) * minimize context usage using minimal types for get issue comments * preserver reactions field * add back author association --- pkg/github/issues.go | 7 +-- pkg/github/issues_test.go | 14 ++--- pkg/github/minimal_types.go | 103 ++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 12 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 83fd46c3c..dcdec6d45 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -376,12 +376,9 @@ func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, } } - r, err := json.Marshal(issue) - if err != nil { - return nil, fmt.Errorf("failed to marshal issue: %w", err) - } + minimalIssue := convertToMinimalIssue(issue) - return utils.NewToolResultText(string(r)), nil + return MarshalledTextResult(minimalIssue), nil } func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 1eeec2246..c8ff34843 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -345,15 +345,15 @@ func Test_GetIssue(t *testing.T) { textContent := getTextResult(t, result) - var returnedIssue github.Issue + var returnedIssue MinimalIssue err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) require.NoError(t, err) - assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) - assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) - assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) - assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) - assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) - assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) + assert.Equal(t, tc.expectedIssue.GetNumber(), returnedIssue.Number) + assert.Equal(t, tc.expectedIssue.GetTitle(), returnedIssue.Title) + assert.Equal(t, tc.expectedIssue.GetBody(), returnedIssue.Body) + assert.Equal(t, tc.expectedIssue.GetState(), returnedIssue.State) + assert.Equal(t, tc.expectedIssue.GetHTMLURL(), returnedIssue.HTMLURL) + assert.Equal(t, tc.expectedIssue.GetUser().GetLogin(), returnedIssue.User.Login) }) } } diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index f1dcfe06e..4031bfa2c 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -136,6 +136,43 @@ type MinimalProject struct { OwnerType string `json:"owner_type,omitempty"` } +// MinimalReactions is the trimmed output type for reaction summaries, dropping the API URL. +type MinimalReactions struct { + TotalCount int `json:"total_count"` + PlusOne int `json:"+1"` + MinusOne int `json:"-1"` + Laugh int `json:"laugh"` + Confused int `json:"confused"` + Heart int `json:"heart"` + Hooray int `json:"hooray"` + Rocket int `json:"rocket"` + Eyes int `json:"eyes"` +} + +// MinimalIssue is the trimmed output type for issue objects to reduce verbosity. +type MinimalIssue struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + State string `json:"state"` + StateReason string `json:"state_reason,omitempty"` + Draft bool `json:"draft,omitempty"` + Locked bool `json:"locked,omitempty"` + HTMLURL string `json:"html_url"` + User *MinimalUser `json:"user,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Milestone string `json:"milestone,omitempty"` + Comments int `json:"comments,omitempty"` + Reactions *MinimalReactions `json:"reactions,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` + ClosedBy string `json:"closed_by,omitempty"` + IssueType string `json:"issue_type,omitempty"` +} + // MinimalPullRequest is the trimmed output type for pull request objects to reduce verbosity. type MinimalPullRequest struct { Number int `json:"number"` @@ -180,6 +217,72 @@ type MinimalPRBranchRepo struct { // Helper functions +func convertToMinimalIssue(issue *github.Issue) MinimalIssue { + m := MinimalIssue{ + Number: issue.GetNumber(), + Title: issue.GetTitle(), + Body: issue.GetBody(), + State: issue.GetState(), + StateReason: issue.GetStateReason(), + Draft: issue.GetDraft(), + Locked: issue.GetLocked(), + HTMLURL: issue.GetHTMLURL(), + User: convertToMinimalUser(issue.GetUser()), + AuthorAssociation: issue.GetAuthorAssociation(), + Comments: issue.GetComments(), + } + + if issue.CreatedAt != nil { + m.CreatedAt = issue.CreatedAt.Format(time.RFC3339) + } + if issue.UpdatedAt != nil { + m.UpdatedAt = issue.UpdatedAt.Format(time.RFC3339) + } + if issue.ClosedAt != nil { + m.ClosedAt = issue.ClosedAt.Format(time.RFC3339) + } + + for _, label := range issue.Labels { + if label != nil { + m.Labels = append(m.Labels, label.GetName()) + } + } + + for _, assignee := range issue.Assignees { + if assignee != nil { + m.Assignees = append(m.Assignees, assignee.GetLogin()) + } + } + + if closedBy := issue.GetClosedBy(); closedBy != nil { + m.ClosedBy = closedBy.GetLogin() + } + + if milestone := issue.GetMilestone(); milestone != nil { + m.Milestone = milestone.GetTitle() + } + + if issueType := issue.GetType(); issueType != nil { + m.IssueType = issueType.GetName() + } + + if r := issue.Reactions; r != nil { + m.Reactions = &MinimalReactions{ + TotalCount: r.GetTotalCount(), + PlusOne: r.GetPlusOne(), + MinusOne: r.GetMinusOne(), + Laugh: r.GetLaugh(), + Confused: r.GetConfused(), + Heart: r.GetHeart(), + Hooray: r.GetHooray(), + Rocket: r.GetRocket(), + Eyes: r.GetEyes(), + } + } + + return m +} + func convertToMinimalPullRequest(pr *github.PullRequest) MinimalPullRequest { m := MinimalPullRequest{ Number: pr.GetNumber(), From 543a1fa01936c6e1ea36e04ce66ca14691691ece Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:36:28 +0000 Subject: [PATCH 03/57] use minimal types for issue comments to optimize context usage (#2024) --- pkg/github/issues.go | 8 +++---- pkg/github/issues_test.go | 8 +++---- pkg/github/minimal_types.go | 45 +++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index dcdec6d45..cd7085550 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -433,12 +433,12 @@ func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDepen comments = filteredComments } - r, err := json.Marshal(comments) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + minimalComments := make([]MinimalIssueComment, 0, len(comments)) + for _, comment := range comments { + minimalComments = append(minimalComments, convertToMinimalIssueComment(comment)) } - return utils.NewToolResultText(string(r)), nil + return MarshalledTextResult(minimalComments), nil } func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index c8ff34843..512ba8a6b 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -2020,16 +2020,16 @@ func Test_GetIssueComments(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedComments []*github.IssueComment + var returnedComments []MinimalIssueComment err = json.Unmarshal([]byte(textContent.Text), &returnedComments) require.NoError(t, err) assert.Equal(t, len(tc.expectedComments), len(returnedComments)) for i := range tc.expectedComments { require.NotNil(t, tc.expectedComments[i].User) require.NotNil(t, returnedComments[i].User) - assert.Equal(t, tc.expectedComments[i].GetID(), returnedComments[i].GetID()) - assert.Equal(t, tc.expectedComments[i].GetBody(), returnedComments[i].GetBody()) - assert.Equal(t, tc.expectedComments[i].GetUser().GetLogin(), returnedComments[i].GetUser().GetLogin()) + assert.Equal(t, tc.expectedComments[i].GetID(), returnedComments[i].ID) + assert.Equal(t, tc.expectedComments[i].GetBody(), returnedComments[i].Body) + assert.Equal(t, tc.expectedComments[i].GetUser().GetLogin(), returnedComments[i].User.Login) } }) } diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 4031bfa2c..2010f5610 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -173,6 +173,18 @@ type MinimalIssue struct { IssueType string `json:"issue_type,omitempty"` } +// MinimalIssueComment is the trimmed output type for issue comment objects to reduce verbosity. +type MinimalIssueComment struct { + ID int64 `json:"id"` + Body string `json:"body,omitempty"` + HTMLURL string `json:"html_url"` + User *MinimalUser `json:"user,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + Reactions *MinimalReactions `json:"reactions,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + // MinimalPullRequest is the trimmed output type for pull request objects to reduce verbosity. type MinimalPullRequest struct { Number int `json:"number"` @@ -283,6 +295,39 @@ func convertToMinimalIssue(issue *github.Issue) MinimalIssue { return m } +func convertToMinimalIssueComment(comment *github.IssueComment) MinimalIssueComment { + m := MinimalIssueComment{ + ID: comment.GetID(), + Body: comment.GetBody(), + HTMLURL: comment.GetHTMLURL(), + User: convertToMinimalUser(comment.GetUser()), + AuthorAssociation: comment.GetAuthorAssociation(), + } + + if comment.CreatedAt != nil { + m.CreatedAt = comment.CreatedAt.Format(time.RFC3339) + } + if comment.UpdatedAt != nil { + m.UpdatedAt = comment.UpdatedAt.Format(time.RFC3339) + } + + if r := comment.Reactions; r != nil { + m.Reactions = &MinimalReactions{ + TotalCount: r.GetTotalCount(), + PlusOne: r.GetPlusOne(), + MinusOne: r.GetMinusOne(), + Laugh: r.GetLaugh(), + Confused: r.GetConfused(), + Heart: r.GetHeart(), + Hooray: r.GetHooray(), + Rocket: r.GetRocket(), + Eyes: r.GetEyes(), + } + } + + return m +} + func convertToMinimalPullRequest(pr *github.PullRequest) MinimalPullRequest { m := MinimalPullRequest{ Number: pr.GetNumber(), From 67b8bf2ed266a1edd03cb0f79ed8538a01e3101b Mon Sep 17 00:00:00 2001 From: e-straight <142330569+e-straight@users.noreply.github.com> Date: Wed, 18 Feb 2026 01:14:40 -0800 Subject: [PATCH 04/57] Add ProjectV2 status update tools (list, get, create) (#1987) * Add ProjectV2 status update tools (list, get, create) Closes https://github.com/github/github-mcp-server/issues/1963 Add three new individual tools and wire them into the consolidated project tools for managing GitHub ProjectV2 status updates: - list_project_status_updates / projects_list: List status updates for a project with pagination, ordered by creation date descending - get_project_status_update / projects_get: Fetch a single status update by node ID - create_project_status_update / projects_write: Create a status update with optional body, status, start_date, and target_date New GraphQL types and queries (statusUpdateNode, statusUpdatesUserQuery, statusUpdatesOrgQuery, statusUpdateNodeQuery) support both user-owned and org-owned projects. The CreateProjectV2StatusUpdateInput type is defined locally since the shurcooL/githubv4 library does not include it. Also includes quality improvements discovered during implementation: - Extract resolveProjectNodeID helper to deduplicate ~70 lines of project ID resolution logic shared between addProjectItem and createProjectStatusUpdate - Add client-side YYYY-MM-DD date format validation for start_date and target_date fields before sending to the API - Fix brittle node type check in getProjectStatusUpdate that relied on stringifying a githubv4.ID and comparing to "" - Refactor createProjectStatusUpdate to accept typed parameters instead of raw args map - Add deprecated tool aliases for all three new individual tools - Add ProjectResolveIDFailedError constant for consistent error reporting Test coverage includes 21 subtests covering both user and org paths, pagination, error handling, input validation, field verification, and consolidated tool dispatch. * Fix projects_get required params and harden status update tools Loosen projects_get schema to only require "method", since get_project_status_update only needs status_update_id and never uses owner or project_number. Also use pointer types for optional statusUpdateNode fields, add owner_type validation for list/create status updates, clamp negative per_page values, and fix resolveProjectNodeID to return "" instead of nil on error. * Resolve conflicts * Update doc * Update aliases * Dont update tool renaming docs --------- Co-authored-by: e-straight Co-authored-by: JoannaaKL --- README.md | 11 +- pkg/github/__toolsnaps__/projects_get.snap | 11 +- pkg/github/__toolsnaps__/projects_list.snap | 5 +- pkg/github/__toolsnaps__/projects_write.snap | 28 +- pkg/github/minimal_types.go | 10 + pkg/github/projects.go | 470 ++++++++++++++++--- pkg/github/projects_test.go | 208 +++++++- pkg/github/toolset_instructions.go | 2 + 8 files changed, 667 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index f0c1a7401..6e964a192 100644 --- a/README.md +++ b/README.md @@ -983,9 +983,10 @@ The following sets of tools are available: - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. Only used for 'get_project_item' method. (string[], optional) - `item_id`: The item's ID. Required for 'get_project_item' method. (number, optional) - `method`: The method to execute (string, required) - - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required) + - `owner`: The owner (user or organization login). The name is not case sensitive. (string, optional) - `owner_type`: Owner type (user or org). If not provided, will be automatically detected. (string, optional) - - `project_number`: The project's number. (number, required) + - `project_number`: The project's number. (number, optional) + - `status_update_id`: The node ID of the project status update. Required for 'get_project_status_update' method. (string, optional) - **projects_list** - List GitHub Projects resources - **Required OAuth Scopes**: `read:project` @@ -997,11 +998,12 @@ The following sets of tools are available: - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required) - `owner_type`: Owner type (user or org). If not provided, will automatically try both. (string, optional) - `per_page`: Results per page (max 50) (number, optional) - - `project_number`: The project's number. Required for 'list_project_fields' and 'list_project_items' methods. (number, optional) + - `project_number`: The project's number. Required for 'list_project_fields', 'list_project_items', and 'list_project_status_updates' methods. (number, optional) - `query`: Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax. (string, optional) - **projects_write** - Modify GitHub Project items - **Required OAuth Scopes**: `project` + - `body`: The body of the status update (markdown). Used for 'create_project_status_update' method. (string, optional) - `issue_number`: The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional) - `item_id`: The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. (number, optional) - `item_owner`: The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional) @@ -1012,6 +1014,9 @@ The following sets of tools are available: - `owner_type`: Owner type (user or org). If not provided, will be automatically detected. (string, optional) - `project_number`: The project's number. (number, required) - `pull_request_number`: The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional) + - `start_date`: The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional) + - `status`: The status of the project. Used for 'create_project_status_update' method. (string, optional) + - `target_date`: The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional) - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"}. Required for 'update_project_item' method. (object, optional) diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap index cb5013d74..864f61d83 100644 --- a/pkg/github/__toolsnaps__/projects_get.snap +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -26,7 +26,8 @@ "enum": [ "get_project", "get_project_field", - "get_project_item" + "get_project_item", + "get_project_status_update" ], "type": "string" }, @@ -45,12 +46,14 @@ "project_number": { "description": "The project's number.", "type": "number" + }, + "status_update_id": { + "description": "The node ID of the project status update. Required for 'get_project_status_update' method.", + "type": "string" } }, "required": [ - "method", - "owner", - "project_number" + "method" ], "type": "object" }, diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap index f12452b5a..c2bb0d3f4 100644 --- a/pkg/github/__toolsnaps__/projects_list.snap +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -26,7 +26,8 @@ "enum": [ "list_projects", "list_project_fields", - "list_project_items" + "list_project_items", + "list_project_status_updates" ], "type": "string" }, @@ -47,7 +48,7 @@ "type": "number" }, "project_number": { - "description": "The project's number. Required for 'list_project_fields' and 'list_project_items' methods.", + "description": "The project's number. Required for 'list_project_fields', 'list_project_items', and 'list_project_status_updates' methods.", "type": "number" }, "query": { diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index d2d871bcd..f6d3197b8 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -3,9 +3,13 @@ "destructiveHint": true, "title": "Modify GitHub Project items" }, - "description": "Add, update, or delete project items in a GitHub Project.", + "description": "Add, update, or delete project items, or create status updates in a GitHub Project.", "inputSchema": { "properties": { + "body": { + "description": "The body of the status update (markdown). Used for 'create_project_status_update' method.", + "type": "string" + }, "issue_number": { "description": "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.", "type": "number" @@ -35,7 +39,8 @@ "enum": [ "add_project_item", "update_project_item", - "delete_project_item" + "delete_project_item", + "create_project_status_update" ], "type": "string" }, @@ -59,6 +64,25 @@ "description": "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.", "type": "number" }, + "start_date": { + "description": "The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", + "type": "string" + }, + "status": { + "description": "The status of the project. Used for 'create_project_status_update' method.", + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ], + "type": "string" + }, + "target_date": { + "description": "The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", + "type": "string" + }, "updated_field": { "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", "type": "object" diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 2010f5610..adb8a386d 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -227,6 +227,16 @@ type MinimalPRBranchRepo struct { Description string `json:"description,omitempty"` } +type MinimalProjectStatusUpdate struct { + ID string `json:"id"` + Body string `json:"body,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + StartDate string `json:"start_date,omitempty"` + TargetDate string `json:"target_date,omitempty"` + Creator *MinimalUser `json:"creator,omitempty"` +} + // Helper functions func convertToMinimalIssue(issue *github.Issue) MinimalIssue { diff --git a/pkg/github/projects.go b/pkg/github/projects.go index d2ab05008..dcb9193ec 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" @@ -19,26 +20,121 @@ import ( ) const ( - ProjectUpdateFailedError = "failed to update a project item" - ProjectAddFailedError = "failed to add a project item" - ProjectDeleteFailedError = "failed to delete a project item" - ProjectListFailedError = "failed to list project items" - MaxProjectsPerPage = 50 + ProjectUpdateFailedError = "failed to update a project item" + ProjectAddFailedError = "failed to add a project item" + ProjectDeleteFailedError = "failed to delete a project item" + ProjectListFailedError = "failed to list project items" + ProjectStatusUpdateListFailedError = "failed to list project status updates" + ProjectStatusUpdateGetFailedError = "failed to get project status update" + ProjectStatusUpdateCreateFailedError = "failed to create project status update" + ProjectResolveIDFailedError = "failed to resolve project ID" + MaxProjectsPerPage = 50 ) // Method constants for consolidated project tools const ( - projectsMethodListProjects = "list_projects" - projectsMethodListProjectFields = "list_project_fields" - projectsMethodListProjectItems = "list_project_items" - projectsMethodGetProject = "get_project" - projectsMethodGetProjectField = "get_project_field" - projectsMethodGetProjectItem = "get_project_item" - projectsMethodAddProjectItem = "add_project_item" - projectsMethodUpdateProjectItem = "update_project_item" - projectsMethodDeleteProjectItem = "delete_project_item" + projectsMethodListProjects = "list_projects" + projectsMethodListProjectFields = "list_project_fields" + projectsMethodListProjectItems = "list_project_items" + projectsMethodGetProject = "get_project" + projectsMethodGetProjectField = "get_project_field" + projectsMethodGetProjectItem = "get_project_item" + projectsMethodAddProjectItem = "add_project_item" + projectsMethodUpdateProjectItem = "update_project_item" + projectsMethodDeleteProjectItem = "delete_project_item" + projectsMethodListProjectStatusUpdates = "list_project_status_updates" + projectsMethodGetProjectStatusUpdate = "get_project_status_update" + projectsMethodCreateProjectStatusUpdate = "create_project_status_update" ) +// GraphQL types for ProjectV2 status updates + +type statusUpdateNode struct { + ID githubv4.ID + Body *githubv4.String + Status *githubv4.String + CreatedAt githubv4.DateTime + StartDate *githubv4.String + TargetDate *githubv4.String + Creator struct { + Login githubv4.String + } +} + +type statusUpdateConnection struct { + Nodes []statusUpdateNode + PageInfo PageInfoFragment +} + +// statusUpdatesUserQuery is the GraphQL query for listing status updates on a user-owned project. +type statusUpdatesUserQuery struct { + User struct { + ProjectV2 struct { + StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` +} + +// statusUpdatesOrgQuery is the GraphQL query for listing status updates on an org-owned project. +type statusUpdatesOrgQuery struct { + Organization struct { + ProjectV2 struct { + StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` +} + +// statusUpdateNodeQuery is the GraphQL query for fetching a single status update by node ID. +type statusUpdateNodeQuery struct { + Node struct { + StatusUpdate statusUpdateNode `graphql:"... on ProjectV2StatusUpdate"` + } `graphql:"node(id: $id)"` +} + +// CreateProjectV2StatusUpdateInput is the input for the createProjectV2StatusUpdate mutation. +// Defined locally because the shurcooL/githubv4 library does not include this type. +type CreateProjectV2StatusUpdateInput struct { + ProjectID githubv4.ID `json:"projectId"` + Body *githubv4.String `json:"body,omitempty"` + Status *githubv4.String `json:"status,omitempty"` + StartDate *githubv4.String `json:"startDate,omitempty"` + TargetDate *githubv4.String `json:"targetDate,omitempty"` + ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"` +} + +// validProjectV2StatusUpdateStatuses is the set of valid status values for the createProjectV2StatusUpdate mutation. +var validProjectV2StatusUpdateStatuses = map[string]bool{ + "INACTIVE": true, + "ON_TRACK": true, + "AT_RISK": true, + "OFF_TRACK": true, + "COMPLETE": true, +} + +func convertToMinimalStatusUpdate(node statusUpdateNode) MinimalProjectStatusUpdate { + var creator *MinimalUser + if login := string(node.Creator.Login); login != "" { + creator = &MinimalUser{Login: login} + } + + return MinimalProjectStatusUpdate{ + ID: fmt.Sprintf("%v", node.ID), + Body: derefString(node.Body), + Status: derefString(node.Status), + CreatedAt: node.CreatedAt.Time.Format(time.RFC3339), + StartDate: derefString(node.StartDate), + TargetDate: derefString(node.TargetDate), + Creator: creator, + } +} + +func derefString(s *githubv4.String) string { + if s == nil { + return "" + } + return string(*s) +} + // ProjectsList returns the tool and handler for listing GitHub Projects resources. func ProjectsList(t translations.TranslationHelperFunc) inventory.ServerTool { tool := NewTool( @@ -63,6 +159,7 @@ Use this tool to list projects for a user or organization, or list project field projectsMethodListProjects, projectsMethodListProjectFields, projectsMethodListProjectItems, + projectsMethodListProjectStatusUpdates, }, }, "owner_type": { @@ -76,7 +173,7 @@ Use this tool to list projects for a user or organization, or list project field }, "project_number": { Type: "number", - Description: "The project's number. Required for 'list_project_fields' and 'list_project_items' methods.", + Description: "The project's number. Required for 'list_project_fields', 'list_project_items', and 'list_project_status_updates' methods.", }, "query": { Type: "string", @@ -130,8 +227,8 @@ Use this tool to list projects for a user or organization, or list project field switch method { case projectsMethodListProjects: return listProjects(ctx, client, args, owner, ownerType) - case projectsMethodListProjectFields: - // Detect owner type if not provided and project_number is available + default: + // All other methods require project_number and ownerType detection if ownerType == "" { projectNumber, err := RequiredInt(args, "project_number") if err != nil { @@ -142,22 +239,21 @@ Use this tool to list projects for a user or organization, or list project field return utils.NewToolResultError(err.Error()), nil, nil } } - return listProjectFields(ctx, client, args, owner, ownerType) - case projectsMethodListProjectItems: - // Detect owner type if not provided and project_number is available - if ownerType == "" { - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) + + switch method { + case projectsMethodListProjectFields: + return listProjectFields(ctx, client, args, owner, ownerType) + case projectsMethodListProjectItems: + return listProjectItems(ctx, client, args, owner, ownerType) + case projectsMethodListProjectStatusUpdates: + gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + return listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - return listProjectItems(ctx, client, args, owner, ownerType) - default: - return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, ) @@ -187,6 +283,7 @@ Use this tool to get details about individual projects, project fields, and proj projectsMethodGetProject, projectsMethodGetProjectField, projectsMethodGetProjectItem, + projectsMethodGetProjectStatusUpdate, }, }, "owner_type": { @@ -217,8 +314,12 @@ Use this tool to get details about individual projects, project fields, and proj Type: "string", }, }, + "status_update_id": { + Type: "string", + Description: "The node ID of the project status update. Required for 'get_project_status_update' method.", + }, }, - Required: []string{"method", "owner", "project_number"}, + Required: []string{"method"}, }, }, []scopes.Scope{scopes.ReadProject}, @@ -228,6 +329,19 @@ Use this tool to get details about individual projects, project fields, and proj return utils.NewToolResultError(err.Error()), nil, nil } + // Handle get_project_status_update early — it only needs status_update_id + if method == projectsMethodGetProjectStatusUpdate { + statusUpdateID, err := RequiredParam[string](args, "status_update_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return getProjectStatusUpdate(ctx, gqlClient, statusUpdateID) + } + owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -289,7 +403,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { ToolsetMetadataProjects, mcp.Tool{ Name: "projects_write", - Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Add, update, or delete project items in a GitHub Project."), + Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Add, update, or delete project items, or create status updates in a GitHub Project."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Modify GitHub Project items"), ReadOnlyHint: false, @@ -305,6 +419,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { projectsMethodAddProjectItem, projectsMethodUpdateProjectItem, projectsMethodDeleteProjectItem, + projectsMethodCreateProjectStatusUpdate, }, }, "owner_type": { @@ -349,6 +464,23 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Type: "object", Description: "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", }, + "body": { + Type: "string", + Description: "The body of the status update (markdown). Used for 'create_project_status_update' method.", + }, + "status": { + Type: "string", + Description: "The status of the project. Used for 'create_project_status_update' method.", + Enum: []any{"INACTIVE", "ON_TRACK", "AT_RISK", "OFF_TRACK", "COMPLETE"}, + }, + "start_date": { + Type: "string", + Description: "The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", + }, + "target_date": { + Type: "string", + Description: "The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", + }, }, Required: []string{"method", "owner", "project_number"}, }, @@ -445,6 +577,24 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(err.Error()), nil, nil } return deleteProjectItem(ctx, client, owner, ownerType, projectNumber, itemID) + case projectsMethodCreateProjectStatusUpdate: + body, err := OptionalParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + status, err := OptionalParam[string](args, "status") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + startDate, err := OptionalParam[string](args, "start_date") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + targetDate, err := OptionalParam[string](args, "target_date") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return createProjectStatusUpdate(ctx, gqlClient, owner, ownerType, projectNumber, body, status, startDate, targetDate) default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -875,6 +1025,43 @@ func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerT return utils.NewToolResultText("project item successfully deleted"), nil, nil } +// resolveProjectNodeID resolves (owner, ownerType, projectNumber) to a project node ID via GraphQL. +func resolveProjectNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) (githubv4.ID, error) { + var projectIDQueryUser struct { + User struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` + } + var projectIDQueryOrg struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + } + + queryVars := map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers + } + + if ownerType == "org" { + err := gqlClient.Query(ctx, &projectIDQueryOrg, queryVars) + if err != nil { + return "", fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err) + } + return projectIDQueryOrg.Organization.ProjectV2.ID, nil + } + + err := gqlClient.Query(ctx, &projectIDQueryUser, queryVars) + if err != nil { + return "", fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err) + } + return projectIDQueryUser.User.ProjectV2.ID, nil +} + // addProjectItem adds an item to a project by resolving the issue/PR number to a node ID func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemOwner, itemRepo string, itemNumber int, itemType string) (*mcp.CallToolResult, any, error) { if itemType != "issue" && itemType != "pull_request" { @@ -902,41 +1089,10 @@ func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, owne } `graphql:"addProjectV2ItemById(input: $input)"` } - // First, get the project ID - var projectIDQuery struct { - User struct { - ProjectV2 struct { - ID githubv4.ID - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"user(login: $owner)"` - } - var projectIDQueryOrg struct { - Organization struct { - ProjectV2 struct { - ID githubv4.ID - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"organization(login: $owner)"` - } - - var projectID githubv4.ID - if ownerType == "org" { - err = gqlClient.Query(ctx, &projectIDQueryOrg, map[string]any{ - "owner": githubv4.String(owner), - "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers - }) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil - } - projectID = projectIDQueryOrg.Organization.ProjectV2.ID - } else { - err = gqlClient.Query(ctx, &projectIDQuery, map[string]any{ - "owner": githubv4.String(owner), - "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers - }) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil - } - projectID = projectIDQuery.User.ProjectV2.ID + // Resolve the project number to a node ID + projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil } // Add the item to the project @@ -963,6 +1119,188 @@ func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, owne return utils.NewToolResultText(string(r)), nil, nil } +// validateDateFormat checks that a date string is in YYYY-MM-DD format. +func validateDateFormat(value, fieldName string) error { + if _, err := time.Parse("2006-01-02", value); err != nil { + return fmt.Errorf("invalid %s %q: must be YYYY-MM-DD format", fieldName, value) + } + return nil +} + +// createProjectStatusUpdate creates a new status update for a project via GraphQL. +func createProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, body, status, startDate, targetDate string) (*mcp.CallToolResult, any, error) { + // Validate inputs + if ownerType != "user" && ownerType != "org" { + return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil + } + if status != "" && !validProjectV2StatusUpdateStatuses[status] { + return utils.NewToolResultError(fmt.Sprintf("invalid status %q: must be one of INACTIVE, ON_TRACK, AT_RISK, OFF_TRACK, COMPLETE", status)), nil, nil + } + if startDate != "" { + if err := validateDateFormat(startDate, "start_date"); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + if targetDate != "" { + if err := validateDateFormat(targetDate, "target_date"); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + + // Resolve project number to project node ID + projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Build mutation input + input := CreateProjectV2StatusUpdateInput{ + ProjectID: projectID, + } + + if body != "" { + s := githubv4.String(body) + input.Body = &s + } + if status != "" { + s := githubv4.String(status) + input.Status = &s + } + if startDate != "" { + s := githubv4.String(startDate) + input.StartDate = &s + } + if targetDate != "" { + s := githubv4.String(targetDate) + input.TargetDate = &s + } + + // Execute mutation + var mutation struct { + CreateProjectV2StatusUpdate struct { + StatusUpdate statusUpdateNode + } `graphql:"createProjectV2StatusUpdate(input: $input)"` + } + + err = gqlClient.Mutate(ctx, &mutation, input, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateCreateFailedError, err)), nil, nil + } + + // Convert and return + result := convertToMinimalStatusUpdate(mutation.CreateProjectV2StatusUpdate.StatusUpdate) + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +// listProjectStatusUpdates lists status updates for a project via GraphQL. +func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + if ownerType != "user" && ownerType != "org" { + return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if perPage > MaxProjectsPerPage { + perPage = MaxProjectsPerPage + } + if perPage < 1 { + perPage = MaxProjectsPerPage + } + + afterCursor, err := OptionalParam[string](args, "after") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers + "first": githubv4.Int(int32(perPage)), //nolint:gosec // perPage is bounded by MaxProjectsPerPage + } + if afterCursor != "" { + vars["after"] = githubv4.String(afterCursor) + } else { + vars["after"] = (*githubv4.String)(nil) + } + + var nodes []statusUpdateNode + var pi PageInfoFragment + + if ownerType == "org" { + var q statusUpdatesOrgQuery + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), nil, nil + } + nodes = q.Organization.ProjectV2.StatusUpdates.Nodes + pi = q.Organization.ProjectV2.StatusUpdates.PageInfo + } else { + var q statusUpdatesUserQuery + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), nil, nil + } + nodes = q.User.ProjectV2.StatusUpdates.Nodes + pi = q.User.ProjectV2.StatusUpdates.PageInfo + } + + updates := make([]MinimalProjectStatusUpdate, 0, len(nodes)) + for _, n := range nodes { + updates = append(updates, convertToMinimalStatusUpdate(n)) + } + + response := map[string]any{ + "statusUpdates": updates, + "pageInfo": map[string]any{ + "hasNextPage": pi.HasNextPage, + "hasPreviousPage": pi.HasPreviousPage, + "nextCursor": string(pi.EndCursor), + "prevCursor": string(pi.StartCursor), + }, + } + + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + return utils.NewToolResultText(string(r)), nil, nil +} + +// getProjectStatusUpdate fetches a single status update by its node ID via GraphQL. +func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, statusUpdateID string) (*mcp.CallToolResult, any, error) { + var q statusUpdateNodeQuery + vars := map[string]any{ + "id": githubv4.ID(statusUpdateID), + } + + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateGetFailedError, err)), nil, nil + } + + if q.Node.StatusUpdate.ID == nil || q.Node.StatusUpdate.ID == "" { + return utils.NewToolResultError(fmt.Sprintf("%s: node is not a ProjectV2StatusUpdate or was not found", ProjectStatusUpdateGetFailedError)), nil, nil + } + + update := convertToMinimalStatusUpdate(q.Node.StatusUpdate) + + r, err := json.Marshal(update) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + return utils.NewToolResultText(string(r)), nil, nil +} + type pageInfo struct { HasNextPage bool `json:"hasNextPage"` HasPreviousPage bool `json:"hasPreviousPage"` diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 7c8f4a46f..9b0e07292 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -236,7 +236,7 @@ func Test_ProjectsGet(t *testing.T) { assert.Contains(t, inputSchema.Properties, "project_number") assert.Contains(t, inputSchema.Properties, "field_id") assert.Contains(t, inputSchema.Properties, "item_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "project_number"}) + assert.ElementsMatch(t, inputSchema.Required, []string{"method"}) } func Test_ProjectsGet_GetProject(t *testing.T) { @@ -814,3 +814,209 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { assert.Contains(t, textContent.Text, "missing required parameter: item_id") }) } + +func Test_ProjectsList_ListProjectStatusUpdates(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + t.Run("success via consolidated tool", func(t *testing.T) { + // REST mock for detectOwnerType (when owner_type is omitted) + restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusOK, map[string]any{"id": 1}), + }) + + // GQL mock for listProjectStatusUpdates + gqlMockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdatesUserQuery{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(1), + "first": githubv4.Int(50), + "after": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "statusUpdates": map[string]any{ + "nodes": []map[string]any{ + { + "id": "SU_1", + "body": "On track", + "status": "ON_TRACK", + "createdAt": "2026-01-15T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + }, + }, + }, + }), + ), + ) + + gqlClient := githubv4.NewClient(gqlMockedClient) + deps := BaseDeps{ + Client: gh.NewClient(restClient), + GQLClient: gqlClient, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_status_updates", + "owner": "octocat", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + updates, ok := response["statusUpdates"].([]any) + require.True(t, ok) + assert.Len(t, updates, 1) + }) +} + +func Test_ProjectsGet_GetProjectStatusUpdate(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + t.Run("success via consolidated tool", func(t *testing.T) { + gqlMockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdateNodeQuery{}, + map[string]any{ + "id": githubv4.ID("SU_abc123"), + }, + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{ + "id": "SU_abc123", + "body": "On track", + "status": "ON_TRACK", + "createdAt": "2026-01-15T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, + }, + }), + ), + ) + + gqlClient := githubv4.NewClient(gqlMockedClient) + deps := BaseDeps{ + GQLClient: gqlClient, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_status_update", + "owner": "octocat", + "project_number": float64(1), + "status_update_id": "SU_abc123", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "SU_abc123", response["id"]) + assert.Equal(t, "On track", response["body"]) + }) +} + +func Test_ProjectsWrite_CreateProjectStatusUpdate(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success via consolidated tool", func(t *testing.T) { + bodyStr := githubv4.String("Consolidated test") + statusStr := githubv4.String("AT_RISK") + + gqlMockedClient := githubv4mock.NewMockedHTTPClient( + // Mock project ID query for user + githubv4mock.NewQueryMatcher( + struct { + User struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(3), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project3", + }, + }, + }), + ), + // Mock createProjectV2StatusUpdate mutation + githubv4mock.NewMutationMatcher( + struct { + CreateProjectV2StatusUpdate struct { + StatusUpdate statusUpdateNode + } `graphql:"createProjectV2StatusUpdate(input: $input)"` + }{}, + CreateProjectV2StatusUpdateInput{ + ProjectID: githubv4.ID("PVT_project3"), + Body: &bodyStr, + Status: &statusStr, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createProjectV2StatusUpdate": map[string]any{ + "statusUpdate": map[string]any{ + "id": "PVTSU_su003", + "body": "Consolidated test", + "status": "AT_RISK", + "createdAt": "2026-02-09T12:00:00Z", + "creator": map[string]any{"login": "octocat"}, + }, + }, + }), + ), + ) + + gqlClient := githubv4.NewClient(gqlMockedClient) + deps := BaseDeps{ + GQLClient: gqlClient, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_project_status_update", + "owner": "octocat", + "owner_type": "user", + "project_number": float64(3), + "body": "Consolidated test", + "status": "AT_RISK", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "PVTSU_su003", response["id"]) + assert.Equal(t, "Consolidated test", response["body"]) + assert.Equal(t, "AT_RISK", response["status"]) + }) +} diff --git a/pkg/github/toolset_instructions.go b/pkg/github/toolset_instructions.go index bf2388a3d..bc9da4e65 100644 --- a/pkg/github/toolset_instructions.go +++ b/pkg/github/toolset_instructions.go @@ -39,6 +39,8 @@ func generateProjectsToolsetInstructions(_ *inventory.Inventory) string { Workflow: 1) list_project_fields (get field IDs), 2) list_project_items (with pagination), 3) optional updates. +Status updates: Use list_project_status_updates to read recent project status updates (newest first). Use get_project_status_update with a node ID to get a single update. Use create_project_status_update to create a new status update for a project. + Field usage: - Call list_project_fields first to understand available fields and get IDs/types before filtering. - Use EXACT returned field names (case-insensitive match). Don't invent names or IDs. From eec84f73b5629273b393b2471eb319a62d760938 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Wed, 18 Feb 2026 11:20:27 +0100 Subject: [PATCH 05/57] Improve AI issue triage prompts to detect unfilled templates (#2030) - Add explicit detection of unmodified template text and placeholders - Add detection of meaningless/spam-like titles - Add 'Invalid' assessment category for spam/test issues - Add label recommendations (waiting-for-reply, invalid) - Strengthen 'Missing Details' criteria with specific examples - Add guidance to be specific about which sections need actual content This addresses issues like #2029 where template text was not replaced with actual information but was not flagged as missing details. --- .github/prompts/bug-report-review.prompt.yml | 24 ++++++++++---- .../prompts/default-issue-review.prompt.yml | 33 ++++++++++++++++--- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/.github/prompts/bug-report-review.prompt.yml b/.github/prompts/bug-report-review.prompt.yml index 23c4bf70d..ccb95eff0 100644 --- a/.github/prompts/bug-report-review.prompt.yml +++ b/.github/prompts/bug-report-review.prompt.yml @@ -5,26 +5,38 @@ messages: Your job is to analyze bug reports and assess their completeness. + **CRITICAL: Detect unfilled templates** + - Flag issues containing unmodified template text like "A clear and concise description of what the bug is" + - Flag placeholder values like "Type this '...'" or "View the output '....'" that haven't been replaced + - Flag generic/meaningless titles (e.g., random words, test content) + - These are ALWAYS "Missing Details" even if the template structure is present + Analyze the issue for these key elements: - 1. Clear description of the problem + 1. Clear description of the problem (not template text) 2. Affected version (from running `docker run -i --rm ghcr.io/github/github-mcp-server ./github-mcp-server --version`) - 3. Steps to reproduce the behavior - 4. Expected vs actual behavior + 3. Steps to reproduce the behavior (actual steps, not placeholders) + 4. Expected vs actual behavior (real descriptions, not template text) 5. Relevant logs (if applicable) Provide ONE of these assessments: ### AI Assessment: Ready for Review - Use when the bug report has most required information and can be triaged by a maintainer. + Use when the bug report has actual information in required fields and can be triaged by a maintainer. ### AI Assessment: Missing Details - Use when critical information is missing (no reproduction steps, no version info, unclear problem description). + Use when: + - Template text has not been replaced with actual content + - Critical information is missing (no reproduction steps, no version info, unclear problem description) + - The title is meaningless or spam-like + - Placeholder text remains in any section + + When marking as Missing Details, recommend adding the "waiting-for-reply" label. ### AI Assessment: Unsure Use when you cannot determine the completeness of the report. After your assessment header, provide a brief explanation of your rating. - If details are missing, note which specific sections need more information. + If details are missing, be specific about which sections contain template text or need actual information. - role: user content: "{{input}}" model: openai/gpt-4o-mini diff --git a/.github/prompts/default-issue-review.prompt.yml b/.github/prompts/default-issue-review.prompt.yml index 6b4cd4a2b..a574c9d89 100644 --- a/.github/prompts/default-issue-review.prompt.yml +++ b/.github/prompts/default-issue-review.prompt.yml @@ -5,24 +5,47 @@ messages: Your job is to analyze new issues and help categorize them. + **CRITICAL: Detect invalid or incomplete submissions** + - Flag issues with unmodified template text (e.g., "A clear and concise description...") + - Flag placeholder values that haven't been replaced (e.g., "Type this '...'", "....", "XXX") + - Flag meaningless, spam-like, or test titles (e.g., random words, nonsensical content) + - Flag empty or nearly empty issues + - These are ALWAYS "Missing Details" or "Invalid" depending on severity + Analyze the issue to determine: - 1. Is this a bug report, feature request, question, or something else? - 2. Is the issue clear and well-described? + 1. Is this a bug report, feature request, question, documentation issue, or something else? + 2. Is the issue clear and well-described with actual content (not template text)? 3. Does it contain enough information for maintainers to act on? + 4. Is this potentially spam, a test issue, or completely invalid? Provide ONE of these assessments: ### AI Assessment: Ready for Review - Use when the issue is clear, well-described, and contains enough context for maintainers to understand and act on it. + Use when the issue is clear, well-described with actual content, and contains enough context for maintainers to understand and act on it. ### AI Assessment: Missing Details - Use when the issue is unclear, lacks context, or needs more information to be actionable. + Use when: + - Template text has not been replaced with actual content + - The issue is unclear or lacks context + - Critical information is missing to make it actionable + - The title is vague but the issue seems legitimate + + When marking as Missing Details, recommend adding the "waiting-for-reply" label. + + ### AI Assessment: Invalid + Use when: + - The issue appears to be spam or test content + - The title is completely meaningless and body has no useful information + - This doesn't relate to the GitHub MCP Server project at all + + When marking as Invalid, recommend adding the "invalid" label and consider closing. ### AI Assessment: Unsure Use when you cannot determine the nature or completeness of the issue. After your assessment header, provide a brief explanation including: - - What type of issue this appears to be (bug, feature request, question, etc.) + - What type of issue this appears to be (bug, feature request, question, invalid, etc.) + - Which specific sections contain template text or need actual information - What additional information might be helpful if any - role: user content: "{{input}}" From 851030c20bb743a95a4b9b152ca031c6343a4357 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:26:19 +0000 Subject: [PATCH 06/57] Reduce context usage for `create_or_update_file` tool (#2027) * optimize context usage * add assortions and check for nil response * refactor --- pkg/github/minimal_types.go | 63 +++++++++++++++++++++++++++++++-- pkg/github/repositories.go | 13 +++---- pkg/github/repositories_test.go | 21 +++++++---- 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index adb8a386d..f8c82d78e 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -185,6 +185,29 @@ type MinimalIssueComment struct { UpdatedAt string `json:"updated_at,omitempty"` } +// MinimalFileContentResponse is the trimmed output type for create/update/delete file responses. +type MinimalFileContentResponse struct { + Content *MinimalFileContent `json:"content,omitempty"` + Commit *MinimalFileCommit `json:"commit,omitempty"` +} + +// MinimalFileContent is the trimmed content portion of a file operation response. +type MinimalFileContent struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Size int `json:"size,omitempty"` + HTMLURL string `json:"html_url"` +} + +// MinimalFileCommit is the trimmed commit portion of a file operation response. +type MinimalFileCommit struct { + SHA string `json:"sha"` + Message string `json:"message,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Author *MinimalCommitAuthor `json:"author,omitempty"` +} + // MinimalPullRequest is the trimmed output type for pull request objects to reduce verbosity. type MinimalPullRequest struct { Number int `json:"number"` @@ -338,6 +361,42 @@ func convertToMinimalIssueComment(comment *github.IssueComment) MinimalIssueComm return m } +func convertToMinimalFileContentResponse(resp *github.RepositoryContentResponse) MinimalFileContentResponse { + m := MinimalFileContentResponse{} + + if resp == nil { + return m + } + + if c := resp.Content; c != nil { + m.Content = &MinimalFileContent{ + Name: c.GetName(), + Path: c.GetPath(), + SHA: c.GetSHA(), + Size: c.GetSize(), + HTMLURL: c.GetHTMLURL(), + } + } + + m.Commit = &MinimalFileCommit{ + SHA: resp.Commit.GetSHA(), + Message: resp.Commit.GetMessage(), + HTMLURL: resp.Commit.GetHTMLURL(), + } + + if author := resp.Commit.Author; author != nil { + m.Commit.Author = &MinimalCommitAuthor{ + Name: author.GetName(), + Email: author.GetEmail(), + } + if author.Date != nil { + m.Commit.Author.Date = author.Date.Format(time.RFC3339) + } + } + + return m +} + func convertToMinimalPullRequest(pr *github.PullRequest) MinimalPullRequest { m := MinimalPullRequest{ Number: pr.GetNumber(), @@ -480,7 +539,7 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) Email: commit.Commit.Author.GetEmail(), } if commit.Commit.Author.Date != nil { - minimalCommit.Commit.Author.Date = commit.Commit.Author.Date.Format("2006-01-02T15:04:05Z") + minimalCommit.Commit.Author.Date = commit.Commit.Author.Date.Format(time.RFC3339) } } @@ -490,7 +549,7 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) Email: commit.Commit.Committer.GetEmail(), } if commit.Commit.Committer.Date != nil { - minimalCommit.Commit.Committer.Date = commit.Commit.Committer.Date.Format("2006-01-02T15:04:05Z") + minimalCommit.Commit.Committer.Date = commit.Commit.Committer.Date.Format(time.RFC3339) } } } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 1af296882..4433fe64c 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -489,13 +489,14 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create/update file", resp, body), nil, nil } - r, err := json.Marshal(fileContent) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } + minimalResponse := convertToMinimalFileContentResponse(fileContent) // Warn if file was updated without SHA validation (blind update) if sha == "" && previousSHA != "" { + warning, err := json.Marshal(minimalResponse) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } return utils.NewToolResultText(fmt.Sprintf( "Warning: File updated without SHA validation. Previous file SHA was %s. "+ `Verify no unintended changes were overwritten: @@ -504,10 +505,10 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the 3. Revert changes if shas do not match. %s`, - previousSHA, path, string(r))), nil, nil + previousSHA, path, string(warning))), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return MarshalledTextResult(minimalResponse), nil, nil }, ) } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 38e8f8938..76628283d 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1434,18 +1434,27 @@ func Test_CreateOrUpdateFile(t *testing.T) { } // Unmarshal and verify the result - var returnedContent github.RepositoryContentResponse + var returnedContent MinimalFileContentResponse err = json.Unmarshal([]byte(textContent.Text), &returnedContent) require.NoError(t, err) // Verify content - assert.Equal(t, *tc.expectedContent.Content.Name, *returnedContent.Content.Name) - assert.Equal(t, *tc.expectedContent.Content.Path, *returnedContent.Content.Path) - assert.Equal(t, *tc.expectedContent.Content.SHA, *returnedContent.Content.SHA) + assert.Equal(t, tc.expectedContent.Content.GetName(), returnedContent.Content.Name) + assert.Equal(t, tc.expectedContent.Content.GetPath(), returnedContent.Content.Path) + assert.Equal(t, tc.expectedContent.Content.GetSHA(), returnedContent.Content.SHA) + assert.Equal(t, tc.expectedContent.Content.GetSize(), returnedContent.Content.Size) + assert.Equal(t, tc.expectedContent.Content.GetHTMLURL(), returnedContent.Content.HTMLURL) // Verify commit - assert.Equal(t, *tc.expectedContent.Commit.SHA, *returnedContent.Commit.SHA) - assert.Equal(t, *tc.expectedContent.Commit.Message, *returnedContent.Commit.Message) + assert.Equal(t, tc.expectedContent.Commit.GetSHA(), returnedContent.Commit.SHA) + assert.Equal(t, tc.expectedContent.Commit.GetMessage(), returnedContent.Commit.Message) + assert.Equal(t, tc.expectedContent.Commit.GetHTMLURL(), returnedContent.Commit.HTMLURL) + + // Verify commit author + require.NotNil(t, returnedContent.Commit.Author) + assert.Equal(t, tc.expectedContent.Commit.Author.GetName(), returnedContent.Commit.Author.Name) + assert.Equal(t, tc.expectedContent.Commit.Author.GetEmail(), returnedContent.Commit.Author.Email) + assert.NotEmpty(t, returnedContent.Commit.Author.Date) }) } } From 08231a2aeb05b78e0e2e93bcb79a74deb724736d Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Wed, 18 Feb 2026 14:43:59 +0100 Subject: [PATCH 07/57] Add support for custom middleware in the correct order. (#2026) * Add support for custom middleware in the correct order. * Switch this up to be more clear on what it's doing --- pkg/github/server.go | 8 +++++--- pkg/http/handler.go | 3 +++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/github/server.go b/pkg/github/server.go index 9a602e153..14741939d 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -73,7 +73,7 @@ type MCPServerConfig struct { type MCPServerOption func(*mcp.ServerOptions) -func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependencies, inv *inventory.Inventory) (*mcp.Server, error) { +func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependencies, inv *inventory.Inventory, middleware ...mcp.Middleware) (*mcp.Server, error) { // Create the MCP server serverOpts := &mcp.ServerOptions{ Instructions: inv.Instructions(), @@ -98,9 +98,11 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci ghServer := NewServer(cfg.Version, serverOpts) - // Add middlewares - ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) + // Add middlewares. Order matters - for example, the error context middleware should be applied last so that it runs FIRST (closest to the handler) to ensure all errors are captured, + // and any middleware that needs to read or modify the context should be before it. + ghServer.AddReceivingMiddleware(middleware...) ghServer.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) if unrecognized := inv.UnrecognizedToolsets(); len(unrecognized) > 0 { cfg.Logger.Warn("Warning: unrecognized toolsets ignored", "toolsets", strings.Join(unrecognized, ", ")) diff --git a/pkg/http/handler.go b/pkg/http/handler.go index 3c6c5302e..c4fcdec72 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -19,6 +19,9 @@ import ( ) type InventoryFactoryFunc func(r *http.Request) (*inventory.Inventory, error) + +// GitHubMCPServerFactoryFunc is a function type for creating a new MCP Server instance. +// middleware are applied AFTER the default GitHub MCP Server middlewares (like error context injection) type GitHubMCPServerFactoryFunc func(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) type Handler struct { From 5e1c94b25c59f3bab38a4e24ea0aa8eafa003321 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 18 Feb 2026 16:39:53 +0100 Subject: [PATCH 08/57] fix: pin Docker base images to SHA256 digests Pin all three Dockerfile base images to their SHA256 digests to resolve code scanning alerts for unpinned Docker images. Dependabot docker ecosystem is already configured and will keep these digests up to date. - node:20-alpine (alert #14) - golang:1.25.7-alpine (alert #15) - gcr.io/distroless/base-debian12 (proactive) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index cc81c5145..90c8b4007 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine AS ui-build +FROM node:20-alpine@sha256:09e2b3d9726018aecf269bd35325f46bf75046a643a66d28360ec71132750ec8 AS ui-build WORKDIR /app COPY ui/package*.json ./ui/ RUN cd ui && npm ci @@ -7,7 +7,7 @@ COPY ui/ ./ui/ RUN mkdir -p ./pkg/github/ui_dist && \ cd ui && npm run build -FROM golang:1.25.7-alpine AS build +FROM golang:1.25.7-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS build ARG VERSION="dev" # Set the working directory @@ -30,7 +30,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -o /bin/github-mcp-server ./cmd/github-mcp-server # Make a stage to run the app -FROM gcr.io/distroless/base-debian12 +FROM gcr.io/distroless/base-debian12@sha256:937c7eaaf6f3f2d38a1f8c4aeff326f0c56e4593ea152e9e8f74d976dde52f56 # Add required MCP server annotation LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server" From 0b76ca8fd2308435dfddb28dc53bdba41c7b93a3 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 18 Feb 2026 12:28:26 +0000 Subject: [PATCH 09/57] add disallowed-tools flag to enable shuting off tools as part of server configuration --- cmd/github-mcp-server/main.go | 11 +++ docs/server-configuration.md | 55 +++++++++++- internal/ghmcp/server.go | 7 ++ pkg/github/server.go | 5 ++ pkg/inventory/builder.go | 27 ++++++ pkg/inventory/registry_test.go | 148 +++++++++++++++++++++++++++++++++ 6 files changed, 252 insertions(+), 1 deletion(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index b8002d456..47da21e61 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -61,6 +61,14 @@ var ( } } + // Parse disallowed tools (similar to tools) + var disallowedTools []string + if viper.IsSet("disallowed_tools") { + if err := viper.UnmarshalKey("disallowed_tools", &disallowedTools); err != nil { + return fmt.Errorf("failed to unmarshal disallowed-tools: %w", err) + } + } + // Parse enabled features (similar to toolsets) var enabledFeatures []string if viper.IsSet("features") { @@ -85,6 +93,7 @@ var ( ContentWindowSize: viper.GetInt("content-window-size"), LockdownMode: viper.GetBool("lockdown-mode"), InsidersMode: viper.GetBool("insiders"), + DisallowedTools: disallowedTools, RepoAccessCacheTTL: &ttl, } return ghmcp.RunStdioServer(stdioServerConfig) @@ -126,6 +135,7 @@ func init() { // Add global flags that will be shared by all commands rootCmd.PersistentFlags().StringSlice("toolsets", nil, github.GenerateToolsetsHelp()) rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable") + rootCmd.PersistentFlags().StringSlice("disallowed-tools", nil, "Comma-separated list of tool names to disable regardless of other settings") rootCmd.PersistentFlags().StringSlice("features", nil, "Comma-separated list of feature flags to enable") rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets") rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations") @@ -147,6 +157,7 @@ func init() { // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) _ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools")) + _ = viper.BindPFlag("disallowed_tools", rootCmd.PersistentFlags().Lookup("disallowed-tools")) _ = viper.BindPFlag("features", rootCmd.PersistentFlags().Lookup("features")) _ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets")) _ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only")) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 46ec3bc64..2c5f875b2 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -9,6 +9,7 @@ We currently support the following ways in which the GitHub MCP Server can be co |---------------|---------------|--------------| | Toolsets | `X-MCP-Toolsets` header or `/x/{toolset}` URL | `--toolsets` flag or `GITHUB_TOOLSETS` env var | | Individual Tools | `X-MCP-Tools` header | `--tools` flag or `GITHUB_TOOLS` env var | +| Disallowed Tools | `X-MCP-Disallowed-Tools` header | `--disallowed-tools` flag or `GITHUB_DISALLOWED_TOOLS` env var | | Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var | | Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var | | Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var | @@ -20,10 +21,12 @@ We currently support the following ways in which the GitHub MCP Server can be co ## How Configuration Works -All configuration options are **composable**: you can combine toolsets, individual tools, dynamic discovery, read-only mode and lockdown mode in any way that suits your workflow. +All configuration options are **composable**: you can combine toolsets, individual tools, disallowed tools, dynamic discovery, read-only mode and lockdown mode in any way that suits your workflow. Note: **read-only** mode acts as a strict security filter that takes precedence over any other configuration, by disabling write tools even when explicitly requested. +Note: **disallowed tools** takes precedence over toolsets and individual tools — listed tools are always excluded, even if their toolset is enabled or they are explicitly added via `--tools` / `X-MCP-Tools`. + --- ## Configuration Examples @@ -170,6 +173,56 @@ Enable entire toolsets, then add individual tools from toolsets you don't want f --- +### Disallowing Specific Tools + +**Best for:** Users who want to enable a broad toolset but need to exclude specific tools for security, compliance, or to prevent undesired behavior. + +Listed tools are removed regardless of any other configuration — even if their toolset is enabled or they are individually added. + + + + + + + +
Remote ServerLocal Server
+ +```json +{ + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "X-MCP-Toolsets": "pull_requests", + "X-MCP-Disallowed-Tools": "create_pull_request,merge_pull_request" + } +} +``` + + + +```json +{ + "type": "stdio", + "command": "go", + "args": [ + "run", + "./cmd/github-mcp-server", + "stdio", + "--toolsets=pull_requests", + "--disallowed-tools=create_pull_request,merge_pull_request" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" + } +} +``` + +
+ +**Result:** All pull request tools except `create_pull_request` and `merge_pull_request` — the user gets read and review tools only. + +--- + ### Read-Only Mode **Best for:** Security conscious users who want to ensure the server won't allow operations that modify issues, pull requests, repositories etc. diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 6f5ba4e45..beaa898e2 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -135,6 +135,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se WithReadOnly(cfg.ReadOnly). WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)). WithTools(github.CleanTools(cfg.EnabledTools)). + WithDisallowedTools(cfg.DisallowedTools). WithServerInstructions(). WithFeatureChecker(featureChecker). WithInsidersMode(cfg.InsidersMode) @@ -214,6 +215,11 @@ type StdioServerConfig struct { // InsidersMode indicates if we should enable experimental features InsidersMode bool + // DisallowedTools is a list of tool names to disable regardless of other settings. + // These tools will be excluded even if their toolset is enabled or they are + // explicitly listed in EnabledTools. + DisallowedTools []string + // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration } @@ -271,6 +277,7 @@ func RunStdioServer(cfg StdioServerConfig) error { ContentWindowSize: cfg.ContentWindowSize, LockdownMode: cfg.LockdownMode, InsidersMode: cfg.InsidersMode, + DisallowedTools: cfg.DisallowedTools, Logger: logger, RepoAccessTTL: cfg.RepoAccessCacheTTL, TokenScopes: tokenScopes, diff --git a/pkg/github/server.go b/pkg/github/server.go index 14741939d..5ff71fc0e 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -62,6 +62,11 @@ type MCPServerConfig struct { // RepoAccessTTL overrides the default TTL for repository access cache entries. RepoAccessTTL *time.Duration + // DisallowedTools is a list of tool names that should be disabled regardless of + // other configuration. These tools will be excluded even if their toolset is enabled + // or they are explicitly listed in EnabledTools. + DisallowedTools []string + // TokenScopes contains the OAuth scopes available to the token. // When non-nil, tools requiring scopes not in this list will be hidden. // This is used for PAT scope filtering where we can't issue scope challenges. diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index 6d2f080aa..904498881 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -141,6 +141,19 @@ func (b *Builder) WithFilter(filter ToolFilter) *Builder { return b } +// WithDisallowedTools specifies tools that should be disabled regardless of other settings. +// These tools will be excluded even if their toolset is enabled or they are in the +// additional tools list. This takes precedence over all other tool enablement settings. +// Input is cleaned (trimmed, deduplicated) before applying. +// Returns self for chaining. +func (b *Builder) WithDisallowedTools(toolNames []string) *Builder { + cleaned := cleanTools(toolNames) + if len(cleaned) > 0 { + b.filters = append(b.filters, CreateDisallowedToolsFilter(cleaned)) + } + return b +} + // WithInsidersMode enables or disables insiders mode features. // When insiders mode is disabled (default), UI metadata is removed from tools // so clients won't attempt to load UI resources. @@ -150,6 +163,20 @@ func (b *Builder) WithInsidersMode(enabled bool) *Builder { return b } +// CreateDisallowedToolsFilter creates a ToolFilter that excludes tools by name. +// Any tool whose name appears in the disallowed list will be filtered out. +// The input slice should already be cleaned (trimmed, deduplicated). +func CreateDisallowedToolsFilter(disallowed []string) ToolFilter { + set := make(map[string]struct{}, len(disallowed)) + for _, name := range disallowed { + set[name] = struct{}{} + } + return func(_ context.Context, tool *ServerTool) (bool, error) { + _, blocked := set[tool.Tool.Name] + return !blocked, nil + } +} + // cleanTools trims whitespace and removes duplicates from tool names. // Empty strings after trimming are excluded. func cleanTools(tools []string) []string { diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index fc380ab32..cb0f038d7 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -2129,3 +2129,151 @@ func TestWithInsidersMode_DoesNotMutateOriginalTools(t *testing.T) { require.Equal(t, "data", tools[0].Tool.Meta["ui"], "original tool should not be mutated") require.Equal(t, "kept", tools[0].Tool.Meta["description"], "original tool should not be mutated") } + +func TestWithDisallowedTools(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset1", true), + mockTool("tool3", "toolset2", true), + } + + tests := []struct { + name string + disallowed []string + toolsets []string + expectedNames []string + unexpectedNames []string + }{ + { + name: "single tool disallowed", + disallowed: []string{"tool2"}, + toolsets: []string{"all"}, + expectedNames: []string{"tool1", "tool3"}, + unexpectedNames: []string{"tool2"}, + }, + { + name: "multiple tools disallowed", + disallowed: []string{"tool1", "tool3"}, + toolsets: []string{"all"}, + expectedNames: []string{"tool2"}, + unexpectedNames: []string{"tool1", "tool3"}, + }, + { + name: "empty disallowed list is a no-op", + disallowed: []string{}, + toolsets: []string{"all"}, + expectedNames: []string{"tool1", "tool2", "tool3"}, + unexpectedNames: nil, + }, + { + name: "nil disallowed list is a no-op", + disallowed: nil, + toolsets: []string{"all"}, + expectedNames: []string{"tool1", "tool2", "tool3"}, + unexpectedNames: nil, + }, + { + name: "disallowing non-existent tool is a no-op", + disallowed: []string{"nonexistent"}, + toolsets: []string{"all"}, + expectedNames: []string{"tool1", "tool2", "tool3"}, + unexpectedNames: nil, + }, + { + name: "disallow all tools", + disallowed: []string{"tool1", "tool2", "tool3"}, + toolsets: []string{"all"}, + expectedNames: nil, + unexpectedNames: []string{"tool1", "tool2", "tool3"}, + }, + { + name: "whitespace is trimmed", + disallowed: []string{" tool2 ", " tool3 "}, + toolsets: []string{"all"}, + expectedNames: []string{"tool1"}, + unexpectedNames: []string{"tool2", "tool3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := mustBuild(t, NewBuilder(). + SetTools(tools). + WithToolsets(tt.toolsets). + WithDisallowedTools(tt.disallowed)) + + available := reg.AvailableTools(context.Background()) + names := make(map[string]bool) + for _, tool := range available { + names[tool.Tool.Name] = true + } + + for _, expected := range tt.expectedNames { + require.True(t, names[expected], "tool %q should be available", expected) + } + for _, unexpected := range tt.unexpectedNames { + require.False(t, names[unexpected], "tool %q should be disallowed", unexpected) + } + }) + } +} + +func TestWithDisallowedTools_OverridesAdditionalTools(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset1", true), + mockTool("tool3", "toolset2", true), + } + + // tool3 is explicitly enabled via WithTools, but also disallowed + // disallowed should win because builder filters run before additional tools check + reg := mustBuild(t, NewBuilder(). + SetTools(tools). + WithToolsets([]string{"toolset1"}). + WithTools([]string{"tool3"}). + WithDisallowedTools([]string{"tool3"})) + + available := reg.AvailableTools(context.Background()) + names := make(map[string]bool) + for _, tool := range available { + names[tool.Tool.Name] = true + } + + require.True(t, names["tool1"], "tool1 should be available") + require.True(t, names["tool2"], "tool2 should be available") + require.False(t, names["tool3"], "tool3 should be disallowed even though explicitly added via WithTools") +} + +func TestWithDisallowedTools_CombinesWithReadOnly(t *testing.T) { + tools := []ServerTool{ + mockTool("read_tool", "toolset1", true), + mockTool("write_tool", "toolset1", false), + mockTool("another_read", "toolset1", true), + } + + // read-only excludes write_tool, disallowed excludes read_tool + reg := mustBuild(t, NewBuilder(). + SetTools(tools). + WithToolsets([]string{"all"}). + WithReadOnly(true). + WithDisallowedTools([]string{"read_tool"})) + + available := reg.AvailableTools(context.Background()) + require.Len(t, available, 1) + require.Equal(t, "another_read", available[0].Tool.Name) +} + +func TestCreateDisallowedToolsFilter(t *testing.T) { + filter := CreateDisallowedToolsFilter([]string{"blocked_tool"}) + + blockedTool := mockTool("blocked_tool", "toolset1", true) + allowedTool := mockTool("allowed_tool", "toolset1", true) + + allowed, err := filter(context.Background(), &blockedTool) + require.NoError(t, err) + require.False(t, allowed, "blocked_tool should be excluded") + + allowed, err = filter(context.Background(), &allowedTool) + require.NoError(t, err) + require.True(t, allowed, "allowed_tool should be included") +} From 9c8f96f6bf76310ec31b9e821b309f6992f04c32 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 18 Feb 2026 15:09:03 +0000 Subject: [PATCH 10/57] add header support in http entry point --- pkg/context/request.go | 16 ++++++++ pkg/http/handler.go | 4 ++ pkg/http/handler_test.go | 59 +++++++++++++++++++++++++++ pkg/http/headers/headers.go | 3 ++ pkg/http/middleware/request_config.go | 5 +++ 5 files changed, 87 insertions(+) diff --git a/pkg/context/request.go b/pkg/context/request.go index 70867f32e..8986f0c9e 100644 --- a/pkg/context/request.go +++ b/pkg/context/request.go @@ -82,6 +82,22 @@ func IsInsidersMode(ctx context.Context) bool { return false } +// disallowedToolsCtxKey is a context key for disallowed tools +type disallowedToolsCtxKey struct{} + +// WithDisallowedTools adds the disallowed tools to the context +func WithDisallowedTools(ctx context.Context, tools []string) context.Context { + return context.WithValue(ctx, disallowedToolsCtxKey{}, tools) +} + +// GetDisallowedTools retrieves the disallowed tools from the context +func GetDisallowedTools(ctx context.Context) []string { + if tools, ok := ctx.Value(disallowedToolsCtxKey{}).([]string); ok { + return tools + } + return nil +} + // headerFeaturesCtxKey is a context key for raw header feature flags type headerFeaturesCtxKey struct{} diff --git a/pkg/http/handler.go b/pkg/http/handler.go index c4fcdec72..3db6d07ce 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -275,6 +275,10 @@ func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *in builder = builder.WithTools(github.CleanTools(tools)) } + if disallowed := ghcontext.GetDisallowedTools(ctx); len(disallowed) > 0 { + builder = builder.WithDisallowedTools(disallowed) + } + return builder } diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index 32125f987..5d5d133d9 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -104,6 +104,31 @@ func TestInventoryFiltersForRequest(t *testing.T) { }, expectedTools: []string{"get_file_contents", "create_repository", "list_issues"}, }, + { + name: "disallowed tools removes specific tools", + contextSetup: func(ctx context.Context) context.Context { + return ghcontext.WithDisallowedTools(ctx, []string{"create_repository", "issue_write"}) + }, + expectedTools: []string{"get_file_contents", "list_issues"}, + }, + { + name: "disallowed tools overrides explicit tools", + contextSetup: func(ctx context.Context) context.Context { + ctx = ghcontext.WithTools(ctx, []string{"list_issues", "create_repository"}) + ctx = ghcontext.WithDisallowedTools(ctx, []string{"create_repository"}) + return ctx + }, + expectedTools: []string{"list_issues"}, + }, + { + name: "disallowed tools combines with readonly", + contextSetup: func(ctx context.Context) context.Context { + ctx = ghcontext.WithReadonly(ctx, true) + ctx = ghcontext.WithDisallowedTools(ctx, []string{"list_issues"}) + return ctx + }, + expectedTools: []string{"get_file_contents"}, + }, } for _, tt := range tests { @@ -267,6 +292,40 @@ func TestHTTPHandlerRoutes(t *testing.T) { }, expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"}, }, + { + name: "X-MCP-Disallowed-Tools header removes specific tools", + path: "/", + headers: map[string]string{ + headers.MCPDisallowedToolsHeader: "create_issue,create_pull_request", + }, + expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "list_pull_requests", "hidden_by_holdback"}, + }, + { + name: "X-MCP-Disallowed-Tools with toolset header", + path: "/", + headers: map[string]string{ + headers.MCPToolsetsHeader: "issues", + headers.MCPDisallowedToolsHeader: "create_issue", + }, + expectedTools: []string{"list_issues"}, + }, + { + name: "X-MCP-Disallowed-Tools overrides X-MCP-Tools", + path: "/", + headers: map[string]string{ + headers.MCPToolsHeader: "list_issues,create_issue", + headers.MCPDisallowedToolsHeader: "create_issue", + }, + expectedTools: []string{"list_issues"}, + }, + { + name: "X-MCP-Disallowed-Tools with readonly path", + path: "/readonly", + headers: map[string]string{ + headers.MCPDisallowedToolsHeader: "list_issues", + }, + expectedTools: []string{"get_file_contents", "list_pull_requests", "hidden_by_holdback"}, + }, } for _, tt := range tests { diff --git a/pkg/http/headers/headers.go b/pkg/http/headers/headers.go index bbc46b43f..63601fa48 100644 --- a/pkg/http/headers/headers.go +++ b/pkg/http/headers/headers.go @@ -41,6 +41,9 @@ const ( MCPLockdownHeader = "X-MCP-Lockdown" // MCPInsidersHeader indicates whether insiders mode is enabled for early access features. MCPInsidersHeader = "X-MCP-Insiders" + // MCPDisallowedToolsHeader is a comma-separated list of MCP tools that should be + // disabled regardless of other settings or header values. + MCPDisallowedToolsHeader = "X-MCP-Disallowed-Tools" // MCPFeaturesHeader is a comma-separated list of feature flags to enable. MCPFeaturesHeader = "X-MCP-Features" diff --git a/pkg/http/middleware/request_config.go b/pkg/http/middleware/request_config.go index 5cabe16eb..779f462d5 100644 --- a/pkg/http/middleware/request_config.go +++ b/pkg/http/middleware/request_config.go @@ -35,6 +35,11 @@ func WithRequestConfig(next http.Handler) http.Handler { ctx = ghcontext.WithLockdownMode(ctx, true) } + // Disallowed tools + if disallowedTools := headers.ParseCommaSeparated(r.Header.Get(headers.MCPDisallowedToolsHeader)); len(disallowedTools) > 0 { + ctx = ghcontext.WithDisallowedTools(ctx, disallowedTools) + } + // Insiders mode if relaxedParseBool(r.Header.Get(headers.MCPInsidersHeader)) { ctx = ghcontext.WithInsidersMode(ctx, true) From c38802ac800fa1e037765c85bfc44f23a0f9f1bc Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 18 Feb 2026 15:23:31 +0000 Subject: [PATCH 11/57] rename to --exclude-tools --- cmd/github-mcp-server/main.go | 16 ++++---- docs/server-configuration.md | 12 +++--- internal/ghmcp/server.go | 8 ++-- pkg/context/request.go | 16 ++++---- pkg/github/server.go | 4 +- pkg/http/handler.go | 4 +- pkg/http/handler_test.go | 32 ++++++++-------- pkg/http/headers/headers.go | 4 +- pkg/http/middleware/request_config.go | 6 +-- pkg/inventory/builder.go | 16 ++++---- pkg/inventory/registry_test.go | 54 +++++++++++++-------------- 11 files changed, 86 insertions(+), 86 deletions(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 47da21e61..05c2c6e0b 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -61,11 +61,11 @@ var ( } } - // Parse disallowed tools (similar to tools) - var disallowedTools []string - if viper.IsSet("disallowed_tools") { - if err := viper.UnmarshalKey("disallowed_tools", &disallowedTools); err != nil { - return fmt.Errorf("failed to unmarshal disallowed-tools: %w", err) + // Parse excluded tools (similar to tools) + var excludeTools []string + if viper.IsSet("exclude_tools") { + if err := viper.UnmarshalKey("exclude_tools", &excludeTools); err != nil { + return fmt.Errorf("failed to unmarshal exclude-tools: %w", err) } } @@ -93,7 +93,7 @@ var ( ContentWindowSize: viper.GetInt("content-window-size"), LockdownMode: viper.GetBool("lockdown-mode"), InsidersMode: viper.GetBool("insiders"), - DisallowedTools: disallowedTools, + ExcludeTools: excludeTools, RepoAccessCacheTTL: &ttl, } return ghmcp.RunStdioServer(stdioServerConfig) @@ -135,7 +135,7 @@ func init() { // Add global flags that will be shared by all commands rootCmd.PersistentFlags().StringSlice("toolsets", nil, github.GenerateToolsetsHelp()) rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable") - rootCmd.PersistentFlags().StringSlice("disallowed-tools", nil, "Comma-separated list of tool names to disable regardless of other settings") + rootCmd.PersistentFlags().StringSlice("exclude-tools", nil, "Comma-separated list of tool names to disable regardless of other settings") rootCmd.PersistentFlags().StringSlice("features", nil, "Comma-separated list of feature flags to enable") rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets") rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations") @@ -157,7 +157,7 @@ func init() { // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) _ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools")) - _ = viper.BindPFlag("disallowed_tools", rootCmd.PersistentFlags().Lookup("disallowed-tools")) + _ = viper.BindPFlag("exclude_tools", rootCmd.PersistentFlags().Lookup("exclude-tools")) _ = viper.BindPFlag("features", rootCmd.PersistentFlags().Lookup("features")) _ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets")) _ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only")) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 2c5f875b2..506ac0354 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -9,7 +9,7 @@ We currently support the following ways in which the GitHub MCP Server can be co |---------------|---------------|--------------| | Toolsets | `X-MCP-Toolsets` header or `/x/{toolset}` URL | `--toolsets` flag or `GITHUB_TOOLSETS` env var | | Individual Tools | `X-MCP-Tools` header | `--tools` flag or `GITHUB_TOOLS` env var | -| Disallowed Tools | `X-MCP-Disallowed-Tools` header | `--disallowed-tools` flag or `GITHUB_DISALLOWED_TOOLS` env var | +| Exclude Tools | `X-MCP-Exclude-Tools` header | `--exclude-tools` flag or `GITHUB_EXCLUDE_TOOLS` env var | | Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var | | Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var | | Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var | @@ -21,11 +21,11 @@ We currently support the following ways in which the GitHub MCP Server can be co ## How Configuration Works -All configuration options are **composable**: you can combine toolsets, individual tools, disallowed tools, dynamic discovery, read-only mode and lockdown mode in any way that suits your workflow. +All configuration options are **composable**: you can combine toolsets, individual tools, excluded tools, dynamic discovery, read-only mode and lockdown mode in any way that suits your workflow. Note: **read-only** mode acts as a strict security filter that takes precedence over any other configuration, by disabling write tools even when explicitly requested. -Note: **disallowed tools** takes precedence over toolsets and individual tools — listed tools are always excluded, even if their toolset is enabled or they are explicitly added via `--tools` / `X-MCP-Tools`. +Note: **excluded tools** takes precedence over toolsets and individual tools — listed tools are always excluded, even if their toolset is enabled or they are explicitly added via `--tools` / `X-MCP-Tools`. --- @@ -173,7 +173,7 @@ Enable entire toolsets, then add individual tools from toolsets you don't want f --- -### Disallowing Specific Tools +### Excluding Specific Tools **Best for:** Users who want to enable a broad toolset but need to exclude specific tools for security, compliance, or to prevent undesired behavior. @@ -190,7 +190,7 @@ Listed tools are removed regardless of any other configuration — even if their "url": "https://api.githubcopilot.com/mcp/", "headers": { "X-MCP-Toolsets": "pull_requests", - "X-MCP-Disallowed-Tools": "create_pull_request,merge_pull_request" + "X-MCP-Exclude-Tools": "create_pull_request,merge_pull_request" } } ``` @@ -207,7 +207,7 @@ Listed tools are removed regardless of any other configuration — even if their "./cmd/github-mcp-server", "stdio", "--toolsets=pull_requests", - "--disallowed-tools=create_pull_request,merge_pull_request" + "--exclude-tools=create_pull_request,merge_pull_request" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index beaa898e2..5c4e7f6f1 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -135,7 +135,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se WithReadOnly(cfg.ReadOnly). WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)). WithTools(github.CleanTools(cfg.EnabledTools)). - WithDisallowedTools(cfg.DisallowedTools). + WithExcludeTools(cfg.ExcludeTools). WithServerInstructions(). WithFeatureChecker(featureChecker). WithInsidersMode(cfg.InsidersMode) @@ -215,10 +215,10 @@ type StdioServerConfig struct { // InsidersMode indicates if we should enable experimental features InsidersMode bool - // DisallowedTools is a list of tool names to disable regardless of other settings. + // ExcludeTools is a list of tool names to disable regardless of other settings. // These tools will be excluded even if their toolset is enabled or they are // explicitly listed in EnabledTools. - DisallowedTools []string + ExcludeTools []string // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration @@ -277,7 +277,7 @@ func RunStdioServer(cfg StdioServerConfig) error { ContentWindowSize: cfg.ContentWindowSize, LockdownMode: cfg.LockdownMode, InsidersMode: cfg.InsidersMode, - DisallowedTools: cfg.DisallowedTools, + ExcludeTools: cfg.ExcludeTools, Logger: logger, RepoAccessTTL: cfg.RepoAccessCacheTTL, TokenScopes: tokenScopes, diff --git a/pkg/context/request.go b/pkg/context/request.go index 8986f0c9e..9af925fc1 100644 --- a/pkg/context/request.go +++ b/pkg/context/request.go @@ -82,17 +82,17 @@ func IsInsidersMode(ctx context.Context) bool { return false } -// disallowedToolsCtxKey is a context key for disallowed tools -type disallowedToolsCtxKey struct{} +// excludeToolsCtxKey is a context key for excluded tools +type excludeToolsCtxKey struct{} -// WithDisallowedTools adds the disallowed tools to the context -func WithDisallowedTools(ctx context.Context, tools []string) context.Context { - return context.WithValue(ctx, disallowedToolsCtxKey{}, tools) +// WithExcludeTools adds the excluded tools to the context +func WithExcludeTools(ctx context.Context, tools []string) context.Context { + return context.WithValue(ctx, excludeToolsCtxKey{}, tools) } -// GetDisallowedTools retrieves the disallowed tools from the context -func GetDisallowedTools(ctx context.Context) []string { - if tools, ok := ctx.Value(disallowedToolsCtxKey{}).([]string); ok { +// GetExcludeTools retrieves the excluded tools from the context +func GetExcludeTools(ctx context.Context) []string { + if tools, ok := ctx.Value(excludeToolsCtxKey{}).([]string); ok { return tools } return nil diff --git a/pkg/github/server.go b/pkg/github/server.go index 5ff71fc0e..06c12575d 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -62,10 +62,10 @@ type MCPServerConfig struct { // RepoAccessTTL overrides the default TTL for repository access cache entries. RepoAccessTTL *time.Duration - // DisallowedTools is a list of tool names that should be disabled regardless of + // ExcludeTools is a list of tool names that should be disabled regardless of // other configuration. These tools will be excluded even if their toolset is enabled // or they are explicitly listed in EnabledTools. - DisallowedTools []string + ExcludeTools []string // TokenScopes contains the OAuth scopes available to the token. // When non-nil, tools requiring scopes not in this list will be hidden. diff --git a/pkg/http/handler.go b/pkg/http/handler.go index 3db6d07ce..2e828211d 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -275,8 +275,8 @@ func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *in builder = builder.WithTools(github.CleanTools(tools)) } - if disallowed := ghcontext.GetDisallowedTools(ctx); len(disallowed) > 0 { - builder = builder.WithDisallowedTools(disallowed) + if excluded := ghcontext.GetExcludeTools(ctx); len(excluded) > 0 { + builder = builder.WithExcludeTools(excluded) } return builder diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index 5d5d133d9..2a19e0a23 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -105,26 +105,26 @@ func TestInventoryFiltersForRequest(t *testing.T) { expectedTools: []string{"get_file_contents", "create_repository", "list_issues"}, }, { - name: "disallowed tools removes specific tools", + name: "excluded tools removes specific tools", contextSetup: func(ctx context.Context) context.Context { - return ghcontext.WithDisallowedTools(ctx, []string{"create_repository", "issue_write"}) + return ghcontext.WithExcludeTools(ctx, []string{"create_repository", "issue_write"}) }, expectedTools: []string{"get_file_contents", "list_issues"}, }, { - name: "disallowed tools overrides explicit tools", + name: "excluded tools overrides explicit tools", contextSetup: func(ctx context.Context) context.Context { ctx = ghcontext.WithTools(ctx, []string{"list_issues", "create_repository"}) - ctx = ghcontext.WithDisallowedTools(ctx, []string{"create_repository"}) + ctx = ghcontext.WithExcludeTools(ctx, []string{"create_repository"}) return ctx }, expectedTools: []string{"list_issues"}, }, { - name: "disallowed tools combines with readonly", + name: "excluded tools combines with readonly", contextSetup: func(ctx context.Context) context.Context { ctx = ghcontext.WithReadonly(ctx, true) - ctx = ghcontext.WithDisallowedTools(ctx, []string{"list_issues"}) + ctx = ghcontext.WithExcludeTools(ctx, []string{"list_issues"}) return ctx }, expectedTools: []string{"get_file_contents"}, @@ -293,36 +293,36 @@ func TestHTTPHandlerRoutes(t *testing.T) { expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"}, }, { - name: "X-MCP-Disallowed-Tools header removes specific tools", + name: "X-MCP-Exclude-Tools header removes specific tools", path: "/", headers: map[string]string{ - headers.MCPDisallowedToolsHeader: "create_issue,create_pull_request", + headers.MCPExcludeToolsHeader: "create_issue,create_pull_request", }, expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "list_pull_requests", "hidden_by_holdback"}, }, { - name: "X-MCP-Disallowed-Tools with toolset header", + name: "X-MCP-Exclude-Tools with toolset header", path: "/", headers: map[string]string{ - headers.MCPToolsetsHeader: "issues", - headers.MCPDisallowedToolsHeader: "create_issue", + headers.MCPToolsetsHeader: "issues", + headers.MCPExcludeToolsHeader: "create_issue", }, expectedTools: []string{"list_issues"}, }, { - name: "X-MCP-Disallowed-Tools overrides X-MCP-Tools", + name: "X-MCP-Exclude-Tools overrides X-MCP-Tools", path: "/", headers: map[string]string{ - headers.MCPToolsHeader: "list_issues,create_issue", - headers.MCPDisallowedToolsHeader: "create_issue", + headers.MCPToolsHeader: "list_issues,create_issue", + headers.MCPExcludeToolsHeader: "create_issue", }, expectedTools: []string{"list_issues"}, }, { - name: "X-MCP-Disallowed-Tools with readonly path", + name: "X-MCP-Exclude-Tools with readonly path", path: "/readonly", headers: map[string]string{ - headers.MCPDisallowedToolsHeader: "list_issues", + headers.MCPExcludeToolsHeader: "list_issues", }, expectedTools: []string{"get_file_contents", "list_pull_requests", "hidden_by_holdback"}, }, diff --git a/pkg/http/headers/headers.go b/pkg/http/headers/headers.go index 63601fa48..e032a0ce9 100644 --- a/pkg/http/headers/headers.go +++ b/pkg/http/headers/headers.go @@ -41,9 +41,9 @@ const ( MCPLockdownHeader = "X-MCP-Lockdown" // MCPInsidersHeader indicates whether insiders mode is enabled for early access features. MCPInsidersHeader = "X-MCP-Insiders" - // MCPDisallowedToolsHeader is a comma-separated list of MCP tools that should be + // MCPExcludeToolsHeader is a comma-separated list of MCP tools that should be // disabled regardless of other settings or header values. - MCPDisallowedToolsHeader = "X-MCP-Disallowed-Tools" + MCPExcludeToolsHeader = "X-MCP-Exclude-Tools" // MCPFeaturesHeader is a comma-separated list of feature flags to enable. MCPFeaturesHeader = "X-MCP-Features" diff --git a/pkg/http/middleware/request_config.go b/pkg/http/middleware/request_config.go index 779f462d5..a7311334d 100644 --- a/pkg/http/middleware/request_config.go +++ b/pkg/http/middleware/request_config.go @@ -35,9 +35,9 @@ func WithRequestConfig(next http.Handler) http.Handler { ctx = ghcontext.WithLockdownMode(ctx, true) } - // Disallowed tools - if disallowedTools := headers.ParseCommaSeparated(r.Header.Get(headers.MCPDisallowedToolsHeader)); len(disallowedTools) > 0 { - ctx = ghcontext.WithDisallowedTools(ctx, disallowedTools) + // Excluded tools + if excludeTools := headers.ParseCommaSeparated(r.Header.Get(headers.MCPExcludeToolsHeader)); len(excludeTools) > 0 { + ctx = ghcontext.WithExcludeTools(ctx, excludeTools) } // Insiders mode diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index 904498881..d492e69b5 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -141,15 +141,15 @@ func (b *Builder) WithFilter(filter ToolFilter) *Builder { return b } -// WithDisallowedTools specifies tools that should be disabled regardless of other settings. +// WithExcludeTools specifies tools that should be disabled regardless of other settings. // These tools will be excluded even if their toolset is enabled or they are in the // additional tools list. This takes precedence over all other tool enablement settings. // Input is cleaned (trimmed, deduplicated) before applying. // Returns self for chaining. -func (b *Builder) WithDisallowedTools(toolNames []string) *Builder { +func (b *Builder) WithExcludeTools(toolNames []string) *Builder { cleaned := cleanTools(toolNames) if len(cleaned) > 0 { - b.filters = append(b.filters, CreateDisallowedToolsFilter(cleaned)) + b.filters = append(b.filters, CreateExcludeToolsFilter(cleaned)) } return b } @@ -163,12 +163,12 @@ func (b *Builder) WithInsidersMode(enabled bool) *Builder { return b } -// CreateDisallowedToolsFilter creates a ToolFilter that excludes tools by name. -// Any tool whose name appears in the disallowed list will be filtered out. +// CreateExcludeToolsFilter creates a ToolFilter that excludes tools by name. +// Any tool whose name appears in the excluded list will be filtered out. // The input slice should already be cleaned (trimmed, deduplicated). -func CreateDisallowedToolsFilter(disallowed []string) ToolFilter { - set := make(map[string]struct{}, len(disallowed)) - for _, name := range disallowed { +func CreateExcludeToolsFilter(excluded []string) ToolFilter { + set := make(map[string]struct{}, len(excluded)) + for _, name := range excluded { set[name] = struct{}{} } return func(_ context.Context, tool *ServerTool) (bool, error) { diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index cb0f038d7..207e65dba 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -2130,7 +2130,7 @@ func TestWithInsidersMode_DoesNotMutateOriginalTools(t *testing.T) { require.Equal(t, "kept", tools[0].Tool.Meta["description"], "original tool should not be mutated") } -func TestWithDisallowedTools(t *testing.T) { +func TestWithExcludeTools(t *testing.T) { tools := []ServerTool{ mockTool("tool1", "toolset1", true), mockTool("tool2", "toolset1", true), @@ -2139,56 +2139,56 @@ func TestWithDisallowedTools(t *testing.T) { tests := []struct { name string - disallowed []string + excluded []string toolsets []string expectedNames []string unexpectedNames []string }{ { - name: "single tool disallowed", - disallowed: []string{"tool2"}, + name: "single tool excluded", + excluded: []string{"tool2"}, toolsets: []string{"all"}, expectedNames: []string{"tool1", "tool3"}, unexpectedNames: []string{"tool2"}, }, { - name: "multiple tools disallowed", - disallowed: []string{"tool1", "tool3"}, + name: "multiple tools excluded", + excluded: []string{"tool1", "tool3"}, toolsets: []string{"all"}, expectedNames: []string{"tool2"}, unexpectedNames: []string{"tool1", "tool3"}, }, { - name: "empty disallowed list is a no-op", - disallowed: []string{}, + name: "empty excluded list is a no-op", + excluded: []string{}, toolsets: []string{"all"}, expectedNames: []string{"tool1", "tool2", "tool3"}, unexpectedNames: nil, }, { - name: "nil disallowed list is a no-op", - disallowed: nil, + name: "nil excluded list is a no-op", + excluded: nil, toolsets: []string{"all"}, expectedNames: []string{"tool1", "tool2", "tool3"}, unexpectedNames: nil, }, { - name: "disallowing non-existent tool is a no-op", - disallowed: []string{"nonexistent"}, + name: "excluding non-existent tool is a no-op", + excluded: []string{"nonexistent"}, toolsets: []string{"all"}, expectedNames: []string{"tool1", "tool2", "tool3"}, unexpectedNames: nil, }, { - name: "disallow all tools", - disallowed: []string{"tool1", "tool2", "tool3"}, + name: "exclude all tools", + excluded: []string{"tool1", "tool2", "tool3"}, toolsets: []string{"all"}, expectedNames: nil, unexpectedNames: []string{"tool1", "tool2", "tool3"}, }, { name: "whitespace is trimmed", - disallowed: []string{" tool2 ", " tool3 "}, + excluded: []string{" tool2 ", " tool3 "}, toolsets: []string{"all"}, expectedNames: []string{"tool1"}, unexpectedNames: []string{"tool2", "tool3"}, @@ -2200,7 +2200,7 @@ func TestWithDisallowedTools(t *testing.T) { reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets(tt.toolsets). - WithDisallowedTools(tt.disallowed)) + WithExcludeTools(tt.excluded)) available := reg.AvailableTools(context.Background()) names := make(map[string]bool) @@ -2212,26 +2212,26 @@ func TestWithDisallowedTools(t *testing.T) { require.True(t, names[expected], "tool %q should be available", expected) } for _, unexpected := range tt.unexpectedNames { - require.False(t, names[unexpected], "tool %q should be disallowed", unexpected) + require.False(t, names[unexpected], "tool %q should be excluded", unexpected) } }) } } -func TestWithDisallowedTools_OverridesAdditionalTools(t *testing.T) { +func TestWithExcludeTools_OverridesAdditionalTools(t *testing.T) { tools := []ServerTool{ mockTool("tool1", "toolset1", true), mockTool("tool2", "toolset1", true), mockTool("tool3", "toolset2", true), } - // tool3 is explicitly enabled via WithTools, but also disallowed - // disallowed should win because builder filters run before additional tools check + // tool3 is explicitly enabled via WithTools, but also excluded + // excluded should win because builder filters run before additional tools check reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"toolset1"}). WithTools([]string{"tool3"}). - WithDisallowedTools([]string{"tool3"})) + WithExcludeTools([]string{"tool3"})) available := reg.AvailableTools(context.Background()) names := make(map[string]bool) @@ -2241,30 +2241,30 @@ func TestWithDisallowedTools_OverridesAdditionalTools(t *testing.T) { require.True(t, names["tool1"], "tool1 should be available") require.True(t, names["tool2"], "tool2 should be available") - require.False(t, names["tool3"], "tool3 should be disallowed even though explicitly added via WithTools") + require.False(t, names["tool3"], "tool3 should be excluded even though explicitly added via WithTools") } -func TestWithDisallowedTools_CombinesWithReadOnly(t *testing.T) { +func TestWithExcludeTools_CombinesWithReadOnly(t *testing.T) { tools := []ServerTool{ mockTool("read_tool", "toolset1", true), mockTool("write_tool", "toolset1", false), mockTool("another_read", "toolset1", true), } - // read-only excludes write_tool, disallowed excludes read_tool + // read-only excludes write_tool, exclude-tools excludes read_tool reg := mustBuild(t, NewBuilder(). SetTools(tools). WithToolsets([]string{"all"}). WithReadOnly(true). - WithDisallowedTools([]string{"read_tool"})) + WithExcludeTools([]string{"read_tool"})) available := reg.AvailableTools(context.Background()) require.Len(t, available, 1) require.Equal(t, "another_read", available[0].Tool.Name) } -func TestCreateDisallowedToolsFilter(t *testing.T) { - filter := CreateDisallowedToolsFilter([]string{"blocked_tool"}) +func TestCreateExcludeToolsFilter(t *testing.T) { + filter := CreateExcludeToolsFilter([]string{"blocked_tool"}) blockedTool := mockTool("blocked_tool", "toolset1", true) allowedTool := mockTool("allowed_tool", "toolset1", true) From dc3ee11f4c1c9541a2e918acfc7e20c8332c9a36 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 19 Feb 2026 15:17:52 +0000 Subject: [PATCH 12/57] fix for -1 in tailLines (#2047) --- pkg/buffer/buffer.go | 3 +++ pkg/github/actions.go | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/buffer/buffer.go b/pkg/buffer/buffer.go index 4a370d6d7..23cc818e1 100644 --- a/pkg/buffer/buffer.go +++ b/pkg/buffer/buffer.go @@ -32,6 +32,9 @@ const maxLineSize = 10 * 1024 * 1024 // If the response contains more lines than maxJobLogLines, only the most recent lines are kept. // Lines exceeding maxLineSize are truncated with a marker. func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines int) (string, int, *http.Response, error) { + if maxJobLogLines <= 0 { + maxJobLogLines = 500 + } if maxJobLogLines > 100000 { maxJobLogLines = 100000 } diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 516c7fe37..c3b5bb8c7 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -702,8 +702,8 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - // Default to 500 lines if not specified - if tailLines == 0 { + // Default to 500 lines if not specified or invalid + if tailLines <= 0 { tailLines = 500 } From dc41c650a307e44ef50bb43f1c3988a87f9c35e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:41:15 +0000 Subject: [PATCH 13/57] build(deps): bump hono Bumps the npm_and_yarn group with 1 update in the /ui directory: [hono](https://github.com/honojs/hono). Updates `hono` from 4.11.7 to 4.12.0 - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.11.7...v4.12.0) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.0 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- ui/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 692c8d132..7ff670972 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -3697,9 +3697,9 @@ "peer": true }, "node_modules/hono": { - "version": "4.11.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", - "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", + "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", "license": "MIT", "peer": true, "engines": { From d982175686a4f87410bed84506c7ec54da3385a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:49:46 +0000 Subject: [PATCH 14/57] build(deps): bump ajv Bumps the npm_and_yarn group with 1 update in the /ui directory: [ajv](https://github.com/ajv-validator/ajv). Updates `ajv` from 8.17.1 to 8.18.0 - [Release notes](https://github.com/ajv-validator/ajv/releases) - [Commits](https://github.com/ajv-validator/ajv/compare/v8.17.1...v8.18.0) --- updated-dependencies: - dependency-name: ajv dependency-version: 8.18.0 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- ui/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 7ff670972..f5314fb08 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2668,9 +2668,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "peer": true, "dependencies": { From 16ff74a0eb3983d9879749c9c944dd9e6a7b0283 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 23 Feb 2026 11:38:26 +0100 Subject: [PATCH 15/57] feat: move copilot tools to default copilot toolset (#2039) * feat: move copilot tools to default copilot toolset Move AssignCopilotToIssue and RequestCopilotReview from the issues and pull_requests toolsets respectively into a new default-enabled copilot toolset. This groups all copilot-related tools together and makes them available by default. - Set Default: true on ToolsetMetadataCopilot - Remove copilot from RemoteOnlyToolsets() - Update AllTools() grouping and tests - Regenerate docs Refs: github/copilot-mcp-core#1180 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: move copilot tools to dedicated copilot.go Extract AssignCopilotToIssue, RequestCopilotReview, AssignCodingAgentPrompt, and all supporting types/helpers from issues.go and pullrequests.go into copilot.go and copilot_test.go. This follows the existing convention where each domain (actions, dependabot, discussions, gists, etc.) has its own file, and keeps the copilot toolset implementation cohesive in one place. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 35 +- docs/remote-server.md | 2 +- pkg/github/copilot.go | 607 +++++++++++++++++++++++ pkg/github/copilot_test.go | 854 ++++++++++++++++++++++++++++++++ pkg/github/issues.go | 497 ------------------- pkg/github/issues_test.go | 724 --------------------------- pkg/github/pullrequests.go | 89 ---- pkg/github/pullrequests_test.go | 112 ----- pkg/github/tools.go | 13 +- pkg/github/tools_test.go | 3 + 10 files changed, 1494 insertions(+), 1442 deletions(-) create mode 100644 pkg/github/copilot.go create mode 100644 pkg/github/copilot_test.go diff --git a/README.md b/README.md index 6e964a192..ee7b51e95 100644 --- a/README.md +++ b/README.md @@ -560,6 +560,7 @@ The following sets of tools are available: | person | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in | | workflow | `actions` | GitHub Actions workflows and CI/CD operations | | codescan | `code_security` | Code security related tools, such as GitHub Code Scanning | +| copilot | `copilot` | Copilot related tools | | dependabot | `dependabot` | Dependabot tools | | comment-discussion | `discussions` | GitHub Discussions related tools | | logo-gist | `gists` | GitHub Gist related tools | @@ -686,6 +687,26 @@ The following sets of tools are available:
+copilot Copilot + +- **assign_copilot_to_issue** - Assign Copilot to issue + - **Required OAuth Scopes**: `repo` + - `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) + - `custom_instructions`: Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description (string, optional) + - `issue_number`: Issue number (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + +- **request_copilot_review** - Request Copilot review + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number (number, required) + - `repo`: Repository name (string, required) + +
+ +
+ dependabot Dependabot - **get_dependabot_alert** - Get dependabot alert @@ -794,14 +815,6 @@ The following sets of tools are available: - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) -- **assign_copilot_to_issue** - Assign Copilot to issue - - **Required OAuth Scopes**: `repo` - - `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) - - `custom_instructions`: Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description (string, optional) - - `issue_number`: Issue number (number, required) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - **get_label** - Get a specific label from a repository. - **Required OAuth Scopes**: `repo` - `name`: Label name. (string, required) @@ -1106,12 +1119,6 @@ The following sets of tools are available: - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) -- **request_copilot_review** - Request Copilot review - - **Required OAuth Scopes**: `repo` - - `owner`: Repository owner (string, required) - - `pullNumber`: Pull request number (number, required) - - `repo`: Repository name (string, required) - - **search_pull_requests** - Search pull requests - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) diff --git a/docs/remote-server.md b/docs/remote-server.md index cad9ed604..5a82f1c2e 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -22,6 +22,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | apps
`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) | | workflow
`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) | | codescan
`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) | +| copilot
`copilot` | Copilot related tools | 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) | | dependabot
`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) | | comment-discussion
`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) | | logo-gist
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | @@ -46,7 +47,6 @@ These toolsets are only available in the remote GitHub MCP Server and are not in | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | -| copilot
`copilot` | Copilot related tools | 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) | | copilot
`copilot_spaces` | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | | book
`github_support_docs_search` | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go new file mode 100644 index 000000000..525a58c69 --- /dev/null +++ b/pkg/github/copilot.go @@ -0,0 +1,607 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/octicons" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/go-viper/mapstructure/v2" + "github.com/google/go-github/v82/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// mvpDescription is an MVP idea for generating tool descriptions from structured data in a shared format. +// It is not intended for widespread usage and is not a complete implementation. +type mvpDescription struct { + summary string + outcomes []string + referenceLinks []string +} + +func (d *mvpDescription) String() string { + var sb strings.Builder + sb.WriteString(d.summary) + if len(d.outcomes) > 0 { + sb.WriteString("\n\n") + sb.WriteString("This tool can help with the following outcomes:\n") + for _, outcome := range d.outcomes { + sb.WriteString(fmt.Sprintf("- %s\n", outcome)) + } + } + + if len(d.referenceLinks) > 0 { + sb.WriteString("\n\n") + sb.WriteString("More information can be found at:\n") + for _, link := range d.referenceLinks { + sb.WriteString(fmt.Sprintf("- %s\n", link)) + } + } + + return sb.String() +} + +// linkedPullRequest represents a PR linked to an issue by Copilot. +type linkedPullRequest struct { + Number int + URL string + Title string + State string + CreatedAt time.Time +} + +// pollConfigKey is a context key for polling configuration. +type pollConfigKey struct{} + +// PollConfig configures the PR polling behavior. +type PollConfig struct { + MaxAttempts int + Delay time.Duration +} + +// ContextWithPollConfig returns a context with polling configuration. +// Use this in tests to reduce or disable polling. +func ContextWithPollConfig(ctx context.Context, config PollConfig) context.Context { + return context.WithValue(ctx, pollConfigKey{}, config) +} + +// getPollConfig returns the polling configuration from context, or defaults. +func getPollConfig(ctx context.Context) PollConfig { + if config, ok := ctx.Value(pollConfigKey{}).(PollConfig); ok { + return config + } + // Default: 9 attempts with 1s delay = 8s max wait + // Based on observed latency in remote server: p50 ~5s, p90 ~7s + return PollConfig{MaxAttempts: 9, Delay: 1 * time.Second} +} + +// findLinkedCopilotPR searches for a PR created by the copilot-swe-agent bot that references the given issue. +// It queries the issue's timeline for CrossReferencedEvent items from PRs authored by copilot-swe-agent. +// The createdAfter parameter filters to only return PRs created after the specified time. +func findLinkedCopilotPR(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int, createdAfter time.Time) (*linkedPullRequest, error) { + // Query timeline items looking for CrossReferencedEvent from PRs by copilot-swe-agent + var query struct { + Repository struct { + Issue struct { + TimelineItems struct { + Nodes []struct { + TypeName string `graphql:"__typename"` + CrossReferencedEvent struct { + Source struct { + PullRequest struct { + Number int + URL string + Title string + State string + CreatedAt githubv4.DateTime + Author struct { + Login string + } + } `graphql:"... on PullRequest"` + } + } `graphql:"... on CrossReferencedEvent"` + } + } `graphql:"timelineItems(first: 20, itemTypes: [CROSS_REFERENCED_EVENT])"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + "number": githubv4.Int(issueNumber), //nolint:gosec // Issue numbers are always small positive integers + } + + if err := client.Query(ctx, &query, variables); err != nil { + return nil, err + } + + // Look for a PR from copilot-swe-agent created after the assignment time + for _, node := range query.Repository.Issue.TimelineItems.Nodes { + if node.TypeName != "CrossReferencedEvent" { + continue + } + pr := node.CrossReferencedEvent.Source.PullRequest + if pr.Number > 0 && pr.Author.Login == "copilot-swe-agent" { + // Only return PRs created after the assignment time + if pr.CreatedAt.Time.After(createdAfter) { + return &linkedPullRequest{ + Number: pr.Number, + URL: pr.URL, + Title: pr.Title, + State: pr.State, + CreatedAt: pr.CreatedAt.Time, + }, nil + } + } + } + + return nil, nil +} + +func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.ServerTool { + description := mvpDescription{ + summary: "Assign Copilot to a specific issue in a GitHub repository.", + outcomes: []string{ + "a Pull Request created with source code changes to resolve the issue", + }, + referenceLinks: []string{ + "https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot", + }, + } + + return NewTool( + ToolsetMetadataCopilot, + mcp.Tool{ + Name: "assign_copilot_to_issue", + Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String()), + Icons: octicons.Icons("copilot"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_USER_TITLE", "Assign Copilot to issue"), + ReadOnlyHint: false, + IdempotentHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "Issue number", + }, + "base_ref": { + Type: "string", + Description: "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch", + }, + "custom_instructions": { + Type: "string", + Description: "Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description", + }, + }, + Required: []string{"owner", "repo", "issue_number"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + var params struct { + Owner string `mapstructure:"owner"` + Repo string `mapstructure:"repo"` + IssueNumber int32 `mapstructure:"issue_number"` + BaseRef string `mapstructure:"base_ref"` + CustomInstructions string `mapstructure:"custom_instructions"` + } + if err := mapstructure.Decode(args, ¶ms); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Firstly, we try to find the copilot bot in the suggested actors for the repository. + // Although as I write this, we would expect copilot to be at the top of the list, in future, maybe + // it will not be on the first page of responses, thus we will keep paginating until we find it. + type botAssignee struct { + ID githubv4.ID + Login string + TypeName string `graphql:"__typename"` + } + + type suggestedActorsQuery struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot botAssignee `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]any{ + "owner": githubv4.String(params.Owner), + "name": githubv4.String(params.Repo), + "endCursor": (*githubv4.String)(nil), + } + + var copilotAssignee *botAssignee + for { + var query suggestedActorsQuery + err := client.Query(ctx, &query, variables) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get suggested actors", err), nil, nil + } + + // Iterate all the returned nodes looking for the copilot bot, which is supposed to have the + // same name on each host. We need this in order to get the ID for later assignment. + for _, node := range query.Repository.SuggestedActors.Nodes { + if node.Bot.Login == "copilot-swe-agent" { + copilotAssignee = &node.Bot + break + } + } + + if !query.Repository.SuggestedActors.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor) + } + + // If we didn't find the copilot bot, we can't proceed any further. + if copilotAssignee == nil { + // The e2e tests depend upon this specific message to skip the test. + return utils.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil, nil + } + + // Next, get the issue ID and repository ID + var getIssueQuery struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables = map[string]any{ + "owner": githubv4.String(params.Owner), + "name": githubv4.String(params.Repo), + "number": githubv4.Int(params.IssueNumber), + } + + if err := client.Query(ctx, &getIssueQuery, variables); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue ID", err), nil, nil + } + + // Build the assignee IDs list including copilot + actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1) + for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes { + actorIDs[i] = node.ID + } + actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID + + // Prepare agent assignment input + emptyString := githubv4.String("") + agentAssignment := &AgentAssignmentInput{ + CustomAgent: &emptyString, + CustomInstructions: &emptyString, + TargetRepositoryID: getIssueQuery.Repository.ID, + } + + // Add base ref if provided + if params.BaseRef != "" { + baseRef := githubv4.String(params.BaseRef) + agentAssignment.BaseRef = &baseRef + } + + // Add custom instructions if provided + if params.CustomInstructions != "" { + customInstructions := githubv4.String(params.CustomInstructions) + agentAssignment.CustomInstructions = &customInstructions + } + + // Execute the updateIssue mutation with the GraphQL-Features header + // This header is required for the agent assignment API which is not GA yet + var updateIssueMutation struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + } + + // Add the GraphQL-Features header for the agent assignment API + // The header will be read by the HTTP transport if it's configured to do so + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issues_copilot_assignment_api_support") + + // Capture the time before assignment to filter out older PRs during polling + assignmentTime := time.Now().UTC() + + if err := client.Mutate( + ctxWithFeatures, + &updateIssueMutation, + UpdateIssueInput{ + ID: getIssueQuery.Repository.Issue.ID, + AssigneeIDs: actorIDs, + AgentAssignment: agentAssignment, + }, + nil, + ); err != nil { + return nil, nil, fmt.Errorf("failed to update issue with agent assignment: %w", err) + } + + // Poll for a linked PR created by Copilot after the assignment + pollConfig := getPollConfig(ctx) + + // Get progress token from request for sending progress notifications + progressToken := request.Params.GetProgressToken() + + // Send initial progress notification that assignment succeeded and polling is starting + if progressToken != nil && request.Session != nil && pollConfig.MaxAttempts > 0 { + _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: 0, + Total: float64(pollConfig.MaxAttempts), + Message: "Copilot assigned to issue, waiting for PR creation...", + }) + } + + var linkedPR *linkedPullRequest + for attempt := range pollConfig.MaxAttempts { + if attempt > 0 { + time.Sleep(pollConfig.Delay) + } + + // Send progress notification if progress token is available + if progressToken != nil && request.Session != nil { + _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: progressToken, + Progress: float64(attempt + 1), + Total: float64(pollConfig.MaxAttempts), + Message: fmt.Sprintf("Waiting for Copilot to create PR... (attempt %d/%d)", attempt+1, pollConfig.MaxAttempts), + }) + } + + pr, err := findLinkedCopilotPR(ctx, client, params.Owner, params.Repo, int(params.IssueNumber), assignmentTime) + if err != nil { + // Polling errors are non-fatal, continue to next attempt + continue + } + if pr != nil { + linkedPR = pr + break + } + } + + // Build the result + result := map[string]any{ + "message": "successfully assigned copilot to issue", + "issue_number": int(updateIssueMutation.UpdateIssue.Issue.Number), + "issue_url": string(updateIssueMutation.UpdateIssue.Issue.URL), + "owner": params.Owner, + "repo": params.Repo, + } + + // Add PR info if found during polling + if linkedPR != nil { + result["pull_request"] = map[string]any{ + "number": linkedPR.Number, + "url": linkedPR.URL, + "title": linkedPR.Title, + "state": linkedPR.State, + } + result["message"] = "successfully assigned copilot to issue - pull request created" + } else { + result["message"] = "successfully assigned copilot to issue - pull request pending" + result["note"] = "The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates." + } + + r, err := json.Marshal(result) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil + } + + return utils.NewToolResultText(string(r)), result, nil + }) +} + +type ReplaceActorsForAssignableInput struct { + AssignableID githubv4.ID `json:"assignableId"` + ActorIDs []githubv4.ID `json:"actorIds"` +} + +// AgentAssignmentInput represents the input for assigning an agent to an issue. +type AgentAssignmentInput struct { + BaseRef *githubv4.String `json:"baseRef,omitempty"` + CustomAgent *githubv4.String `json:"customAgent,omitempty"` + CustomInstructions *githubv4.String `json:"customInstructions,omitempty"` + TargetRepositoryID githubv4.ID `json:"targetRepositoryId"` +} + +// UpdateIssueInput represents the input for updating an issue with agent assignment. +type UpdateIssueInput struct { + ID githubv4.ID `json:"id"` + AssigneeIDs []githubv4.ID `json:"assigneeIds"` + AgentAssignment *AgentAssignmentInput `json:"agentAssignment,omitempty"` +} + +// RequestCopilotReview creates a tool to request a Copilot review for a pull request. +// Note that this tool will not work on GHES where this feature is unsupported. In future, we should not expose this +// tool if the configured host does not support it. +func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "pullNumber": { + Type: "number", + Description: "Pull request number", + }, + }, + Required: []string{"owner", "repo", "pullNumber"}, + } + + return NewTool( + ToolsetMetadataCopilot, + mcp.Tool{ + Name: "request_copilot_review", + Description: t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer."), + Icons: octicons.Icons("copilot"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_REQUEST_COPILOT_REVIEW_USER_TITLE", "Request Copilot review"), + ReadOnlyHint: false, + }, + InputSchema: schema, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + _, resp, err := client.PullRequests.RequestReviewers( + ctx, + owner, + repo, + pullNumber, + github.ReviewersRequest{ + // The login name of the copilot reviewer bot + Reviewers: []string{"copilot-pull-request-reviewer[bot]"}, + }, + ) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to request copilot review", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to request copilot review", resp, bodyBytes), nil, nil + } + + // Return nothing on success, as there's not much value in returning the Pull Request itself + return utils.NewToolResultText(""), nil, nil + }) +} + +func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) inventory.ServerPrompt { + return inventory.NewServerPrompt( + ToolsetMetadataIssues, + mcp.Prompt{ + Name: "AssignCodingAgent", + Description: t("PROMPT_ASSIGN_CODING_AGENT_DESCRIPTION", "Assign GitHub Coding Agent to multiple tasks in a GitHub repository."), + Arguments: []*mcp.PromptArgument{ + { + Name: "repo", + Description: "The repository to assign tasks in (owner/repo).", + Required: true, + }, + }, + }, + func(_ context.Context, request *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + repo := request.Params.Arguments["repo"] + + messages := []*mcp.PromptMessage{ + { + Role: "user", + Content: &mcp.TextContent{ + Text: "You are a personal assistant for GitHub the Copilot GitHub Coding Agent. Your task is to help the user assign tasks to the Coding Agent based on their open GitHub issues. You can use `assign_copilot_to_issue` tool to assign the Coding Agent to issues that are suitable for autonomous work, and `search_issues` tool to find issues that match the user's criteria. You can also use `list_issues` to get a list of issues in the repository.", + }, + }, + { + Role: "user", + Content: &mcp.TextContent{ + Text: fmt.Sprintf("Please go and get a list of the most recent 10 issues from the %s GitHub repository", repo), + }, + }, + { + Role: "assistant", + Content: &mcp.TextContent{ + Text: fmt.Sprintf("Sure! I will get a list of the 10 most recent issues for the repo %s.", repo), + }, + }, + { + Role: "user", + Content: &mcp.TextContent{ + Text: "For each issue, please check if it is a clearly defined coding task with acceptance criteria and a low to medium complexity to identify issues that are suitable for an AI Coding Agent to work on. Then assign each of the identified issues to Copilot.", + }, + }, + { + Role: "assistant", + Content: &mcp.TextContent{ + Text: "Certainly! Let me carefully check which ones are clearly scoped issues that are good to assign to the coding agent, and I will summarize and assign them now.", + }, + }, + { + Role: "user", + Content: &mcp.TextContent{ + Text: "Great, if you are unsure if an issue is good to assign, ask me first, rather than assigning copilot. If you are certain the issue is clear and suitable you can assign it to Copilot without asking.", + }, + }, + } + return &mcp.GetPromptResult{ + Messages: messages, + }, nil + }, + ) +} diff --git a/pkg/github/copilot_test.go b/pkg/github/copilot_test.go new file mode 100644 index 000000000..6b1e7c990 --- /dev/null +++ b/pkg/github/copilot_test.go @@ -0,0 +1,854 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v82/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAssignCopilotToIssue(t *testing.T) { + t.Parallel() + + // Verify tool definition + serverTool := AssignCopilotToIssue(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "assign_copilot_to_issue", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "base_ref") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "custom_instructions") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number"}) + + // Helper function to create pointer to githubv4.String + ptrGitHubv4String := func(s string) *githubv4.String { + v := githubv4.String(s) + return &v + } + + var pageOfFakeBots = func(n int) []struct{} { + // We don't _really_ need real bots here, just objects that count as entries for the page + bots := make([]struct{}, n) + for i := range n { + bots[i] = struct{}{} + } + return bots + } + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful assignment when there are no existing assignees", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, + { + name: "successful assignment when there are existing assignees", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("existing-assignee-id"), + }, + map[string]any{ + "id": githubv4.ID("existing-assignee-id-2"), + }, + }, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{ + githubv4.ID("existing-assignee-id"), + githubv4.ID("existing-assignee-id-2"), + githubv4.ID("copilot-swe-agent-id"), + }, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, + { + name: "copilot bot not on first page of suggested actors", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + // First page of suggested actors + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": pageOfFakeBots(100), + "pageInfo": map[string]any{ + "hasNextPage": true, + "endCursor": githubv4.String("next-page-cursor"), + }, + }, + }, + }), + ), + // Second page of suggested actors + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": githubv4.String("next-page-cursor"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, + { + name: "copilot not a suggested actor", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{}, + }, + }, + }), + ), + ), + expectToolError: true, + expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.", + }, + { + name: "successful assignment with base_ref specified", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "base_ref": "feature-branch", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: ptrGitHubv4String("feature-branch"), + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, + { + name: "successful assignment with custom_instructions specified", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "custom_instructions": "Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String("Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings"), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + + t.Parallel() + // Setup client with mock + client := githubv4.NewClient(tc.mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Disable polling in tests to avoid timeouts + ctx := ContextWithPollConfig(context.Background(), PollConfig{MaxAttempts: 0}) + ctx = ContextWithDeps(ctx, deps) + + // Call handler + result, err := handler(ctx, &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + if tc.expectToolError { + require.True(t, result.IsError) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text)) + + // Verify the JSON response contains expected fields + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err, "response should be valid JSON") + assert.Equal(t, float64(123), response["issue_number"]) + assert.Equal(t, "https://github.com/owner/repo/issues/123", response["issue_url"]) + assert.Equal(t, "owner", response["owner"]) + assert.Equal(t, "repo", response["repo"]) + assert.Contains(t, response["message"], "successfully assigned copilot to issue") + }) + } +} + +func Test_RequestCopilotReview(t *testing.T) { + t.Parallel() + + serverTool := RequestCopilotReview(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "request_copilot_review", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "pullNumber") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber"}) + + // Setup mock PR for success case + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("abcd1234"), + Ref: github.Ptr("feature-branch"), + }, + Base: &github.PullRequestBranch{ + Ref: github.Ptr("main"), + }, + Body: github.Ptr("This is a test PR"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful request", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expect(t, expectations{ + path: "/repos/owner/repo/pulls/1/requested_reviewers", + requestBody: map[string]any{ + "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockPR), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(1), + }, + expectError: false, + }, + { + name: "request fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to request copilot review", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client := github.NewClient(tc.mockedClient) + serverTool := RequestCopilotReview(translations.NullTranslationHelper) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &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) + assert.NotNil(t, result) + assert.Len(t, result.Content, 1) + + textContent := getTextResult(t, result) + require.Equal(t, "", textContent.Text) + }) + } +} diff --git a/pkg/github/issues.go b/pkg/github/issues.go index cd7085550..048303382 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -9,15 +9,12 @@ import ( "strings" "time" - ghcontext "github.com/github/github-mcp-server/pkg/context" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/sanitize" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/go-viper/mapstructure/v2" "github.com/google/go-github/v82/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -1620,438 +1617,6 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }) } -// mvpDescription is an MVP idea for generating tool descriptions from structured data in a shared format. -// It is not intended for widespread usage and is not a complete implementation. -type mvpDescription struct { - summary string - outcomes []string - referenceLinks []string -} - -func (d *mvpDescription) String() string { - var sb strings.Builder - sb.WriteString(d.summary) - if len(d.outcomes) > 0 { - sb.WriteString("\n\n") - sb.WriteString("This tool can help with the following outcomes:\n") - for _, outcome := range d.outcomes { - sb.WriteString(fmt.Sprintf("- %s\n", outcome)) - } - } - - if len(d.referenceLinks) > 0 { - sb.WriteString("\n\n") - sb.WriteString("More information can be found at:\n") - for _, link := range d.referenceLinks { - sb.WriteString(fmt.Sprintf("- %s\n", link)) - } - } - - return sb.String() -} - -// linkedPullRequest represents a PR linked to an issue by Copilot. -type linkedPullRequest struct { - Number int - URL string - Title string - State string - CreatedAt time.Time -} - -// pollConfigKey is a context key for polling configuration. -type pollConfigKey struct{} - -// PollConfig configures the PR polling behavior. -type PollConfig struct { - MaxAttempts int - Delay time.Duration -} - -// ContextWithPollConfig returns a context with polling configuration. -// Use this in tests to reduce or disable polling. -func ContextWithPollConfig(ctx context.Context, config PollConfig) context.Context { - return context.WithValue(ctx, pollConfigKey{}, config) -} - -// getPollConfig returns the polling configuration from context, or defaults. -func getPollConfig(ctx context.Context) PollConfig { - if config, ok := ctx.Value(pollConfigKey{}).(PollConfig); ok { - return config - } - // Default: 9 attempts with 1s delay = 8s max wait - // Based on observed latency in remote server: p50 ~5s, p90 ~7s - return PollConfig{MaxAttempts: 9, Delay: 1 * time.Second} -} - -// findLinkedCopilotPR searches for a PR created by the copilot-swe-agent bot that references the given issue. -// It queries the issue's timeline for CrossReferencedEvent items from PRs authored by copilot-swe-agent. -// The createdAfter parameter filters to only return PRs created after the specified time. -func findLinkedCopilotPR(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int, createdAfter time.Time) (*linkedPullRequest, error) { - // Query timeline items looking for CrossReferencedEvent from PRs by copilot-swe-agent - var query struct { - Repository struct { - Issue struct { - TimelineItems struct { - Nodes []struct { - TypeName string `graphql:"__typename"` - CrossReferencedEvent struct { - Source struct { - PullRequest struct { - Number int - URL string - Title string - State string - CreatedAt githubv4.DateTime - Author struct { - Login string - } - } `graphql:"... on PullRequest"` - } - } `graphql:"... on CrossReferencedEvent"` - } - } `graphql:"timelineItems(first: 20, itemTypes: [CROSS_REFERENCED_EVENT])"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - variables := map[string]any{ - "owner": githubv4.String(owner), - "name": githubv4.String(repo), - "number": githubv4.Int(issueNumber), //nolint:gosec // Issue numbers are always small positive integers - } - - if err := client.Query(ctx, &query, variables); err != nil { - return nil, err - } - - // Look for a PR from copilot-swe-agent created after the assignment time - for _, node := range query.Repository.Issue.TimelineItems.Nodes { - if node.TypeName != "CrossReferencedEvent" { - continue - } - pr := node.CrossReferencedEvent.Source.PullRequest - if pr.Number > 0 && pr.Author.Login == "copilot-swe-agent" { - // Only return PRs created after the assignment time - if pr.CreatedAt.Time.After(createdAfter) { - return &linkedPullRequest{ - Number: pr.Number, - URL: pr.URL, - Title: pr.Title, - State: pr.State, - CreatedAt: pr.CreatedAt.Time, - }, nil - } - } - } - - return nil, nil -} - -func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.ServerTool { - description := mvpDescription{ - summary: "Assign Copilot to a specific issue in a GitHub repository.", - outcomes: []string{ - "a Pull Request created with source code changes to resolve the issue", - }, - referenceLinks: []string{ - "https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot", - }, - } - - return NewTool( - ToolsetMetadataIssues, - mcp.Tool{ - Name: "assign_copilot_to_issue", - Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String()), - Icons: octicons.Icons("copilot"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_USER_TITLE", "Assign Copilot to issue"), - ReadOnlyHint: false, - IdempotentHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "Repository owner", - }, - "repo": { - Type: "string", - Description: "Repository name", - }, - "issue_number": { - Type: "number", - Description: "Issue number", - }, - "base_ref": { - Type: "string", - Description: "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch", - }, - "custom_instructions": { - Type: "string", - Description: "Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description", - }, - }, - Required: []string{"owner", "repo", "issue_number"}, - }, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - var params struct { - Owner string `mapstructure:"owner"` - Repo string `mapstructure:"repo"` - IssueNumber int32 `mapstructure:"issue_number"` - BaseRef string `mapstructure:"base_ref"` - CustomInstructions string `mapstructure:"custom_instructions"` - } - if err := mapstructure.Decode(args, ¶ms); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetGQLClient(ctx) - if err != nil { - return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Firstly, we try to find the copilot bot in the suggested actors for the repository. - // Although as I write this, we would expect copilot to be at the top of the list, in future, maybe - // it will not be on the first page of responses, thus we will keep paginating until we find it. - type botAssignee struct { - ID githubv4.ID - Login string - TypeName string `graphql:"__typename"` - } - - type suggestedActorsQuery struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot botAssignee `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - variables := map[string]any{ - "owner": githubv4.String(params.Owner), - "name": githubv4.String(params.Repo), - "endCursor": (*githubv4.String)(nil), - } - - var copilotAssignee *botAssignee - for { - var query suggestedActorsQuery - err := client.Query(ctx, &query, variables) - if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get suggested actors", err), nil, nil - } - - // Iterate all the returned nodes looking for the copilot bot, which is supposed to have the - // same name on each host. We need this in order to get the ID for later assignment. - for _, node := range query.Repository.SuggestedActors.Nodes { - if node.Bot.Login == "copilot-swe-agent" { - copilotAssignee = &node.Bot - break - } - } - - if !query.Repository.SuggestedActors.PageInfo.HasNextPage { - break - } - variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor) - } - - // If we didn't find the copilot bot, we can't proceed any further. - if copilotAssignee == nil { - // The e2e tests depend upon this specific message to skip the test. - return utils.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil, nil - } - - // Next, get the issue ID and repository ID - var getIssueQuery struct { - Repository struct { - ID githubv4.ID - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - variables = map[string]any{ - "owner": githubv4.String(params.Owner), - "name": githubv4.String(params.Repo), - "number": githubv4.Int(params.IssueNumber), - } - - if err := client.Query(ctx, &getIssueQuery, variables); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue ID", err), nil, nil - } - - // Build the assignee IDs list including copilot - actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1) - for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes { - actorIDs[i] = node.ID - } - actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID - - // Prepare agent assignment input - emptyString := githubv4.String("") - agentAssignment := &AgentAssignmentInput{ - CustomAgent: &emptyString, - CustomInstructions: &emptyString, - TargetRepositoryID: getIssueQuery.Repository.ID, - } - - // Add base ref if provided - if params.BaseRef != "" { - baseRef := githubv4.String(params.BaseRef) - agentAssignment.BaseRef = &baseRef - } - - // Add custom instructions if provided - if params.CustomInstructions != "" { - customInstructions := githubv4.String(params.CustomInstructions) - agentAssignment.CustomInstructions = &customInstructions - } - - // Execute the updateIssue mutation with the GraphQL-Features header - // This header is required for the agent assignment API which is not GA yet - var updateIssueMutation struct { - UpdateIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - } - } `graphql:"updateIssue(input: $input)"` - } - - // Add the GraphQL-Features header for the agent assignment API - // The header will be read by the HTTP transport if it's configured to do so - ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issues_copilot_assignment_api_support") - - // Capture the time before assignment to filter out older PRs during polling - assignmentTime := time.Now().UTC() - - if err := client.Mutate( - ctxWithFeatures, - &updateIssueMutation, - UpdateIssueInput{ - ID: getIssueQuery.Repository.Issue.ID, - AssigneeIDs: actorIDs, - AgentAssignment: agentAssignment, - }, - nil, - ); err != nil { - return nil, nil, fmt.Errorf("failed to update issue with agent assignment: %w", err) - } - - // Poll for a linked PR created by Copilot after the assignment - pollConfig := getPollConfig(ctx) - - // Get progress token from request for sending progress notifications - progressToken := request.Params.GetProgressToken() - - // Send initial progress notification that assignment succeeded and polling is starting - if progressToken != nil && request.Session != nil && pollConfig.MaxAttempts > 0 { - _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ - ProgressToken: progressToken, - Progress: 0, - Total: float64(pollConfig.MaxAttempts), - Message: "Copilot assigned to issue, waiting for PR creation...", - }) - } - - var linkedPR *linkedPullRequest - for attempt := range pollConfig.MaxAttempts { - if attempt > 0 { - time.Sleep(pollConfig.Delay) - } - - // Send progress notification if progress token is available - if progressToken != nil && request.Session != nil { - _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ - ProgressToken: progressToken, - Progress: float64(attempt + 1), - Total: float64(pollConfig.MaxAttempts), - Message: fmt.Sprintf("Waiting for Copilot to create PR... (attempt %d/%d)", attempt+1, pollConfig.MaxAttempts), - }) - } - - pr, err := findLinkedCopilotPR(ctx, client, params.Owner, params.Repo, int(params.IssueNumber), assignmentTime) - if err != nil { - // Polling errors are non-fatal, continue to next attempt - continue - } - if pr != nil { - linkedPR = pr - break - } - } - - // Build the result - result := map[string]any{ - "message": "successfully assigned copilot to issue", - "issue_number": int(updateIssueMutation.UpdateIssue.Issue.Number), - "issue_url": string(updateIssueMutation.UpdateIssue.Issue.URL), - "owner": params.Owner, - "repo": params.Repo, - } - - // Add PR info if found during polling - if linkedPR != nil { - result["pull_request"] = map[string]any{ - "number": linkedPR.Number, - "url": linkedPR.URL, - "title": linkedPR.Title, - "state": linkedPR.State, - } - result["message"] = "successfully assigned copilot to issue - pull request created" - } else { - result["message"] = "successfully assigned copilot to issue - pull request pending" - result["note"] = "The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates." - } - - r, err := json.Marshal(result) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil - } - - return utils.NewToolResultText(string(r)), result, nil - }) -} - -type ReplaceActorsForAssignableInput struct { - AssignableID githubv4.ID `json:"assignableId"` - ActorIDs []githubv4.ID `json:"actorIds"` -} - -// AgentAssignmentInput represents the input for assigning an agent to an issue. -type AgentAssignmentInput struct { - BaseRef *githubv4.String `json:"baseRef,omitempty"` - CustomAgent *githubv4.String `json:"customAgent,omitempty"` - CustomInstructions *githubv4.String `json:"customInstructions,omitempty"` - TargetRepositoryID githubv4.ID `json:"targetRepositoryId"` -} - -// UpdateIssueInput represents the input for updating an issue with agent assignment. -type UpdateIssueInput struct { - ID githubv4.ID `json:"id"` - AssigneeIDs []githubv4.ID `json:"assigneeIds"` - AgentAssignment *AgentAssignmentInput `json:"agentAssignment,omitempty"` -} - // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. // Returns the parsed time or an error if parsing fails. // Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" @@ -2075,65 +1640,3 @@ func parseISOTimestamp(timestamp string) (time.Time, error) { // Return error with supported formats return time.Time{}, fmt.Errorf("invalid ISO 8601 timestamp: %s (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)", timestamp) } - -func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) inventory.ServerPrompt { - return inventory.NewServerPrompt( - ToolsetMetadataIssues, - mcp.Prompt{ - Name: "AssignCodingAgent", - Description: t("PROMPT_ASSIGN_CODING_AGENT_DESCRIPTION", "Assign GitHub Coding Agent to multiple tasks in a GitHub repository."), - Arguments: []*mcp.PromptArgument{ - { - Name: "repo", - Description: "The repository to assign tasks in (owner/repo).", - Required: true, - }, - }, - }, - func(_ context.Context, request *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - repo := request.Params.Arguments["repo"] - - messages := []*mcp.PromptMessage{ - { - Role: "user", - Content: &mcp.TextContent{ - Text: "You are a personal assistant for GitHub the Copilot GitHub Coding Agent. Your task is to help the user assign tasks to the Coding Agent based on their open GitHub issues. You can use `assign_copilot_to_issue` tool to assign the Coding Agent to issues that are suitable for autonomous work, and `search_issues` tool to find issues that match the user's criteria. You can also use `list_issues` to get a list of issues in the repository.", - }, - }, - { - Role: "user", - Content: &mcp.TextContent{ - Text: fmt.Sprintf("Please go and get a list of the most recent 10 issues from the %s GitHub repository", repo), - }, - }, - { - Role: "assistant", - Content: &mcp.TextContent{ - Text: fmt.Sprintf("Sure! I will get a list of the 10 most recent issues for the repo %s.", repo), - }, - }, - { - Role: "user", - Content: &mcp.TextContent{ - Text: "For each issue, please check if it is a clearly defined coding task with acceptance criteria and a low to medium complexity to identify issues that are suitable for an AI Coding Agent to work on. Then assign each of the identified issues to Copilot.", - }, - }, - { - Role: "assistant", - Content: &mcp.TextContent{ - Text: "Certainly! Let me carefully check which ones are clearly scoped issues that are good to assign to the coding agent, and I will summarize and assign them now.", - }, - }, - { - Role: "user", - Content: &mcp.TextContent{ - Text: "Great, if you are unsure if an issue is good to assign, ask me first, rather than assigning copilot. If you are certain the issue is clear and suitable you can assign it to Copilot without asking.", - }, - }, - } - return &mcp.GetPromptResult{ - Messages: messages, - }, nil - }, - ) -} diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 512ba8a6b..90fd2a3da 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -2142,730 +2142,6 @@ func Test_GetIssueLabels(t *testing.T) { } } -func TestAssignCopilotToIssue(t *testing.T) { - t.Parallel() - - // Verify tool definition - serverTool := AssignCopilotToIssue(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "assign_copilot_to_issue", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "base_ref") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "custom_instructions") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number"}) - - // Helper function to create pointer to githubv4.String - ptrGitHubv4String := func(s string) *githubv4.String { - v := githubv4.String(s) - return &v - } - - var pageOfFakeBots = func(n int) []struct{} { - // We don't _really_ need real bots here, just objects that count as entries for the page - bots := make([]struct{}, n) - for i := range n { - bots[i] = struct{}{} - } - return bots - } - - tests := []struct { - name string - requestArgs map[string]any - mockedClient *http.Client - expectToolError bool - expectedToolErrMsg string - }{ - { - name: "successful assignment when there are no existing assignees", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("copilot-swe-agent-id"), - "login": githubv4.String("copilot-swe-agent"), - "__typename": "Bot", - }, - }, - }, - }, - }), - ), - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - ID githubv4.ID - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "number": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "id": githubv4.ID("test-repo-id"), - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{}, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - UpdateIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - } - } `graphql:"updateIssue(input: $input)"` - }{}, - UpdateIssueInput{ - ID: githubv4.ID("test-issue-id"), - AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, - AgentAssignment: &AgentAssignmentInput{ - BaseRef: nil, - CustomAgent: ptrGitHubv4String(""), - CustomInstructions: ptrGitHubv4String(""), - TargetRepositoryID: githubv4.ID("test-repo-id"), - }, - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "updateIssue": map[string]any{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "number": githubv4.Int(123), - "url": githubv4.String("https://github.com/owner/repo/issues/123"), - }, - }, - }), - ), - ), - }, - { - name: "successful assignment when there are existing assignees", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("copilot-swe-agent-id"), - "login": githubv4.String("copilot-swe-agent"), - "__typename": "Bot", - }, - }, - }, - }, - }), - ), - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - ID githubv4.ID - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "number": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "id": githubv4.ID("test-repo-id"), - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("existing-assignee-id"), - }, - map[string]any{ - "id": githubv4.ID("existing-assignee-id-2"), - }, - }, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - UpdateIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - } - } `graphql:"updateIssue(input: $input)"` - }{}, - UpdateIssueInput{ - ID: githubv4.ID("test-issue-id"), - AssigneeIDs: []githubv4.ID{ - githubv4.ID("existing-assignee-id"), - githubv4.ID("existing-assignee-id-2"), - githubv4.ID("copilot-swe-agent-id"), - }, - AgentAssignment: &AgentAssignmentInput{ - BaseRef: nil, - CustomAgent: ptrGitHubv4String(""), - CustomInstructions: ptrGitHubv4String(""), - TargetRepositoryID: githubv4.ID("test-repo-id"), - }, - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "updateIssue": map[string]any{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "number": githubv4.Int(123), - "url": githubv4.String("https://github.com/owner/repo/issues/123"), - }, - }, - }), - ), - ), - }, - { - name: "copilot bot not on first page of suggested actors", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - // First page of suggested actors - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": pageOfFakeBots(100), - "pageInfo": map[string]any{ - "hasNextPage": true, - "endCursor": githubv4.String("next-page-cursor"), - }, - }, - }, - }), - ), - // Second page of suggested actors - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": githubv4.String("next-page-cursor"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("copilot-swe-agent-id"), - "login": githubv4.String("copilot-swe-agent"), - "__typename": "Bot", - }, - }, - }, - }, - }), - ), - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - ID githubv4.ID - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "number": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "id": githubv4.ID("test-repo-id"), - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{}, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - UpdateIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - } - } `graphql:"updateIssue(input: $input)"` - }{}, - UpdateIssueInput{ - ID: githubv4.ID("test-issue-id"), - AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, - AgentAssignment: &AgentAssignmentInput{ - BaseRef: nil, - CustomAgent: ptrGitHubv4String(""), - CustomInstructions: ptrGitHubv4String(""), - TargetRepositoryID: githubv4.ID("test-repo-id"), - }, - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "updateIssue": map[string]any{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "number": githubv4.Int(123), - "url": githubv4.String("https://github.com/owner/repo/issues/123"), - }, - }, - }), - ), - ), - }, - { - name: "copilot not a suggested actor", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{}, - }, - }, - }), - ), - ), - expectToolError: true, - expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.", - }, - { - name: "successful assignment with base_ref specified", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - "base_ref": "feature-branch", - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("copilot-swe-agent-id"), - "login": githubv4.String("copilot-swe-agent"), - "__typename": "Bot", - }, - }, - }, - }, - }), - ), - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - ID githubv4.ID - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "number": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "id": githubv4.ID("test-repo-id"), - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{}, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - UpdateIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - } - } `graphql:"updateIssue(input: $input)"` - }{}, - UpdateIssueInput{ - ID: githubv4.ID("test-issue-id"), - AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, - AgentAssignment: &AgentAssignmentInput{ - BaseRef: ptrGitHubv4String("feature-branch"), - CustomAgent: ptrGitHubv4String(""), - CustomInstructions: ptrGitHubv4String(""), - TargetRepositoryID: githubv4.ID("test-repo-id"), - }, - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "updateIssue": map[string]any{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "number": githubv4.Int(123), - "url": githubv4.String("https://github.com/owner/repo/issues/123"), - }, - }, - }), - ), - ), - }, - { - name: "successful assignment with custom_instructions specified", - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(123), - "custom_instructions": "Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings", - }, - mockedClient: githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - SuggestedActors struct { - Nodes []struct { - Bot struct { - ID githubv4.ID - Login githubv4.String - TypeName string `graphql:"__typename"` - } `graphql:"... on Bot"` - } - PageInfo struct { - HasNextPage bool - EndCursor string - } - } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "endCursor": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "suggestedActors": map[string]any{ - "nodes": []any{ - map[string]any{ - "id": githubv4.ID("copilot-swe-agent-id"), - "login": githubv4.String("copilot-swe-agent"), - "__typename": "Bot", - }, - }, - }, - }, - }), - ), - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - ID githubv4.ID - Issue struct { - ID githubv4.ID - Assignees struct { - Nodes []struct { - ID githubv4.ID - } - } `graphql:"assignees(first: 100)"` - } `graphql:"issue(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "name": githubv4.String("repo"), - "number": githubv4.Int(123), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "id": githubv4.ID("test-repo-id"), - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "assignees": map[string]any{ - "nodes": []any{}, - }, - }, - }, - }), - ), - githubv4mock.NewMutationMatcher( - struct { - UpdateIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - } - } `graphql:"updateIssue(input: $input)"` - }{}, - UpdateIssueInput{ - ID: githubv4.ID("test-issue-id"), - AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, - AgentAssignment: &AgentAssignmentInput{ - BaseRef: nil, - CustomAgent: ptrGitHubv4String(""), - CustomInstructions: ptrGitHubv4String("Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings"), - TargetRepositoryID: githubv4.ID("test-repo-id"), - }, - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "updateIssue": map[string]any{ - "issue": map[string]any{ - "id": githubv4.ID("test-issue-id"), - "number": githubv4.Int(123), - "url": githubv4.String("https://github.com/owner/repo/issues/123"), - }, - }, - }), - ), - ), - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - - t.Parallel() - // Setup client with mock - client := githubv4.NewClient(tc.mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Disable polling in tests to avoid timeouts - ctx := ContextWithPollConfig(context.Background(), PollConfig{MaxAttempts: 0}) - ctx = ContextWithDeps(ctx, deps) - - // Call handler - result, err := handler(ctx, &request) - require.NoError(t, err) - - textContent := getTextResult(t, result) - - if tc.expectToolError { - require.True(t, result.IsError) - assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) - return - } - - require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text)) - - // Verify the JSON response contains expected fields - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err, "response should be valid JSON") - assert.Equal(t, float64(123), response["issue_number"]) - assert.Equal(t, "https://github.com/owner/repo/issues/123", response["issue_url"]) - assert.Equal(t, "owner", response["owner"]) - assert.Equal(t, "repo", response["repo"]) - assert.Contains(t, response["message"], "successfully assigned copilot to issue") - }) - } -} - func Test_AddSubIssue(t *testing.T) { // Verify tool definition once serverTool := SubIssueWrite(translations.NullTranslationHelper) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 58edc07dc..9175d3552 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1953,95 +1953,6 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S }) } -// RequestCopilotReview creates a tool to request a Copilot review for a pull request. -// Note that this tool will not work on GHES where this feature is unsupported. In future, we should not expose this -// tool if the configured host does not support it. -func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.ServerTool { - schema := &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "Repository owner", - }, - "repo": { - Type: "string", - Description: "Repository name", - }, - "pullNumber": { - Type: "number", - Description: "Pull request number", - }, - }, - Required: []string{"owner", "repo", "pullNumber"}, - } - - return NewTool( - ToolsetMetadataPullRequests, - mcp.Tool{ - Name: "request_copilot_review", - Description: t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer."), - Icons: octicons.Icons("copilot"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_REQUEST_COPILOT_REVIEW_USER_TITLE", "Request Copilot review"), - ReadOnlyHint: false, - }, - InputSchema: schema, - }, - []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - pullNumber, err := RequiredInt(args, "pullNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil - } - - _, resp, err := client.PullRequests.RequestReviewers( - ctx, - owner, - repo, - pullNumber, - github.ReviewersRequest{ - // The login name of the copilot reviewer bot - Reviewers: []string{"copilot-pull-request-reviewer[bot]"}, - }, - ) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to request copilot review", - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusCreated { - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to request copilot review", resp, bodyBytes), nil, nil - } - - // Return nothing on success, as there's not much value in returning the Pull Request itself - return utils.NewToolResultText(""), nil, nil - }) -} - // newGQLString like takes something that approximates a string (of which there are many types in shurcooL/githubv4) // and constructs a pointer to it, or nil if the string is empty. This is extremely useful because when we parse // params from the MCP request, we need to convert them to types that are pointers of type def strings and it's diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 570b1906f..f4adbc75d 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2452,118 +2452,6 @@ func TestCreateAndSubmitPullRequestReview(t *testing.T) { } } -func Test_RequestCopilotReview(t *testing.T) { - t.Parallel() - - serverTool := RequestCopilotReview(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "request_copilot_review", tool.Name) - assert.NotEmpty(t, tool.Description) - schema := tool.InputSchema.(*jsonschema.Schema) - assert.Contains(t, schema.Properties, "owner") - assert.Contains(t, schema.Properties, "repo") - assert.Contains(t, schema.Properties, "pullNumber") - assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber"}) - - // Setup mock PR for success case - mockPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), - }, - Base: &github.PullRequestBranch{ - Ref: github.Ptr("main"), - }, - Body: github.Ptr("This is a test PR"), - User: &github.User{ - Login: github.Ptr("testuser"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedErrMsg string - }{ - { - name: "successful request", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expect(t, expectations{ - path: "/repos/owner/repo/pulls/1/requested_reviewers", - requestBody: map[string]any{ - "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, - }, - }).andThen( - mockResponse(t, http.StatusCreated, mockPR), - ), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(1), - }, - expectError: false, - }, - { - name: "request fails", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - }), - requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "pullNumber": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to request copilot review", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - client := github.NewClient(tc.mockedClient) - serverTool := RequestCopilotReview(translations.NullTranslationHelper) - deps := BaseDeps{ - Client: client, - } - handler := serverTool.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - - result, err := handler(ContextWithDeps(context.Background(), deps), &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) - assert.NotNil(t, result) - assert.Len(t, result.Content, 1) - - textContent := getTextResult(t, result) - require.Equal(t, "", textContent.Text) - }) - } -} - func TestCreatePendingPullRequestReview(t *testing.T) { t.Parallel() diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 0164b48e5..3f1c291a7 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -134,13 +134,15 @@ var ( Icon: "tag", } - // Remote-only toolsets - these are only available in the remote MCP server - // but are documented here for consistency and to enable automated documentation. ToolsetMetadataCopilot = inventory.ToolsetMetadata{ ID: "copilot", Description: "Copilot related tools", + Default: true, Icon: "copilot", } + + // Remote-only toolsets - these are only available in the remote MCP server + // but are documented here for consistency and to enable automated documentation. ToolsetMetadataCopilotSpaces = inventory.ToolsetMetadata{ ID: "copilot_spaces", Description: "Copilot Spaces tools", @@ -194,7 +196,6 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListIssueTypes(t), IssueWrite(t), AddIssueComment(t), - AssignCopilotToIssue(t), SubIssueWrite(t), // User tools @@ -211,11 +212,14 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { UpdatePullRequestBranch(t), CreatePullRequest(t), UpdatePullRequest(t), - RequestCopilotReview(t), PullRequestReviewWrite(t), AddCommentToPendingReview(t), AddReplyToPullRequestComment(t), + // Copilot tools + AssignCopilotToIssue(t), + RequestCopilotReview(t), + // Code security tools GetCodeScanningAlert(t), ListCodeScanningAlerts(t), @@ -436,7 +440,6 @@ func GetDefaultToolsetIDs() []string { // in the local server. func RemoteOnlyToolsets() []inventory.ToolsetMetadata { return []inventory.ToolsetMetadata{ - ToolsetMetadataCopilot, ToolsetMetadataCopilotSpaces, ToolsetMetadataSupportSearch, } diff --git a/pkg/github/tools_test.go b/pkg/github/tools_test.go index 80270d2bc..2bcd2d525 100644 --- a/pkg/github/tools_test.go +++ b/pkg/github/tools_test.go @@ -23,6 +23,7 @@ func TestAddDefaultToolset(t *testing.T) { input: []string{"default"}, expected: []string{ "context", + "copilot", "repos", "issues", "pull_requests", @@ -36,6 +37,7 @@ func TestAddDefaultToolset(t *testing.T) { "actions", "gists", "context", + "copilot", "repos", "issues", "pull_requests", @@ -47,6 +49,7 @@ func TestAddDefaultToolset(t *testing.T) { input: []string{"default", "context", "repos"}, expected: []string{ "context", + "copilot", "repos", "issues", "pull_requests", From 713848b0eb0ded1fb1a801d827f3bd36123f641a Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:49:05 +0000 Subject: [PATCH 16/57] add minimal types for get_files (#2059) --- pkg/github/minimal_types.go | 28 ++++++++++++++++++++++++++++ pkg/github/pullrequests.go | 7 ++----- pkg/github/pullrequests_test.go | 10 +++++----- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index f8c82d78e..731ff6bdf 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -79,6 +79,18 @@ type MinimalCommitFile struct { Changes int `json:"changes,omitempty"` } +// MinimalPRFile represents a file changed in a pull request. +// Compared to MinimalCommitFile, it includes the patch diff and previous filename for renames. +type MinimalPRFile struct { + Filename string `json:"filename"` + Status string `json:"status,omitempty"` + Additions int `json:"additions,omitempty"` + Deletions int `json:"deletions,omitempty"` + Changes int `json:"changes,omitempty"` + Patch string `json:"patch,omitempty"` + PreviousFilename string `json:"previous_filename,omitempty"` +} + // MinimalCommit is the trimmed output type for commit objects. type MinimalCommit struct { SHA string `json:"sha"` @@ -600,6 +612,22 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) return minimalCommit } +func convertToMinimalPRFiles(files []*github.CommitFile) []MinimalPRFile { + result := make([]MinimalPRFile, 0, len(files)) + for _, f := range files { + result = append(result, MinimalPRFile{ + Filename: f.GetFilename(), + Status: f.GetStatus(), + Additions: f.GetAdditions(), + Deletions: f.GetDeletions(), + Changes: f.GetChanges(), + Patch: f.GetPatch(), + PreviousFilename: f.GetPreviousFilename(), + }) + } + return result +} + // convertToMinimalBranch converts a GitHub API Branch to MinimalBranch func convertToMinimalBranch(branch *github.Branch) MinimalBranch { return MinimalBranch{ diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 9175d3552..2fba113aa 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -290,12 +290,9 @@ func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request files", resp, body), nil } - r, err := json.Marshal(files) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + minimalFiles := convertToMinimalPRFiles(files) - return utils.NewToolResultText(string(r)), nil + return MarshalledTextResult(minimalFiles), nil } // GraphQL types for review threads query diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index f4adbc75d..018636d40 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1229,15 +1229,15 @@ func Test_GetPullRequestFiles(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedFiles []*github.CommitFile + var returnedFiles []MinimalPRFile err = json.Unmarshal([]byte(textContent.Text), &returnedFiles) require.NoError(t, err) assert.Len(t, returnedFiles, len(tc.expectedFiles)) for i, file := range returnedFiles { - assert.Equal(t, *tc.expectedFiles[i].Filename, *file.Filename) - assert.Equal(t, *tc.expectedFiles[i].Status, *file.Status) - assert.Equal(t, *tc.expectedFiles[i].Additions, *file.Additions) - assert.Equal(t, *tc.expectedFiles[i].Deletions, *file.Deletions) + assert.Equal(t, tc.expectedFiles[i].GetFilename(), file.Filename) + assert.Equal(t, tc.expectedFiles[i].GetStatus(), file.Status) + assert.Equal(t, tc.expectedFiles[i].GetAdditions(), file.Additions) + assert.Equal(t, tc.expectedFiles[i].GetDeletions(), file.Deletions) } }) } From ee3ab7b23e54fba07b7499ae49819a8c8e512afd Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:32:44 +0000 Subject: [PATCH 17/57] reduce context usage for get_pull_request_review_comments (#2062) --- pkg/github/minimal_types.go | 93 +++++++++++++++++++++++++++++++++ pkg/github/pullrequests.go | 19 +------ pkg/github/pullrequests_test.go | 63 +++++++++------------- 3 files changed, 118 insertions(+), 57 deletions(-) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 731ff6bdf..23cf1e555 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -612,6 +612,41 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) return minimalCommit } +// MinimalPageInfo contains pagination cursor information. +type MinimalPageInfo struct { + HasNextPage bool `json:"has_next_page"` + HasPreviousPage bool `json:"has_previous_page"` + StartCursor string `json:"start_cursor,omitempty"` + EndCursor string `json:"end_cursor,omitempty"` +} + +// MinimalReviewComment is the trimmed output type for PR review comment objects. +type MinimalReviewComment struct { + Body string `json:"body,omitempty"` + Path string `json:"path"` + Line *int `json:"line,omitempty"` + Author string `json:"author,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + HTMLURL string `json:"html_url"` +} + +// MinimalReviewThread is the trimmed output type for PR review thread objects. +type MinimalReviewThread struct { + IsResolved bool `json:"is_resolved"` + IsOutdated bool `json:"is_outdated"` + IsCollapsed bool `json:"is_collapsed"` + Comments []MinimalReviewComment `json:"comments"` + TotalCount int `json:"total_count"` +} + +// MinimalReviewThreadsResponse is the trimmed output for a paginated list of PR review threads. +type MinimalReviewThreadsResponse struct { + ReviewThreads []MinimalReviewThread `json:"review_threads"` + TotalCount int `json:"total_count"` + PageInfo MinimalPageInfo `json:"page_info"` +} + func convertToMinimalPRFiles(files []*github.CommitFile) []MinimalPRFile { result := make([]MinimalPRFile, 0, len(files)) for _, f := range files { @@ -636,3 +671,61 @@ func convertToMinimalBranch(branch *github.Branch) MinimalBranch { Protected: branch.GetProtected(), } } + +func convertToMinimalReviewThreadsResponse(query reviewThreadsQuery) MinimalReviewThreadsResponse { + threads := query.Repository.PullRequest.ReviewThreads + + minimalThreads := make([]MinimalReviewThread, 0, len(threads.Nodes)) + for _, thread := range threads.Nodes { + minimalThreads = append(minimalThreads, convertToMinimalReviewThread(thread)) + } + + return MinimalReviewThreadsResponse{ + ReviewThreads: minimalThreads, + TotalCount: int(threads.TotalCount), + PageInfo: MinimalPageInfo{ + HasNextPage: bool(threads.PageInfo.HasNextPage), + HasPreviousPage: bool(threads.PageInfo.HasPreviousPage), + StartCursor: string(threads.PageInfo.StartCursor), + EndCursor: string(threads.PageInfo.EndCursor), + }, + } +} + +func convertToMinimalReviewThread(thread reviewThreadNode) MinimalReviewThread { + comments := make([]MinimalReviewComment, 0, len(thread.Comments.Nodes)) + for _, c := range thread.Comments.Nodes { + comments = append(comments, convertToMinimalReviewComment(c)) + } + + return MinimalReviewThread{ + IsResolved: bool(thread.IsResolved), + IsOutdated: bool(thread.IsOutdated), + IsCollapsed: bool(thread.IsCollapsed), + Comments: comments, + TotalCount: int(thread.Comments.TotalCount), + } +} + +func convertToMinimalReviewComment(c reviewCommentNode) MinimalReviewComment { + m := MinimalReviewComment{ + Body: string(c.Body), + Path: string(c.Path), + Author: string(c.Author.Login), + HTMLURL: c.URL.String(), + } + + if c.Line != nil { + line := int(*c.Line) + m.Line = &line + } + + if !c.CreatedAt.IsZero() { + m.CreatedAt = c.CreatedAt.Format(time.RFC3339) + } + if !c.UpdatedAt.IsZero() { + m.UpdatedAt = c.UpdatedAt.Format(time.RFC3339) + } + + return m +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 2fba113aa..f4c49283d 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -406,24 +406,7 @@ func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Clien } } - // Build response with review threads and pagination info - response := map[string]any{ - "reviewThreads": query.Repository.PullRequest.ReviewThreads.Nodes, - "pageInfo": map[string]any{ - "hasNextPage": query.Repository.PullRequest.ReviewThreads.PageInfo.HasNextPage, - "hasPreviousPage": query.Repository.PullRequest.ReviewThreads.PageInfo.HasPreviousPage, - "startCursor": string(query.Repository.PullRequest.ReviewThreads.PageInfo.StartCursor), - "endCursor": string(query.Repository.PullRequest.ReviewThreads.PageInfo.EndCursor), - }, - "totalCount": int(query.Repository.PullRequest.ReviewThreads.TotalCount), - } - - r, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil + return MarshalledTextResult(convertToMinimalReviewThreadsResponse(query)), nil } func GetPullRequestReviews(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 018636d40..7490f2254 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1619,45 +1619,35 @@ func Test_GetPullRequestComments(t *testing.T) { }, expectError: false, validateResult: func(t *testing.T, textContent string) { - var result map[string]any + var result MinimalReviewThreadsResponse err := json.Unmarshal([]byte(textContent), &result) require.NoError(t, err) - // Validate response structure - assert.Contains(t, result, "reviewThreads") - assert.Contains(t, result, "pageInfo") - assert.Contains(t, result, "totalCount") - // Validate review threads - threads := result["reviewThreads"].([]any) - assert.Len(t, threads, 1) + assert.Len(t, result.ReviewThreads, 1) - thread := threads[0].(map[string]any) - assert.Equal(t, "RT_kwDOA0xdyM4AX1Yz", thread["ID"]) - assert.Equal(t, false, thread["IsResolved"]) - assert.Equal(t, false, thread["IsOutdated"]) - assert.Equal(t, false, thread["IsCollapsed"]) + thread := result.ReviewThreads[0] + assert.Equal(t, false, thread.IsResolved) + assert.Equal(t, false, thread.IsOutdated) + assert.Equal(t, false, thread.IsCollapsed) // Validate comments within thread - comments := thread["Comments"].(map[string]any) - commentNodes := comments["Nodes"].([]any) - assert.Len(t, commentNodes, 2) + assert.Len(t, thread.Comments, 2) // Validate first comment - comment1 := commentNodes[0].(map[string]any) - assert.Equal(t, "PRRC_kwDOA0xdyM4AX1Y0", comment1["ID"]) - assert.Equal(t, "This looks good", comment1["Body"]) - assert.Equal(t, "file1.go", comment1["Path"]) + comment1 := thread.Comments[0] + assert.Equal(t, "This looks good", comment1.Body) + assert.Equal(t, "file1.go", comment1.Path) + assert.Equal(t, "reviewer1", comment1.Author) // Validate pagination info - pageInfo := result["pageInfo"].(map[string]any) - assert.Equal(t, false, pageInfo["hasNextPage"]) - assert.Equal(t, false, pageInfo["hasPreviousPage"]) - assert.Equal(t, "cursor1", pageInfo["startCursor"]) - assert.Equal(t, "cursor2", pageInfo["endCursor"]) + assert.Equal(t, false, result.PageInfo.HasNextPage) + assert.Equal(t, false, result.PageInfo.HasPreviousPage) + assert.Equal(t, "cursor1", result.PageInfo.StartCursor) + assert.Equal(t, "cursor2", result.PageInfo.EndCursor) // Validate total count - assert.Equal(t, float64(1), result["totalCount"]) + assert.Equal(t, 1, result.TotalCount) }, }, { @@ -1761,27 +1751,22 @@ func Test_GetPullRequestComments(t *testing.T) { expectError: false, lockdownEnabled: true, validateResult: func(t *testing.T, textContent string) { - var result map[string]any + var result MinimalReviewThreadsResponse err := json.Unmarshal([]byte(textContent), &result) require.NoError(t, err) // Validate that only maintainer comment is returned - threads := result["reviewThreads"].([]any) - assert.Len(t, threads, 1) + assert.Len(t, result.ReviewThreads, 1) - thread := threads[0].(map[string]any) - comments := thread["Comments"].(map[string]any) + thread := result.ReviewThreads[0] // Should only have 1 comment (maintainer) after filtering - assert.Equal(t, float64(1), comments["TotalCount"]) - - commentNodes := comments["Nodes"].([]any) - assert.Len(t, commentNodes, 1) + assert.Equal(t, 1, thread.TotalCount) + assert.Len(t, thread.Comments, 1) - comment := commentNodes[0].(map[string]any) - author := comment["Author"].(map[string]any) - assert.Equal(t, "maintainer", author["Login"]) - assert.Equal(t, "Maintainer review comment", comment["Body"]) + comment := thread.Comments[0] + assert.Equal(t, "maintainer", comment.Author) + assert.Equal(t, "Maintainer review comment", comment.Body) }, }, } From cdf84bcf6c1a5239fdd38faf48dfc4037949ad97 Mon Sep 17 00:00:00 2001 From: "C. Ross" Date: Mon, 23 Feb 2026 11:46:43 -0500 Subject: [PATCH 18/57] Make Example MCP Name consistent (#2069) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ee7b51e95..c02261970 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ Example for `https://octocorp.ghe.com` with GitHub PAT token: ``` { ... - "proxima-github": { + "github-octocorp": { "type": "http", "url": "https://copilot-api.octocorp.ghe.com/mcp", "headers": { From 48a2a05651780982f82e12a5c2915a8769bb3202 Mon Sep 17 00:00:00 2001 From: Atharva Patil <53966412+atharva1051@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:22:19 +0530 Subject: [PATCH 19/57] Use configured --gh-host as oauth authorization server (#2046) When configured with a `--gh-host` argument, construct the OAuth Authorization Server URL from this host, rather than defaulting to `https://github.com/login/oauth` Co-authored-by: Adam Holt < 4619+omgitsads@users.noreply.github.com> Co-authored-by: atharva1051 <53966412+atharva1051@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- pkg/http/oauth/oauth.go | 36 +++++--- pkg/http/oauth/oauth_test.go | 162 ++++++++++++++++++++++++++++++----- pkg/http/server.go | 2 +- pkg/scopes/fetcher_test.go | 3 + pkg/utils/api.go | 57 ++++++++---- 5 files changed, 213 insertions(+), 47 deletions(-) diff --git a/pkg/http/oauth/oauth.go b/pkg/http/oauth/oauth.go index 5da253566..3b4d41959 100644 --- a/pkg/http/oauth/oauth.go +++ b/pkg/http/oauth/oauth.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/pkg/utils" "github.com/go-chi/chi/v5" "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/oauthex" @@ -16,9 +17,6 @@ import ( const ( // OAuthProtectedResourcePrefix is the well-known path prefix for OAuth protected resource metadata. OAuthProtectedResourcePrefix = "/.well-known/oauth-protected-resource" - - // DefaultAuthorizationServer is GitHub's OAuth authorization server. - DefaultAuthorizationServer = "https://github.com/login/oauth" ) // SupportedScopes lists all OAuth scopes that may be required by MCP tools. @@ -55,22 +53,27 @@ type Config struct { // AuthHandler handles OAuth-related HTTP endpoints. type AuthHandler struct { - cfg *Config + cfg *Config + apiHost utils.APIHostResolver } // NewAuthHandler creates a new OAuth auth handler. -func NewAuthHandler(cfg *Config) (*AuthHandler, error) { +func NewAuthHandler(cfg *Config, apiHost utils.APIHostResolver) (*AuthHandler, error) { if cfg == nil { cfg = &Config{} } - // Default authorization server to GitHub - if cfg.AuthorizationServer == "" { - cfg.AuthorizationServer = DefaultAuthorizationServer + if apiHost == nil { + var err error + apiHost, err = utils.NewAPIHost("https://api.github.com") + if err != nil { + return nil, fmt.Errorf("failed to create default API host: %w", err) + } } return &AuthHandler{ - cfg: cfg, + cfg: cfg, + apiHost: apiHost, }, nil } @@ -95,15 +98,28 @@ func (h *AuthHandler) RegisterRoutes(r chi.Router) { func (h *AuthHandler) metadataHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() resourcePath := resolveResourcePath( strings.TrimPrefix(r.URL.Path, OAuthProtectedResourcePrefix), h.cfg.ResourcePath, ) resourceURL := h.buildResourceURL(r, resourcePath) + var authorizationServerURL string + if h.cfg.AuthorizationServer != "" { + authorizationServerURL = h.cfg.AuthorizationServer + } else { + authURL, err := h.apiHost.AuthorizationServerURL(ctx) + if err != nil { + http.Error(w, fmt.Sprintf("failed to resolve authorization server URL: %v", err), http.StatusInternalServerError) + return + } + authorizationServerURL = authURL.String() + } + metadata := &oauthex.ProtectedResourceMetadata{ Resource: resourceURL, - AuthorizationServers: []string{h.cfg.AuthorizationServer}, + AuthorizationServers: []string{authorizationServerURL}, ResourceName: "GitHub MCP Server", ScopesSupported: SupportedScopes, BearerMethodsSupported: []string{"header"}, diff --git a/pkg/http/oauth/oauth_test.go b/pkg/http/oauth/oauth_test.go index 9133e8331..6d76b579f 100644 --- a/pkg/http/oauth/oauth_test.go +++ b/pkg/http/oauth/oauth_test.go @@ -8,32 +8,28 @@ import ( "testing" "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/pkg/utils" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +var ( + defaultAuthorizationServer = "https://github.com/login/oauth" +) + func TestNewAuthHandler(t *testing.T) { t.Parallel() + dotcomHost, err := utils.NewAPIHost("https://api.github.com") + require.NoError(t, err) + tests := []struct { name string cfg *Config expectedAuthServer string expectedResourcePath string }{ - { - name: "nil config uses defaults", - cfg: nil, - expectedAuthServer: DefaultAuthorizationServer, - expectedResourcePath: "", - }, - { - name: "empty config uses defaults", - cfg: &Config{}, - expectedAuthServer: DefaultAuthorizationServer, - expectedResourcePath: "", - }, { name: "custom authorization server", cfg: &Config{ @@ -48,7 +44,7 @@ func TestNewAuthHandler(t *testing.T) { BaseURL: "https://example.com", ResourcePath: "/mcp", }, - expectedAuthServer: DefaultAuthorizationServer, + expectedAuthServer: "", expectedResourcePath: "/mcp", }, } @@ -57,11 +53,12 @@ func TestNewAuthHandler(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - handler, err := NewAuthHandler(tc.cfg) + handler, err := NewAuthHandler(tc.cfg, dotcomHost) require.NoError(t, err) require.NotNil(t, handler) assert.Equal(t, tc.expectedAuthServer, handler.cfg.AuthorizationServer) + assert.Equal(t, tc.expectedResourcePath, handler.cfg.ResourcePath) }) } } @@ -372,7 +369,7 @@ func TestHandleProtectedResource(t *testing.T) { authServers, ok := body["authorization_servers"].([]any) require.True(t, ok) require.Len(t, authServers, 1) - assert.Equal(t, DefaultAuthorizationServer, authServers[0]) + assert.Equal(t, defaultAuthorizationServer, authServers[0]) }, }, { @@ -451,7 +448,10 @@ func TestHandleProtectedResource(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - handler, err := NewAuthHandler(tc.cfg) + dotcomHost, err := utils.NewAPIHost("https://api.github.com") + require.NoError(t, err) + + handler, err := NewAuthHandler(tc.cfg, dotcomHost) require.NoError(t, err) router := chi.NewRouter() @@ -493,9 +493,12 @@ func TestHandleProtectedResource(t *testing.T) { func TestRegisterRoutes(t *testing.T) { t.Parallel() + dotcomHost, err := utils.NewAPIHost("https://api.github.com") + require.NoError(t, err) + handler, err := NewAuthHandler(&Config{ BaseURL: "https://api.example.com", - }) + }, dotcomHost) require.NoError(t, err) router := chi.NewRouter() @@ -559,9 +562,12 @@ func TestSupportedScopes(t *testing.T) { func TestProtectedResourceResponseFormat(t *testing.T) { t.Parallel() + dotcomHost, err := utils.NewAPIHost("https://api.github.com") + require.NoError(t, err) + handler, err := NewAuthHandler(&Config{ BaseURL: "https://api.example.com", - }) + }, dotcomHost) require.NoError(t, err) router := chi.NewRouter() @@ -598,7 +604,7 @@ func TestProtectedResourceResponseFormat(t *testing.T) { authServers, ok := response["authorization_servers"].([]any) require.True(t, ok) assert.Len(t, authServers, 1) - assert.Equal(t, DefaultAuthorizationServer, authServers[0]) + assert.Equal(t, defaultAuthorizationServer, authServers[0]) } func TestOAuthProtectedResourcePrefix(t *testing.T) { @@ -611,5 +617,121 @@ func TestOAuthProtectedResourcePrefix(t *testing.T) { func TestDefaultAuthorizationServer(t *testing.T) { t.Parallel() - assert.Equal(t, "https://github.com/login/oauth", DefaultAuthorizationServer) + assert.Equal(t, "https://github.com/login/oauth", defaultAuthorizationServer) +} + +func TestAPIHostResolver_AuthorizationServerURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + host string + oauthConfig *Config + expectedURL string + expectedError bool + expectedStatusCode int + errorContains string + }{ + { + name: "valid host returns authorization server URL", + host: "https://github.com", + expectedURL: "https://github.com/login/oauth", + expectedStatusCode: http.StatusOK, + }, + { + name: "invalid host returns error", + host: "://invalid-url", + expectedURL: "", + expectedError: true, + errorContains: "could not parse host as URL", + }, + { + name: "host without scheme returns error", + host: "github.com", + expectedURL: "", + expectedError: true, + errorContains: "host must have a scheme", + }, + { + name: "GHEC host returns correct authorization server URL", + host: "https://test.ghe.com", + expectedURL: "https://test.ghe.com/login/oauth", + expectedStatusCode: http.StatusOK, + }, + { + name: "GHES host returns correct authorization server URL", + host: "https://ghe.example.com", + expectedURL: "https://ghe.example.com/login/oauth", + expectedStatusCode: http.StatusOK, + }, + { + name: "GHES with http scheme returns the correct authorization server URL", + host: "http://ghe.example.com", + expectedURL: "http://ghe.example.com/login/oauth", + expectedStatusCode: http.StatusOK, + }, + { + name: "custom authorization server in config takes precedence", + host: "https://github.com", + oauthConfig: &Config{ + AuthorizationServer: "https://custom.auth.example.com/oauth", + }, + expectedURL: "https://custom.auth.example.com/oauth", + expectedStatusCode: http.StatusOK, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + apiHost, err := utils.NewAPIHost(tc.host) + if tc.expectedError { + require.Error(t, err) + if tc.errorContains != "" { + assert.Contains(t, err.Error(), tc.errorContains) + } + return + } + require.NoError(t, err) + + config := tc.oauthConfig + if config == nil { + config = &Config{} + } + config.BaseURL = tc.host + + handler, err := NewAuthHandler(config, apiHost) + require.NoError(t, err) + + router := chi.NewRouter() + handler.RegisterRoutes(router) + + req := httptest.NewRequest(http.MethodGet, OAuthProtectedResourcePrefix, nil) + req.Host = "api.example.com" + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var response map[string]any + err = json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Contains(t, response, "authorization_servers") + if tc.expectedStatusCode != http.StatusOK { + require.Equal(t, tc.expectedStatusCode, rec.Code) + if tc.errorContains != "" { + assert.Contains(t, rec.Body.String(), tc.errorContains) + } + return + } + + responseAuthServers, ok := response["authorization_servers"].([]any) + require.True(t, ok) + require.Len(t, responseAuthServers, 1) + assert.Equal(t, tc.expectedURL, responseAuthServers[0]) + }) + } } diff --git a/pkg/http/server.go b/pkg/http/server.go index 7397e54a8..872303940 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -136,7 +136,7 @@ func RunHTTPServer(cfg ServerConfig) error { r := chi.NewRouter() handler := NewHTTPMcpHandler(ctx, &cfg, deps, t, logger, apiHost, append(serverOptions, WithFeatureChecker(featureChecker), WithOAuthConfig(oauthCfg))...) - oauthHandler, err := oauth.NewAuthHandler(oauthCfg) + oauthHandler, err := oauth.NewAuthHandler(oauthCfg, apiHost) if err != nil { return fmt.Errorf("failed to create OAuth handler: %w", err) } diff --git a/pkg/scopes/fetcher_test.go b/pkg/scopes/fetcher_test.go index 2d887d7a8..7ef910a56 100644 --- a/pkg/scopes/fetcher_test.go +++ b/pkg/scopes/fetcher_test.go @@ -28,6 +28,9 @@ func (t testAPIHostResolver) UploadURL(_ context.Context) (*url.URL, error) { func (t testAPIHostResolver) RawURL(_ context.Context) (*url.URL, error) { return nil, nil } +func (t testAPIHostResolver) AuthorizationServerURL(_ context.Context) (*url.URL, error) { + return nil, nil +} func TestParseScopeHeader(t *testing.T) { tests := []struct { diff --git a/pkg/utils/api.go b/pkg/utils/api.go index a523917de..a22711b23 100644 --- a/pkg/utils/api.go +++ b/pkg/utils/api.go @@ -14,13 +14,15 @@ type APIHostResolver interface { GraphqlURL(ctx context.Context) (*url.URL, error) UploadURL(ctx context.Context) (*url.URL, error) RawURL(ctx context.Context) (*url.URL, error) + AuthorizationServerURL(ctx context.Context) (*url.URL, error) } type APIHost struct { - restURL *url.URL - gqlURL *url.URL - uploadURL *url.URL - rawURL *url.URL + restURL *url.URL + gqlURL *url.URL + uploadURL *url.URL + rawURL *url.URL + authorizationServerURL *url.URL } var _ APIHostResolver = APIHost{} @@ -52,6 +54,10 @@ func (a APIHost) RawURL(_ context.Context) (*url.URL, error) { return a.rawURL, nil } +func (a APIHost) AuthorizationServerURL(_ context.Context) (*url.URL, error) { + return a.authorizationServerURL, nil +} + func newDotcomHost() (APIHost, error) { baseRestURL, err := url.Parse("https://api.github.com/") if err != nil { @@ -73,11 +79,18 @@ func newDotcomHost() (APIHost, error) { return APIHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err) } + // The authorization server for GitHub.com is at github.com/login/oauth, not api.github.com + authorizationServerURL, err := url.Parse("https://github.com/login/oauth") + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse dotcom Authorization Server URL: %w", err) + } + return APIHost{ - restURL: baseRestURL, - gqlURL: gqlURL, - uploadURL: uploadURL, - rawURL: rawURL, + restURL: baseRestURL, + gqlURL: gqlURL, + uploadURL: uploadURL, + rawURL: rawURL, + authorizationServerURL: authorizationServerURL, }, nil } @@ -112,11 +125,17 @@ func newGHECHost(hostname string) (APIHost, error) { return APIHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err) } + authorizationServerURL, err := url.Parse(fmt.Sprintf("https://%s/login/oauth", u.Hostname())) + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHEC Authorization Server URL: %w", err) + } + return APIHost{ - restURL: restURL, - gqlURL: gqlURL, - uploadURL: uploadURL, - rawURL: rawURL, + restURL: restURL, + gqlURL: gqlURL, + uploadURL: uploadURL, + rawURL: rawURL, + authorizationServerURL: authorizationServerURL, }, nil } @@ -164,11 +183,17 @@ func newGHESHost(hostname string) (APIHost, error) { return APIHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err) } + authorizationServerURL, err := url.Parse(fmt.Sprintf("%s://%s/login/oauth", u.Scheme, u.Hostname())) + if err != nil { + return APIHost{}, fmt.Errorf("failed to parse GHES Authorization Server URL: %w", err) + } + return APIHost{ - restURL: restURL, - gqlURL: gqlURL, - uploadURL: uploadURL, - rawURL: rawURL, + restURL: restURL, + gqlURL: gqlURL, + uploadURL: uploadURL, + rawURL: rawURL, + authorizationServerURL: authorizationServerURL, }, nil } From 3ffc06b71d44e865f93858300b4db9f26ca04a89 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:20:19 +0000 Subject: [PATCH 20/57] reduce context for add_issue_comments using minimal types (#2063) --- pkg/github/issues.go | 7 ++++++- pkg/github/issues_test.go | 11 +++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 048303382..d19f2543b 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -688,7 +688,12 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create comment", resp, body), nil, nil } - r, err := json.Marshal(createdComment) + minimalResponse := MinimalResponse{ + ID: fmt.Sprintf("%d", createdComment.GetID()), + URL: createdComment.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 90fd2a3da..f52f4f4ba 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -458,13 +458,12 @@ func Test_AddIssueComment(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedComment github.IssueComment - err = json.Unmarshal([]byte(textContent.Text), &returnedComment) + // Unmarshal and verify the result contains minimal response + var minimalResponse MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &minimalResponse) require.NoError(t, err) - assert.Equal(t, *tc.expectedComment.ID, *returnedComment.ID) - assert.Equal(t, *tc.expectedComment.Body, *returnedComment.Body) - assert.Equal(t, *tc.expectedComment.User.Login, *returnedComment.User.Login) + assert.Equal(t, fmt.Sprintf("%d", tc.expectedComment.GetID()), minimalResponse.ID) + assert.Equal(t, tc.expectedComment.GetHTMLURL(), minimalResponse.URL) }) } From c0ba3edceed207fc243536ce75a1a97badde6178 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:07:45 +0000 Subject: [PATCH 21/57] use minimal types (#2066) --- pkg/github/minimal_types.go | 30 ++++++++++++++++++++++++++++++ pkg/github/pullrequests.go | 8 ++++---- pkg/github/pullrequests_test.go | 12 ++++++------ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 23cf1e555..73ff549c6 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -272,8 +272,38 @@ type MinimalProjectStatusUpdate struct { Creator *MinimalUser `json:"creator,omitempty"` } +// MinimalPullRequestReview is the trimmed output type for pull request review objects to reduce verbosity. +type MinimalPullRequestReview struct { + ID int64 `json:"id"` + State string `json:"state"` + Body string `json:"body,omitempty"` + HTMLURL string `json:"html_url"` + User *MinimalUser `json:"user,omitempty"` + CommitID string `json:"commit_id,omitempty"` + SubmittedAt string `json:"submitted_at,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` +} + // Helper functions +func convertToMinimalPullRequestReview(review *github.PullRequestReview) MinimalPullRequestReview { + m := MinimalPullRequestReview{ + ID: review.GetID(), + State: review.GetState(), + Body: review.GetBody(), + HTMLURL: review.GetHTMLURL(), + User: convertToMinimalUser(review.GetUser()), + CommitID: review.GetCommitID(), + AuthorAssociation: review.GetAuthorAssociation(), + } + + if review.SubmittedAt != nil { + m.SubmittedAt = review.SubmittedAt.Format(time.RFC3339) + } + + return m +} + func convertToMinimalIssue(issue *github.Issue) MinimalIssue { m := MinimalIssue{ Number: issue.GetNumber(), diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index f4c49283d..0a25dd1bd 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -454,12 +454,12 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool } } - r, err := json.Marshal(reviews) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + minimalReviews := make([]MinimalPullRequestReview, 0, len(reviews)) + for _, review := range reviews { + minimalReviews = append(minimalReviews, convertToMinimalPullRequestReview(review)) } - return utils.NewToolResultText(string(r)), nil + return MarshalledTextResult(minimalReviews), nil } // PullRequestWriteUIResourceURI is the URI for the create_pull_request tool's MCP App UI resource. diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 7490f2254..4a92865d6 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1990,18 +1990,18 @@ func Test_GetPullRequestReviews(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedReviews []*github.PullRequestReview + var returnedReviews []MinimalPullRequestReview err = json.Unmarshal([]byte(textContent.Text), &returnedReviews) require.NoError(t, err) assert.Len(t, returnedReviews, len(tc.expectedReviews)) for i, review := range returnedReviews { + assert.Equal(t, tc.expectedReviews[i].GetID(), review.ID) + assert.Equal(t, tc.expectedReviews[i].GetState(), review.State) + assert.Equal(t, tc.expectedReviews[i].GetBody(), review.Body) require.NotNil(t, tc.expectedReviews[i].User) require.NotNil(t, review.User) - assert.Equal(t, tc.expectedReviews[i].GetID(), review.GetID()) - assert.Equal(t, tc.expectedReviews[i].GetState(), review.GetState()) - assert.Equal(t, tc.expectedReviews[i].GetBody(), review.GetBody()) - assert.Equal(t, tc.expectedReviews[i].GetUser().GetLogin(), review.GetUser().GetLogin()) - assert.Equal(t, tc.expectedReviews[i].GetHTMLURL(), review.GetHTMLURL()) + assert.Equal(t, tc.expectedReviews[i].GetUser().GetLogin(), review.User.Login) + assert.Equal(t, tc.expectedReviews[i].GetHTMLURL(), review.HTMLURL) } }) } From a94f95b43fbaa786806c6c848e272305d21d69f5 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 24 Feb 2026 13:41:19 +0000 Subject: [PATCH 22/57] Enhance client support checks for MCP Apps UI rendering (#2051) * enhance client support checks for MCP Apps UI rendering * update dependencies and enhance MCP Apps UI support handling * chore: regenerate license files Auto-generated by license-check workflow * retrigger CI * update test * introduce constants for client names and remove wrong ide name for mcp apps support --------- Co-authored-by: github-actions[bot] --- go.mod | 8 +- go.sum | 24 ++- pkg/context/request.go | 16 ++ pkg/github/helper_test.go | 22 +- pkg/github/issues.go | 2 +- pkg/github/issues_test.go | 4 +- pkg/github/pullrequests.go | 2 +- pkg/github/pullrequests_test.go | 4 +- pkg/github/ui_capability.go | 43 ++-- pkg/github/ui_capability_test.go | 92 +++++--- third-party-licenses.darwin.md | 7 +- third-party-licenses.linux.md | 7 +- third-party-licenses.windows.md | 7 +- .../modelcontextprotocol/go-sdk/LICENSE | 197 +++++++++++++++++- third-party/github.com/segmentio/asm/LICENSE | 21 ++ .../github.com/segmentio/encoding/LICENSE | 21 ++ .../golang.org/x/sys/{unix => }/LICENSE | 0 third-party/golang.org/x/sys/windows/LICENSE | 27 --- 18 files changed, 395 insertions(+), 109 deletions(-) create mode 100644 third-party/github.com/segmentio/asm/LICENSE create mode 100644 third-party/github.com/segmentio/encoding/LICENSE rename third-party/golang.org/x/sys/{unix => }/LICENSE (100%) delete mode 100644 third-party/golang.org/x/sys/windows/LICENSE diff --git a/go.mod b/go.mod index f1ffb02a2..2bacfe759 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/josephburnett/jd/v2 v2.4.0 github.com/lithammer/fuzzysearch v1.1.8 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/modelcontextprotocol/go-sdk v1.3.0 + github.com/modelcontextprotocol/go-sdk v1.3.1-0.20260220105450-b17143f71798 github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 @@ -35,6 +35,8 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -43,8 +45,8 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.28.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index fc8c2241b..80f153a82 100644 --- a/go.sum +++ b/go.sum @@ -15,8 +15,8 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -44,8 +44,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs= -github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE= +github.com/modelcontextprotocol/go-sdk v1.3.1-0.20260220105450-b17143f71798 h1:ogb5ErmcnxZgfaTeVZnKEMrwdHDpJ3yln5EhCIPcTlY= +github.com/modelcontextprotocol/go-sdk v1.3.1-0.20260220105450-b17143f71798/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021/go.mod h1:WERUkUryfUWlrHnFSO/BEUZ+7Ns8aZy7iVOGewxKzcc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -57,6 +57,10 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M= github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= @@ -97,8 +101,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -108,8 +112,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -124,8 +128,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/pkg/context/request.go b/pkg/context/request.go index 9af925fc1..6d8d8a106 100644 --- a/pkg/context/request.go +++ b/pkg/context/request.go @@ -113,3 +113,19 @@ func GetHeaderFeatures(ctx context.Context) []string { } return nil } + +// uiSupportCtxKey is a context key for MCP Apps UI support +type uiSupportCtxKey struct{} + +// WithUISupport stores whether the client supports MCP Apps UI in the context. +// This is used by HTTP/stateless servers where the go-sdk session may not +// persist client capabilities across requests. +func WithUISupport(ctx context.Context, supported bool) context.Context { + return context.WithValue(ctx, uiSupportCtxKey{}, supported) +} + +// HasUISupport retrieves the MCP Apps UI support flag from context. +func HasUISupport(ctx context.Context) (supported bool, ok bool) { + v, ok := ctx.Value(uiSupportCtxKey{}).(bool) + return v, ok +} diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index ae6c644e2..a17b8178b 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -291,10 +291,16 @@ func createMCPRequest(args any) mcp.CallToolRequest { } } +// Well-known MCP client names used in tests. +const ( + ClientNameVSCodeInsiders = "Visual Studio Code - Insiders" + ClientNameVSCode = "Visual Studio Code" +) + // createMCPRequestWithSession creates a CallToolRequest with a ServerSession -// that has the given client name in its InitializeParams. This is used to test -// UI capability detection based on ClientInfo.Name. -func createMCPRequestWithSession(t *testing.T, clientName string, args any) mcp.CallToolRequest { +// that has the given client name in its InitializeParams. When withUI is true +// the session advertises MCP Apps UI support via the capability extension. +func createMCPRequestWithSession(t *testing.T, clientName string, withUI bool, args any) mcp.CallToolRequest { t.Helper() argsMap, ok := args.(map[string]any) @@ -306,11 +312,19 @@ func createMCPRequestWithSession(t *testing.T, clientName string, args any) mcp. srv := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) + caps := &mcp.ClientCapabilities{} + if withUI { + caps.AddExtension("io.modelcontextprotocol/ui", map[string]any{ + "mimeTypes": []string{"text/html;profile=mcp-app"}, + }) + } + st, _ := mcp.NewInMemoryTransports() session, err := srv.Connect(context.Background(), st, &mcp.ServerSessionOptions{ State: &mcp.ServerSessionState{ InitializeParams: &mcp.InitializeParams{ - ClientInfo: &mcp.Implementation{Name: clientName}, + ClientInfo: &mcp.Implementation{Name: clientName}, + Capabilities: caps, }, }, }) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index d19f2543b..d07ce3fed 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1105,7 +1105,7 @@ Options are: // to distinguish form submissions from LLM calls. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") - if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(req) && !uiSubmitted { + if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(ctx, req) && !uiSubmitted { if method == "update" { issueNumber, numErr := RequiredInt(args, "issue_number") if numErr != nil { diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index f52f4f4ba..f9b8c7c62 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -957,7 +957,7 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { handler := serverTool.Handler(deps) t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) { - request := createMCPRequestWithSession(t, "Visual Studio Code - Insiders", map[string]any{ + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ "method": "create", "owner": "owner", "repo": "repo", @@ -971,7 +971,7 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { }) t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { - request := createMCPRequestWithSession(t, "Visual Studio Code - Insiders", map[string]any{ + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ "method": "create", "owner": "owner", "repo": "repo", diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 0a25dd1bd..8e1ad10e5 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -537,7 +537,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo // to distinguish form submissions from LLM calls. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") - if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(req) && !uiSubmitted { + if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(ctx, req) && !uiSubmitted { return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. The user will review and confirm via the interactive form.", owner, repo)), nil, nil } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 4a92865d6..de652869f 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2185,7 +2185,7 @@ func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) { handler := serverTool.Handler(deps) t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) { - request := createMCPRequestWithSession(t, "Visual Studio Code", map[string]any{ + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ "owner": "owner", "repo": "repo", "title": "Test PR", @@ -2200,7 +2200,7 @@ func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) { }) t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { - request := createMCPRequestWithSession(t, "Visual Studio Code", map[string]any{ + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ "owner": "owner", "repo": "repo", "title": "Test PR", diff --git a/pkg/github/ui_capability.go b/pkg/github/ui_capability.go index a898382cc..8b06dd455 100644 --- a/pkg/github/ui_capability.go +++ b/pkg/github/ui_capability.go @@ -1,27 +1,32 @@ package github -import "github.com/modelcontextprotocol/go-sdk/mcp" +import ( + "context" -// uiSupportedClients lists client names (from ClientInfo.Name) known to -// support MCP Apps UI rendering. -// -// This is a temporary workaround until the Go SDK adds an Extensions field -// to ClientCapabilities (see https://github.com/modelcontextprotocol/go-sdk/issues/777). -// Once that lands, detection should use capabilities.extensions instead. -var uiSupportedClients = map[string]bool{ - "Visual Studio Code - Insiders": true, - "Visual Studio Code": true, -} + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// mcpAppsExtensionKey is the capability extension key that clients use to +// advertise MCP Apps UI support. +const mcpAppsExtensionKey = "io.modelcontextprotocol/ui" // clientSupportsUI reports whether the MCP client that sent this request -// supports MCP Apps UI rendering, based on its ClientInfo.Name. -func clientSupportsUI(req *mcp.CallToolRequest) bool { - if req == nil || req.Session == nil { - return false +// supports MCP Apps UI rendering. +// It checks the context first (set by HTTP/stateless servers from stored +// session capabilities), then falls back to the go-sdk Session (for stdio). +func clientSupportsUI(ctx context.Context, req *mcp.CallToolRequest) bool { + // Check context first (works for HTTP/stateless servers) + if supported, ok := ghcontext.HasUISupport(ctx); ok { + return supported } - params := req.Session.InitializeParams() - if params == nil || params.ClientInfo == nil { - return false + // Fall back to go-sdk session (works for stdio/stateful servers) + if req != nil && req.Session != nil { + params := req.Session.InitializeParams() + if params != nil && params.Capabilities != nil { + _, hasUI := params.Capabilities.Extensions[mcpAppsExtensionKey] + return hasUI + } } - return uiSupportedClients[params.ClientInfo.Name] + return false } diff --git a/pkg/github/ui_capability_test.go b/pkg/github/ui_capability_test.go index 59c08c4ad..72275d7c4 100644 --- a/pkg/github/ui_capability_test.go +++ b/pkg/github/ui_capability_test.go @@ -4,58 +4,84 @@ import ( "context" "testing" + ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func createMCPRequestWithCapabilities(t *testing.T, caps *mcp.ClientCapabilities) mcp.CallToolRequest { + t.Helper() + srv := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) + st, _ := mcp.NewInMemoryTransports() + session, err := srv.Connect(context.Background(), st, &mcp.ServerSessionOptions{ + State: &mcp.ServerSessionState{ + InitializeParams: &mcp.InitializeParams{ + ClientInfo: &mcp.Implementation{Name: "test-client"}, + Capabilities: caps, + }, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + return mcp.CallToolRequest{Session: session} +} + func Test_clientSupportsUI(t *testing.T) { t.Parallel() + ctx := context.Background() - tests := []struct { - name string - clientName string - want bool - }{ - {name: "VS Code Insiders", clientName: "Visual Studio Code - Insiders", want: true}, - {name: "VS Code Stable", clientName: "Visual Studio Code", want: true}, - {name: "unknown client", clientName: "some-other-client", want: false}, - {name: "empty client name", clientName: "", want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := createMCPRequestWithSession(t, tt.clientName, nil) - assert.Equal(t, tt.want, clientSupportsUI(&req)) + t.Run("client with UI extension", func(t *testing.T) { + caps := &mcp.ClientCapabilities{} + caps.AddExtension("io.modelcontextprotocol/ui", map[string]any{ + "mimeTypes": []string{"text/html;profile=mcp-app"}, }) - } + req := createMCPRequestWithCapabilities(t, caps) + assert.True(t, clientSupportsUI(ctx, &req)) + }) + + t.Run("client without UI extension", func(t *testing.T) { + req := createMCPRequestWithCapabilities(t, &mcp.ClientCapabilities{}) + assert.False(t, clientSupportsUI(ctx, &req)) + }) + + t.Run("client with nil capabilities", func(t *testing.T) { + req := createMCPRequestWithCapabilities(t, nil) + assert.False(t, clientSupportsUI(ctx, &req)) + }) t.Run("nil request", func(t *testing.T) { - assert.False(t, clientSupportsUI(nil)) + assert.False(t, clientSupportsUI(ctx, nil)) }) t.Run("nil session", func(t *testing.T) { req := createMCPRequest(nil) - assert.False(t, clientSupportsUI(&req)) + assert.False(t, clientSupportsUI(ctx, &req)) }) } -func Test_clientSupportsUI_nilClientInfo(t *testing.T) { +func Test_clientSupportsUI_fromContext(t *testing.T) { t.Parallel() - srv := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) - st, _ := mcp.NewInMemoryTransports() - session, err := srv.Connect(context.Background(), st, &mcp.ServerSessionOptions{ - State: &mcp.ServerSessionState{ - InitializeParams: &mcp.InitializeParams{ - ClientInfo: nil, - }, - }, + t.Run("UI supported in context", func(t *testing.T) { + ctx := ghcontext.WithUISupport(context.Background(), true) + assert.True(t, clientSupportsUI(ctx, nil)) + }) + + t.Run("UI not supported in context", func(t *testing.T) { + ctx := ghcontext.WithUISupport(context.Background(), false) + assert.False(t, clientSupportsUI(ctx, nil)) }) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = session.Close() }) - req := mcp.CallToolRequest{Session: session} - assert.False(t, clientSupportsUI(&req)) + t.Run("context takes precedence over session", func(t *testing.T) { + ctx := ghcontext.WithUISupport(context.Background(), false) + caps := &mcp.ClientCapabilities{} + caps.AddExtension("io.modelcontextprotocol/ui", map[string]any{}) + req := createMCPRequestWithCapabilities(t, caps) + assert.False(t, clientSupportsUI(ctx, &req)) + }) + + t.Run("no context or session", func(t *testing.T) { + assert.False(t, clientSupportsUI(context.Background(), nil)) + }) } diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index de0981d75..b62febda3 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -28,10 +28,13 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.3.0/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/b17143f71798/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/b17143f71798/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) + - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) + - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.3/LICENSE)) - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) @@ -45,7 +48,7 @@ The following packages are included for the amd64, arm64 architectures. - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [golang.org/x/exp/slices](https://pkg.go.dev/golang.org/x/exp/slices) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/054e65f0:LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) - - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.40.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 48c632c6c..825c1ed6a 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -28,10 +28,13 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.3.0/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/b17143f71798/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/b17143f71798/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) + - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) + - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.3/LICENSE)) - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) @@ -45,7 +48,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [golang.org/x/exp/slices](https://pkg.go.dev/golang.org/x/exp/slices) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/054e65f0:LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) - - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.40.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 8845d59aa..d45aa33e0 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -29,10 +29,13 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.3.0/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/b17143f71798/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/b17143f71798/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) + - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) + - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.3/LICENSE)) - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) @@ -46,7 +49,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [golang.org/x/exp/slices](https://pkg.go.dev/golang.org/x/exp/slices) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/054e65f0:LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) - - [golang.org/x/sys/windows](https://pkg.go.dev/golang.org/x/sys/windows) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.40.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)) diff --git a/third-party/github.com/modelcontextprotocol/go-sdk/LICENSE b/third-party/github.com/modelcontextprotocol/go-sdk/LICENSE index 508be9266..5791499cb 100644 --- a/third-party/github.com/modelcontextprotocol/go-sdk/LICENSE +++ b/third-party/github.com/modelcontextprotocol/go-sdk/LICENSE @@ -1,6 +1,193 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + MIT License -Copyright (c) 2025 Go MCP SDK Authors +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -19,3 +206,11 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. diff --git a/third-party/github.com/segmentio/asm/LICENSE b/third-party/github.com/segmentio/asm/LICENSE new file mode 100644 index 000000000..29e1ab6b0 --- /dev/null +++ b/third-party/github.com/segmentio/asm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Segment + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/github.com/segmentio/encoding/LICENSE b/third-party/github.com/segmentio/encoding/LICENSE new file mode 100644 index 000000000..1fbffdf72 --- /dev/null +++ b/third-party/github.com/segmentio/encoding/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Segment.io, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third-party/golang.org/x/sys/unix/LICENSE b/third-party/golang.org/x/sys/LICENSE similarity index 100% rename from third-party/golang.org/x/sys/unix/LICENSE rename to third-party/golang.org/x/sys/LICENSE diff --git a/third-party/golang.org/x/sys/windows/LICENSE b/third-party/golang.org/x/sys/windows/LICENSE deleted file mode 100644 index 2a7cf70da..000000000 --- a/third-party/golang.org/x/sys/windows/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright 2009 The Go Authors. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google LLC nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From 2b55513a9ed8228d6f6ebe0c294983175767cc7c Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 24 Feb 2026 16:38:13 +0000 Subject: [PATCH 23/57] Update MIME types for UI resources to include profile for MCP Apps (#2078) * update MIME types for UI resources to include profile for MCP Apps * make it a const --- pkg/github/ui_capability.go | 3 +++ pkg/github/ui_resources.go | 12 ++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pkg/github/ui_capability.go b/pkg/github/ui_capability.go index 8b06dd455..f237df842 100644 --- a/pkg/github/ui_capability.go +++ b/pkg/github/ui_capability.go @@ -11,6 +11,9 @@ import ( // advertise MCP Apps UI support. const mcpAppsExtensionKey = "io.modelcontextprotocol/ui" +// MCPAppMIMEType is the MIME type for MCP App UI resources. +const MCPAppMIMEType = "text/html;profile=mcp-app" + // clientSupportsUI reports whether the MCP client that sent this request // supports MCP Apps UI rendering. // It checks the context first (set by HTTP/stateless servers from stored diff --git a/pkg/github/ui_resources.go b/pkg/github/ui_resources.go index 3fdb4a935..c41d2ac3f 100644 --- a/pkg/github/ui_resources.go +++ b/pkg/github/ui_resources.go @@ -17,7 +17,7 @@ func RegisterUIResources(s *mcp.Server) { URI: GetMeUIResourceURI, Name: "get_me_ui", Description: "MCP App UI for the get_me tool", - MIMEType: "text/html", + MIMEType: MCPAppMIMEType, }, func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { html := MustGetUIAsset("get-me.html") @@ -25,7 +25,7 @@ func RegisterUIResources(s *mcp.Server) { Contents: []*mcp.ResourceContents{ { URI: GetMeUIResourceURI, - MIMEType: "text/html", + MIMEType: MCPAppMIMEType, Text: html, // MCP Apps UI metadata - CSP configuration to allow loading GitHub avatars // See: https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx @@ -49,7 +49,7 @@ func RegisterUIResources(s *mcp.Server) { URI: IssueWriteUIResourceURI, Name: "issue_write_ui", Description: "MCP App UI for creating and updating GitHub issues", - MIMEType: "text/html", + MIMEType: MCPAppMIMEType, }, func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { html := MustGetUIAsset("issue-write.html") @@ -57,7 +57,7 @@ func RegisterUIResources(s *mcp.Server) { Contents: []*mcp.ResourceContents{ { URI: IssueWriteUIResourceURI, - MIMEType: "text/html", + MIMEType: MCPAppMIMEType, Text: html, }, }, @@ -71,7 +71,7 @@ func RegisterUIResources(s *mcp.Server) { URI: PullRequestWriteUIResourceURI, Name: "pr_write_ui", Description: "MCP App UI for creating GitHub pull requests", - MIMEType: "text/html", + MIMEType: MCPAppMIMEType, }, func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { html := MustGetUIAsset("pr-write.html") @@ -79,7 +79,7 @@ func RegisterUIResources(s *mcp.Server) { Contents: []*mcp.ResourceContents{ { URI: PullRequestWriteUIResourceURI, - MIMEType: "text/html", + MIMEType: MCPAppMIMEType, Text: html, }, }, From a32a757d70329fc289b9e623c27f22370e3ba773 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 25 Feb 2026 12:51:06 +0100 Subject: [PATCH 24/57] Fix panic when fetching resources fails due to network error (#1506) * fix panic due to defer reading body of failed request --------- Co-authored-by: Adam Holt --- pkg/github/repository_resource.go | 5 ++-- pkg/github/repository_resource_test.go | 40 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index 8b515d1b4..be86cc451 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -191,13 +191,14 @@ func RepositoryResourceContentsHandler(resourceURITemplate *uritemplate.Template } resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts) + if err != nil { + return nil, fmt.Errorf("failed to get raw content: %w", err) + } defer func() { _ = resp.Body.Close() }() // If the raw content is not found, we will fall back to the GitHub API (in case it is a directory) switch { - case err != nil: - return nil, fmt.Errorf("failed to get raw content: %w", err) case resp.StatusCode == http.StatusOK: ext := filepath.Ext(path) mimeType := resp.Header.Get("Content-Type") diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index b032554d8..f0fba30df 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "errors" "net/http" "net/url" "testing" @@ -12,6 +13,15 @@ import ( "github.com/stretchr/testify/require" ) +// errorTransport is a http.RoundTripper that always returns an error. +type errorTransport struct { + err error +} + +func (t *errorTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, t.err +} + type resourceResponseType int const ( @@ -272,3 +282,33 @@ func Test_repositoryResourceContents(t *testing.T) { }) } } + +// Test_repositoryResourceContentsHandler_NetworkError tests that a network error +// during raw content fetch does not cause a panic (nil response body dereference). +func Test_repositoryResourceContentsHandler_NetworkError(t *testing.T) { + base, _ := url.Parse("https://raw.example.com/") + networkErr := errors.New("network error: connection refused") + + httpClient := &http.Client{Transport: &errorTransport{err: networkErr}} + client := github.NewClient(httpClient) + mockRawClient := raw.NewClient(client, base) + deps := BaseDeps{ + Client: client, + RawClient: mockRawClient, + } + ctx := ContextWithDeps(context.Background(), deps) + + handler := RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) + + request := &mcp.ReadResourceRequest{ + Params: &mcp.ReadResourceParams{ + URI: "repo://owner/repo/contents/README.md", + }, + } + + // This should not panic, even though the HTTP client returns an error + resp, err := handler(ctx, request) + require.Error(t, err) + require.Nil(t, resp) + require.ErrorContains(t, err, "failed to get raw content") +} From 91b35e0f77006125e02be041e3aca68b98fd04dd Mon Sep 17 00:00:00 2001 From: kaitlin-duolingo Date: Wed, 25 Feb 2026 05:42:24 -0800 Subject: [PATCH 25/57] Get check runs (#1953) * Add support for get_check_runs * Run generate-docs * Address AI code review comment * make descriptions less ambiguous for model * lint and docs * fix lint --------- Co-authored-by: tommaso-moro Co-authored-by: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> --- README.md | 3 +- .../__toolsnaps__/pull_request_read.snap | 5 +- pkg/github/helper_test.go | 1 + pkg/github/minimal_types.go | 39 +++++ pkg/github/pullrequests.go | 73 ++++++++- pkg/github/pullrequests_test.go | 155 ++++++++++++++++++ 6 files changed, 271 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c02261970..045eda56c 100644 --- a/README.md +++ b/README.md @@ -1097,11 +1097,12 @@ The following sets of tools are available: Possible options: 1. get - Get details of a specific pull request. 2. get_diff - Get the diff of a pull request. - 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks. + 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. + 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. (string, required) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index a8591fc5c..9bb14cc07 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -7,7 +7,7 @@ "inputSchema": { "properties": { "method": { - "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n", + "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", "enum": [ "get", "get_diff", @@ -15,7 +15,8 @@ "get_files", "get_review_comments", "get_reviews", - "get_comments" + "get_comments", + "get_check_runs" ], "type": "string" }, diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index a17b8178b..ff752f5f3 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -51,6 +51,7 @@ const ( PostReposGitTreesByOwnerByRepo = "POST /repos/{owner}/{repo}/git/trees" GetReposCommitsStatusByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/status" GetReposCommitsStatusesByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/statuses" + GetReposCommitsCheckRunsByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}/check-runs" // Issues endpoints GetReposIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}" diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 73ff549c6..1d26f152a 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -702,6 +702,45 @@ func convertToMinimalBranch(branch *github.Branch) MinimalBranch { } } +// MinimalCheckRun is the trimmed output type for check run objects. +type MinimalCheckRun struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + DetailsURL string `json:"details_url,omitempty"` + StartedAt string `json:"started_at,omitempty"` + CompletedAt string `json:"completed_at,omitempty"` +} + +// MinimalCheckRunsResult is the trimmed output type for check runs list results. +type MinimalCheckRunsResult struct { + TotalCount int `json:"total_count"` + CheckRuns []MinimalCheckRun `json:"check_runs"` +} + +// convertToMinimalCheckRun converts a GitHub API CheckRun to MinimalCheckRun +func convertToMinimalCheckRun(checkRun *github.CheckRun) MinimalCheckRun { + minimalCheckRun := MinimalCheckRun{ + ID: checkRun.GetID(), + Name: checkRun.GetName(), + Status: checkRun.GetStatus(), + Conclusion: checkRun.GetConclusion(), + HTMLURL: checkRun.GetHTMLURL(), + DetailsURL: checkRun.GetDetailsURL(), + } + + if checkRun.StartedAt != nil { + minimalCheckRun.StartedAt = checkRun.StartedAt.Format("2006-01-02T15:04:05Z") + } + if checkRun.CompletedAt != nil { + minimalCheckRun.CompletedAt = checkRun.CompletedAt.Format("2006-01-02T15:04:05Z") + } + + return minimalCheckRun +} + func convertToMinimalReviewThreadsResponse(query reviewThreadsQuery) MinimalReviewThreadsResponse { threads := query.Repository.PullRequest.ReviewThreads diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 8e1ad10e5..0f6270f33 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -33,13 +33,14 @@ func PullRequestRead(t translations.TranslationHelperFunc) inventory.ServerTool Possible options: 1. get - Get details of a specific pull request. 2. get_diff - Get the diff of a pull request. - 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks. + 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. + 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. `, - Enum: []any{"get", "get_diff", "get_status", "get_files", "get_review_comments", "get_reviews", "get_comments"}, + Enum: []any{"get", "get_diff", "get_status", "get_files", "get_review_comments", "get_reviews", "get_comments", "get_check_runs"}, }, "owner": { Type: "string", @@ -128,6 +129,9 @@ Possible options: case "get_comments": result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination) return result, nil, err + case "get_check_runs": + result, err := GetPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination) + return result, nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -267,6 +271,71 @@ func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, rep return utils.NewToolResultText(string(r)), nil } +func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + // First get the PR to get the head SHA + pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get pull request", + resp, + err, + ), nil + } + defer 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 ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil + } + + // Get check runs for the head SHA + opts := &github.ListCheckRunsOptions{ + ListOptions: github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + }, + } + + checkRuns, resp, err := client.Checks.ListCheckRunsForRef(ctx, owner, repo, *pr.Head.SHA, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get check runs", + resp, + err, + ), nil + } + defer 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 ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get check runs", resp, body), nil + } + + // Convert to minimal check runs to reduce context usage + minimalCheckRuns := make([]MinimalCheckRun, 0, len(checkRuns.CheckRuns)) + for _, checkRun := range checkRuns.CheckRuns { + minimalCheckRuns = append(minimalCheckRuns, convertToMinimalCheckRun(checkRun)) + } + + minimalResult := MinimalCheckRunsResult{ + TotalCount: checkRuns.GetTotal(), + CheckRuns: minimalCheckRuns, + } + + r, err := json.Marshal(minimalResult) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil +} + func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { opts := &github.ListOptions{ PerPage: pagination.PerPage, diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index de652869f..1528fd77e 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1404,6 +1404,161 @@ func Test_GetPullRequestStatus(t *testing.T) { } } +func Test_GetPullRequestCheckRuns(t *testing.T) { + // Verify tool definition once + serverTool := PullRequestRead(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_read", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "method") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "pullNumber") + assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + // Setup mock PR for successful PR fetch + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("abcd1234"), + Ref: github.Ptr("feature-branch"), + }, + } + + // Setup mock check runs for success case + mockCheckRuns := &github.ListCheckRunsResults{ + Total: github.Ptr(2), + CheckRuns: []*github.CheckRun{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("build"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + HTMLURL: github.Ptr("https://github.com/owner/repo/runs/1"), + }, + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + HTMLURL: github.Ptr("https://github.com/owner/repo/runs/2"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedCheckRuns *github.ListCheckRunsResults + expectedErrMsg string + }{ + { + name: "successful check runs fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposCommitsCheckRunsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCheckRuns), + }), + requestArgs: map[string]any{ + "method": "get_check_runs", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: false, + expectedCheckRuns: mockCheckRuns, + }, + { + name: "PR fetch fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), + requestArgs: map[string]any{ + "method": "get_check_runs", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get pull request", + }, + { + name: "check runs fetch fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposCommitsCheckRunsByOwnerByRepoByRef: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), + requestArgs: map[string]any{ + "method": "get_check_runs", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: true, + expectedErrMsg: "failed to get check runs", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + serverTool := PullRequestRead(translations.NullTranslationHelper) + deps := BaseDeps{ + Client: client, + RepoAccessCache: stubRepoAccessCache(githubv4.NewClient(nil), 5*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &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 (using minimal type) + var returnedCheckRuns MinimalCheckRunsResult + err = json.Unmarshal([]byte(textContent.Text), &returnedCheckRuns) + require.NoError(t, err) + assert.Equal(t, *tc.expectedCheckRuns.Total, returnedCheckRuns.TotalCount) + assert.Len(t, returnedCheckRuns.CheckRuns, len(tc.expectedCheckRuns.CheckRuns)) + for i, checkRun := range returnedCheckRuns.CheckRuns { + assert.Equal(t, *tc.expectedCheckRuns.CheckRuns[i].Name, checkRun.Name) + assert.Equal(t, *tc.expectedCheckRuns.CheckRuns[i].Status, checkRun.Status) + assert.Equal(t, *tc.expectedCheckRuns.CheckRuns[i].Conclusion, checkRun.Conclusion) + } + }) + } +} + func Test_UpdatePullRequestBranch(t *testing.T) { // Verify tool definition once serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper) From c1ac64f1a29644acfb1a4d396caabc61c0b6a967 Mon Sep 17 00:00:00 2001 From: Roberto Nacu Date: Wed, 25 Feb 2026 15:25:52 +0000 Subject: [PATCH 26/57] Reduce context usage for list_pull_requests (#2087) --- pkg/github/pullrequests.go | 9 ++++++++- pkg/github/pullrequests_test.go | 14 +++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 0f6270f33..d608b12c7 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1217,7 +1217,14 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool } } - r, err := json.Marshal(prs) + minimalPRs := make([]MinimalPullRequest, 0, len(prs)) + for _, pr := range prs { + if pr != nil { + minimalPRs = append(minimalPRs, convertToMinimalPullRequest(pr)) + } + } + + r, err := json.Marshal(minimalPRs) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 1528fd77e..4bb31e541 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -671,16 +671,16 @@ func Test_ListPullRequests(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedPRs []*github.PullRequest + var returnedPRs []MinimalPullRequest err = json.Unmarshal([]byte(textContent.Text), &returnedPRs) require.NoError(t, err) assert.Len(t, returnedPRs, 2) - assert.Equal(t, *tc.expectedPRs[0].Number, *returnedPRs[0].Number) - assert.Equal(t, *tc.expectedPRs[0].Title, *returnedPRs[0].Title) - assert.Equal(t, *tc.expectedPRs[0].State, *returnedPRs[0].State) - assert.Equal(t, *tc.expectedPRs[1].Number, *returnedPRs[1].Number) - assert.Equal(t, *tc.expectedPRs[1].Title, *returnedPRs[1].Title) - assert.Equal(t, *tc.expectedPRs[1].State, *returnedPRs[1].State) + assert.Equal(t, *tc.expectedPRs[0].Number, returnedPRs[0].Number) + assert.Equal(t, *tc.expectedPRs[0].Title, returnedPRs[0].Title) + assert.Equal(t, *tc.expectedPRs[0].State, returnedPRs[0].State) + assert.Equal(t, *tc.expectedPRs[1].Number, returnedPRs[1].Number) + assert.Equal(t, *tc.expectedPRs[1].Title, returnedPRs[1].Title) + assert.Equal(t, *tc.expectedPRs[1].State, returnedPRs[1].State) }) } } From 391990ae80fc60ab0d7c348037030edf05a2b19d Mon Sep 17 00:00:00 2001 From: Roberto Nacu Date: Wed, 25 Feb 2026 15:32:21 +0000 Subject: [PATCH 27/57] Reduce context usage for list_tags (#2088) --- pkg/github/minimal_types.go | 18 ++++++++++++++++++ pkg/github/repositories.go | 9 ++++++++- pkg/github/repositories_test.go | 6 +++--- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 1d26f152a..c5b588d23 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -122,6 +122,12 @@ type MinimalBranch struct { Protected bool `json:"protected"` } +// MinimalTag is the trimmed output type for tag objects. +type MinimalTag struct { + Name string `json:"name"` + SHA string `json:"sha"` +} + // MinimalResponse represents a minimal response for all CRUD operations. // Success is implicit in the HTTP response status, and all other information // can be derived from the URL or fetched separately if needed. @@ -702,6 +708,18 @@ func convertToMinimalBranch(branch *github.Branch) MinimalBranch { } } +func convertToMinimalTag(tag *github.RepositoryTag) MinimalTag { + m := MinimalTag{ + Name: tag.GetName(), + } + + if commit := tag.GetCommit(); commit != nil { + m.SHA = commit.GetSHA() + } + + return m +} + // MinimalCheckRun is the trimmed output type for check run objects. type MinimalCheckRun struct { ID int64 `json:"id"` diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 4433fe64c..6aa144912 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1497,7 +1497,14 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list tags", resp, body), nil, nil } - r, err := json.Marshal(tags) + minimalTags := make([]MinimalTag, 0, len(tags)) + for _, tag := range tags { + if tag != nil { + minimalTags = append(minimalTags, convertToMinimalTag(tag)) + } + } + + r, err := json.Marshal(minimalTags) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 76628283d..0d7c55e85 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2791,15 +2791,15 @@ func Test_ListTags(t *testing.T) { textContent := getTextResult(t, result) // Parse and verify the result - var returnedTags []*github.RepositoryTag + var returnedTags []MinimalTag err = json.Unmarshal([]byte(textContent.Text), &returnedTags) require.NoError(t, err) // Verify each tag require.Equal(t, len(tc.expectedTags), len(returnedTags)) for i, expectedTag := range tc.expectedTags { - assert.Equal(t, *expectedTag.Name, *returnedTags[i].Name) - assert.Equal(t, *expectedTag.Commit.SHA, *returnedTags[i].Commit.SHA) + assert.Equal(t, *expectedTag.Name, returnedTags[i].Name) + assert.Equal(t, *expectedTag.Commit.SHA, returnedTags[i].SHA) } }) } From 584d0c916324a023b34c030dfd72f4cd73f817bd Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 25 Feb 2026 16:57:15 +0000 Subject: [PATCH 28/57] clarify user confirmation requirement in issue and pull request creation messages (#2094) --- pkg/github/issues.go | 4 ++-- pkg/github/pullrequests.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index d07ce3fed..81ce29df7 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1111,9 +1111,9 @@ Options are: if numErr != nil { return utils.NewToolResultError("issue_number is required for update method"), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. The user will review and confirm via the interactive form.", issueNumber, owner, repo)), nil, nil + return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. The user will review and confirm via the interactive form. The issue has NOT been updated yet - the user MUST confirm this operation.", issueNumber, owner, repo)), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. The user will review and confirm via the interactive form.", owner, repo)), nil, nil + return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. The user will review and confirm via the interactive form. The issue has NOT been created yet - the user MUST confirm this operation.", owner, repo)), nil, nil } title, err := OptionalParam[string](args, "title") diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index d608b12c7..e713c5890 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -607,7 +607,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(ctx, req) && !uiSubmitted { - return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. The user will review and confirm via the interactive form.", owner, repo)), nil, nil + return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. The user will review and confirm via the interactive form. The PR has NOT been created yet - the user MUST confirm this operation.", owner, repo)), nil, nil } // When creating PR, title/head/base are required From 1fec99f74eabd41e4f6f6b22f69c3d658c38f891 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:57:24 +0000 Subject: [PATCH 29/57] Add docs for Insiders Mode (#2095) * update docs * add mention of new doc * Update docs/insiders-features.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- docs/insiders-features.md | 44 +++++++++++++++++++++++++++ docs/server-configuration.md | 58 ++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 docs/insiders-features.md diff --git a/README.md b/README.md index 045eda56c..f83989a1e 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ When no toolsets are specified, [default toolsets](#default-toolset) are used. -See [Remote Server Documentation](docs/remote-server.md#insiders-mode) for more details and examples. +See [Remote Server Documentation](docs/remote-server.md#insiders-mode) for more details and examples, and [Insiders Features](docs/insiders-features.md) for a full list of what's available. #### GitHub Enterprise diff --git a/docs/insiders-features.md b/docs/insiders-features.md new file mode 100644 index 000000000..911257ae4 --- /dev/null +++ b/docs/insiders-features.md @@ -0,0 +1,44 @@ +# Insiders Features + +Insiders Mode gives you access to experimental features in the GitHub MCP Server. These features may change, evolve, or be removed based on community feedback. + +We created this mode to have a way to roll out experimental features and collect feedback. So if you are using Insiders, please don't hesitate to share your feedback with us! + +> [!NOTE] +> Features in Insiders Mode are experimental. + +## Enabling Insiders Mode + +| Method | Remote Server | Local Server | +|--------|---------------|--------------| +| URL path | Append `/insiders` to the URL | N/A | +| Header | `X-MCP-Insiders: true` | N/A | +| CLI flag | N/A | `--insiders` | +| Environment variable | N/A | `GITHUB_INSIDERS=true` | + +For configuration examples, see the [Server Configuration Guide](./server-configuration.md#insiders-mode). + +--- + +## MCP Apps + +[MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to end users. Instead of returning plain text that the LLM must interpret and relay, tools can render forms, profiles, and dashboards right in the chat using MCP Apps. + +This means you can interact with GitHub visually: fill out forms to create issues, see user profiles with avatars, open pull requests — all without leaving your agent chat. + +### Supported tools + +The following tools have MCP Apps UIs: + +| Tool | Description | +|------|-------------| +| `get_me` | Displays your GitHub user profile with avatar, bio, and stats in a rich card | +| `issue_write` | Opens an interactive form to create or update issues | +| `create_pull_request` | Provides a full PR creation form to create a pull request (or a draft pull request) | + +### Client requirements + +MCP Apps requires a host that supports the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps). Currently tested and working with: + +- **VS Code Insiders** — enable via the `chat.mcp.apps.enabled` setting +- **Visual Studio Code** — enable via the `chat.mcp.apps.enabled` setting diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 506ac0354..a334eb1a2 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -13,6 +13,7 @@ We currently support the following ways in which the GitHub MCP Server can be co | Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var | | Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var | | Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var | +| Insiders Mode | `X-MCP-Insiders` header or `/insiders` URL | `--insiders` flag or `GITHUB_INSIDERS` env var | | Scope Filtering | Always enabled | Always enabled | > **Default behavior:** If you don't specify any configuration, the server uses the **default toolsets**: `context`, `issues`, `pull_requests`, `repos`, `users`. @@ -384,6 +385,63 @@ Lockdown mode ensures the server only surfaces content in public repositories fr --- +### Insiders Mode + +**Best for:** Users who want early access to experimental features and new tools before they reach general availability. + +Insiders Mode unlocks experimental features, such as [MCP Apps](./insiders-features.md#mcp-apps) support. We created this mode to have a way to roll out experimental features and collect feedback. So if you are using Insiders, please don't hesitate to share your feedback with us! Features in Insiders Mode may change, evolve, or be removed based on user feedback. + + + + + + + +
Remote ServerLocal Server
+ +**Option A: URL path** +```json +{ + "type": "http", + "url": "https://api.githubcopilot.com/mcp/insiders" +} +``` + +**Option B: Header** +```json +{ + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "X-MCP-Insiders": "true" + } +} +``` + + + +```json +{ + "type": "stdio", + "command": "go", + "args": [ + "run", + "./cmd/github-mcp-server", + "stdio", + "--insiders" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" + } +} +``` + +
+ +See [Insiders Features](./insiders-features.md) for a full list of what's available in Insiders Mode. + +--- + ### Scope Filtering **Automatic feature:** The server handles OAuth scopes differently depending on authentication type: From 81f4c87a318031c9d5fc2426cbc1988691bcd134 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 25 Feb 2026 18:02:37 +0000 Subject: [PATCH 30/57] make ui submit message prior to click even more insistent (#2096) --- pkg/github/issues.go | 4 ++-- pkg/github/pullrequests.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 81ce29df7..ce3c4e945 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1111,9 +1111,9 @@ Options are: if numErr != nil { return utils.NewToolResultError("issue_number is required for update method"), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. The user will review and confirm via the interactive form. The issue has NOT been updated yet - the user MUST confirm this operation.", issueNumber, owner, repo)), nil, nil + return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. The user will review and confirm via the interactive form. The issue has NOT been created yet - the user MUST confirm this operation.", owner, repo)), nil, nil + return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil } title, err := OptionalParam[string](args, "title") diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index e713c5890..7eb9c6d64 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -607,7 +607,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(ctx, req) && !uiSubmitted { - return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. The user will review and confirm via the interactive form. The PR has NOT been created yet - the user MUST confirm this operation.", owner, repo)), nil, nil + return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. IMPORTANT: The PR has NOT been created yet. Do NOT tell the user the PR was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil } // When creating PR, title/head/base are required From b222072346e379528227d881354ddce2ae3daef6 Mon Sep 17 00:00:00 2001 From: Roberto Nacu Date: Wed, 25 Feb 2026 18:15:22 +0000 Subject: [PATCH 31/57] Reduce context usage for list_releases (#2091) --- pkg/github/minimal_types.go | 19 +++++++++++++++++++ pkg/github/repositories.go | 9 ++++++++- pkg/github/repositories_test.go | 6 +++--- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index c5b588d23..3eabf2163 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -708,6 +708,25 @@ func convertToMinimalBranch(branch *github.Branch) MinimalBranch { } } +func convertToMinimalRelease(release *github.RepositoryRelease) MinimalRelease { + m := MinimalRelease{ + ID: release.GetID(), + TagName: release.GetTagName(), + Name: release.GetName(), + Body: release.GetBody(), + HTMLURL: release.GetHTMLURL(), + Prerelease: release.GetPrerelease(), + Draft: release.GetDraft(), + Author: convertToMinimalUser(release.GetAuthor()), + } + + if release.PublishedAt != nil { + m.PublishedAt = release.PublishedAt.Format(time.RFC3339) + } + + return m +} + func convertToMinimalTag(tag *github.RepositoryTag) MinimalTag { m := MinimalTag{ Name: tag.GetName(), diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 6aa144912..a236609bc 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1677,7 +1677,14 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list releases", resp, body), nil, nil } - r, err := json.Marshal(releases) + minimalReleases := make([]MinimalRelease, 0, len(releases)) + for _, release := range releases { + if release != nil { + minimalReleases = append(minimalReleases, convertToMinimalRelease(release)) + } + } + + r, err := json.Marshal(minimalReleases) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 0d7c55e85..fdb5780c2 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -3052,12 +3052,12 @@ func Test_ListReleases(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - var returnedReleases []*github.RepositoryRelease + var returnedReleases []MinimalRelease err = json.Unmarshal([]byte(textContent.Text), &returnedReleases) require.NoError(t, err) assert.Len(t, returnedReleases, len(tc.expectedResult)) - for i, rel := range returnedReleases { - assert.Equal(t, *tc.expectedResult[i].TagName, *rel.TagName) + for i := range returnedReleases { + assert.Equal(t, *tc.expectedResult[i].TagName, returnedReleases[i].TagName) } }) } From bf6467855c13d3b8f5cf72f8dfd9c86f8336b20b Mon Sep 17 00:00:00 2001 From: Roberto Nacu Date: Thu, 26 Feb 2026 10:02:51 +0000 Subject: [PATCH 32/57] Reduce context usage for list_issues (#2098) * reduce context usage for list_issues * address copilot feedback, align pagination tags to camelCase --- pkg/github/issues.go | 64 +++---------------------------------- pkg/github/issues_test.go | 36 ++++++++++----------- pkg/github/minimal_types.go | 62 +++++++++++++++++++++++++++++++---- 3 files changed, 77 insertions(+), 85 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index ce3c4e945..b5bc4ebb8 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -201,33 +201,6 @@ func getIssueQueryType(hasLabels bool, hasSince bool) any { } } -func fragmentToIssue(fragment IssueFragment) *github.Issue { - // Convert GraphQL labels to GitHub API labels format - var foundLabels []*github.Label - for _, labelNode := range fragment.Labels.Nodes { - foundLabels = append(foundLabels, &github.Label{ - Name: github.Ptr(string(labelNode.Name)), - NodeID: github.Ptr(string(labelNode.ID)), - Description: github.Ptr(string(labelNode.Description)), - }) - } - - return &github.Issue{ - Number: github.Ptr(int(fragment.Number)), - Title: github.Ptr(sanitize.Sanitize(string(fragment.Title))), - CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, - UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, - User: &github.User{ - Login: github.Ptr(string(fragment.Author.Login)), - }, - State: github.Ptr(string(fragment.State)), - ID: github.Ptr(fragment.DatabaseID), - Body: github.Ptr(sanitize.Sanitize(string(fragment.Body))), - Labels: foundLabels, - Comments: github.Ptr(int(fragment.Comments.TotalCount)), - } -} - // IssueRead creates a tool to get details of a specific issue in a GitHub repository. func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ @@ -1584,41 +1557,12 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { ), nil, nil } - // Extract and convert all issue nodes using the common interface - var issues []*github.Issue - var pageInfo struct { - HasNextPage githubv4.Boolean - HasPreviousPage githubv4.Boolean - StartCursor githubv4.String - EndCursor githubv4.String - } - var totalCount int - + var resp MinimalIssuesResponse if queryResult, ok := issueQuery.(IssueQueryResult); ok { - fragment := queryResult.GetIssueFragment() - for _, issue := range fragment.Nodes { - issues = append(issues, fragmentToIssue(issue)) - } - pageInfo = fragment.PageInfo - totalCount = fragment.TotalCount - } - - // Create response with issues - response := map[string]any{ - "issues": issues, - "pageInfo": map[string]any{ - "hasNextPage": pageInfo.HasNextPage, - "hasPreviousPage": pageInfo.HasPreviousPage, - "startCursor": string(pageInfo.StartCursor), - "endCursor": string(pageInfo.EndCursor), - }, - "totalCount": totalCount, + resp = convertToMinimalIssuesResponse(queryResult.GetIssueFragment()) } - out, err := json.Marshal(response) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal issues: %w", err) - } - return utils.NewToolResultText(string(out)), nil, nil + + return MarshalledTextResult(resp), nil, nil }) } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index f9b8c7c62..e78a03fcb 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1187,7 +1187,6 @@ func Test_ListIssues(t *testing.T) { expectError bool errContains string expectedCount int - verifyOrder func(t *testing.T, issues []*github.Issue) }{ { name: "list all issues", @@ -1296,31 +1295,32 @@ func Test_ListIssues(t *testing.T) { require.NoError(t, err) // Parse the structured response with pagination info - var response struct { - Issues []*github.Issue `json:"issues"` - PageInfo struct { - HasNextPage bool `json:"hasNextPage"` - HasPreviousPage bool `json:"hasPreviousPage"` - StartCursor string `json:"startCursor"` - EndCursor string `json:"endCursor"` - } `json:"pageInfo"` - TotalCount int `json:"totalCount"` - } + var response MinimalIssuesResponse err = json.Unmarshal([]byte(text), &response) require.NoError(t, err) assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues)) - // Verify order if verifyOrder function is provided - if tc.verifyOrder != nil { - tc.verifyOrder(t, response.Issues) - } + // Verify pagination metadata + assert.Equal(t, tc.expectedCount, response.TotalCount) + assert.False(t, response.PageInfo.HasNextPage) + assert.False(t, response.PageInfo.HasPreviousPage) // Verify that returned issues have expected structure for _, issue := range response.Issues { - assert.NotNil(t, issue.Number, "Issue should have number") - assert.NotNil(t, issue.Title, "Issue should have title") - assert.NotNil(t, issue.State, "Issue should have state") + assert.NotZero(t, issue.Number, "Issue should have number") + assert.NotEmpty(t, issue.Title, "Issue should have title") + assert.NotEmpty(t, issue.State, "Issue should have state") + assert.NotEmpty(t, issue.CreatedAt, "Issue should have created_at") + assert.NotEmpty(t, issue.UpdatedAt, "Issue should have updated_at") + assert.NotNil(t, issue.User, "Issue should have user") + assert.NotEmpty(t, issue.User.Login, "Issue user should have login") + assert.Empty(t, issue.HTMLURL, "html_url should be empty (not populated by GraphQL fragment)") + + // Labels should be flattened to name strings + for _, label := range issue.Labels { + assert.NotEmpty(t, label, "Label should be a non-empty string") + } } }) } diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 3eabf2163..a8757c51c 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -4,6 +4,8 @@ import ( "time" "github.com/google/go-github/v82/github" + + "github.com/github/github-mcp-server/pkg/sanitize" ) // MinimalUser is the output type for user and organization search results. @@ -176,7 +178,7 @@ type MinimalIssue struct { StateReason string `json:"state_reason,omitempty"` Draft bool `json:"draft,omitempty"` Locked bool `json:"locked,omitempty"` - HTMLURL string `json:"html_url"` + HTMLURL string `json:"html_url,omitempty"` User *MinimalUser `json:"user,omitempty"` AuthorAssociation string `json:"author_association,omitempty"` Labels []string `json:"labels,omitempty"` @@ -191,6 +193,13 @@ type MinimalIssue struct { IssueType string `json:"issue_type,omitempty"` } +// MinimalIssuesResponse is the trimmed output for a paginated list of issues. +type MinimalIssuesResponse struct { + Issues []MinimalIssue `json:"issues"` + TotalCount int `json:"totalCount"` + PageInfo MinimalPageInfo `json:"pageInfo"` +} + // MinimalIssueComment is the trimmed output type for issue comment objects to reduce verbosity. type MinimalIssueComment struct { ID int64 `json:"id"` @@ -376,6 +385,45 @@ func convertToMinimalIssue(issue *github.Issue) MinimalIssue { return m } +func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue { + m := MinimalIssue{ + Number: int(fragment.Number), + Title: sanitize.Sanitize(string(fragment.Title)), + Body: sanitize.Sanitize(string(fragment.Body)), + State: string(fragment.State), + Comments: int(fragment.Comments.TotalCount), + CreatedAt: fragment.CreatedAt.Format(time.RFC3339), + UpdatedAt: fragment.UpdatedAt.Format(time.RFC3339), + User: &MinimalUser{ + Login: string(fragment.Author.Login), + }, + } + + for _, label := range fragment.Labels.Nodes { + m.Labels = append(m.Labels, string(label.Name)) + } + + return m +} + +func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesResponse { + minimalIssues := make([]MinimalIssue, 0, len(fragment.Nodes)) + for _, issue := range fragment.Nodes { + minimalIssues = append(minimalIssues, fragmentToMinimalIssue(issue)) + } + + return MinimalIssuesResponse{ + Issues: minimalIssues, + TotalCount: fragment.TotalCount, + PageInfo: MinimalPageInfo{ + HasNextPage: bool(fragment.PageInfo.HasNextPage), + HasPreviousPage: bool(fragment.PageInfo.HasPreviousPage), + StartCursor: string(fragment.PageInfo.StartCursor), + EndCursor: string(fragment.PageInfo.EndCursor), + }, + } +} + func convertToMinimalIssueComment(comment *github.IssueComment) MinimalIssueComment { m := MinimalIssueComment{ ID: comment.GetID(), @@ -650,10 +698,10 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) // MinimalPageInfo contains pagination cursor information. type MinimalPageInfo struct { - HasNextPage bool `json:"has_next_page"` - HasPreviousPage bool `json:"has_previous_page"` - StartCursor string `json:"start_cursor,omitempty"` - EndCursor string `json:"end_cursor,omitempty"` + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + StartCursor string `json:"startCursor,omitempty"` + EndCursor string `json:"endCursor,omitempty"` } // MinimalReviewComment is the trimmed output type for PR review comment objects. @@ -679,8 +727,8 @@ type MinimalReviewThread struct { // MinimalReviewThreadsResponse is the trimmed output for a paginated list of PR review threads. type MinimalReviewThreadsResponse struct { ReviewThreads []MinimalReviewThread `json:"review_threads"` - TotalCount int `json:"total_count"` - PageInfo MinimalPageInfo `json:"page_info"` + TotalCount int `json:"totalCount"` + PageInfo MinimalPageInfo `json:"pageInfo"` } func convertToMinimalPRFiles(files []*github.CommitFile) []MinimalPRFile { From 3fe6bc01d361b6acf1f8c9f996b9c9bc43fd0e89 Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Tue, 3 Mar 2026 13:55:42 +0100 Subject: [PATCH 33/57] Stricter matching for github.com and ghe.com URLs --- pkg/utils/api.go | 4 +-- pkg/utils/api_test.go | 75 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 pkg/utils/api_test.go diff --git a/pkg/utils/api.go b/pkg/utils/api.go index a22711b23..ae3a9afc3 100644 --- a/pkg/utils/api.go +++ b/pkg/utils/api.go @@ -235,11 +235,11 @@ func parseAPIHost(s string) (APIHost, error) { return APIHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s) } - if strings.HasSuffix(u.Hostname(), "github.com") { + if u.Hostname() == "github.com" || strings.HasSuffix(u.Hostname(), ".github.com") { return newDotcomHost() } - if strings.HasSuffix(u.Hostname(), "ghe.com") { + if u.Hostname() == "ghe.com" || strings.HasSuffix(u.Hostname(), ".ghe.com") { return newGHECHost(s) } diff --git a/pkg/utils/api_test.go b/pkg/utils/api_test.go new file mode 100644 index 000000000..ad7acb0b6 --- /dev/null +++ b/pkg/utils/api_test.go @@ -0,0 +1,75 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseAPIHost(t *testing.T) { + tests := []struct { + name string + input string + wantRestURL string + wantErr bool + }{ + { + name: "empty string defaults to dotcom", + input: "", + wantRestURL: "https://api.github.com/", + }, + { + name: "github.com hostname", + input: "https://github.com", + wantRestURL: "https://api.github.com/", + }, + { + name: "subdomain of github.com", + input: "https://foo.github.com", + wantRestURL: "https://api.github.com/", + }, + { + name: "hostname ending in github.com but not a subdomain", + input: "https://mycompanygithub.com", + wantRestURL: "https://mycompanygithub.com/api/v3/", + }, + { + name: "hostname ending in notgithub.com", + input: "https://notgithub.com", + wantRestURL: "https://notgithub.com/api/v3/", + }, + { + name: "ghe.com hostname", + input: "https://ghe.com", + wantRestURL: "https://api.ghe.com/", + }, + { + name: "subdomain of ghe.com", + input: "https://mycompany.ghe.com", + wantRestURL: "https://api.mycompany.ghe.com/", + }, + { + name: "hostname ending in ghe.com but not a subdomain", + input: "https://myghe.com", + wantRestURL: "https://myghe.com/api/v3/", + }, + { + name: "missing scheme", + input: "github.com", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + host, err := parseAPIHost(tc.input) + if tc.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantRestURL, host.restURL.String()) + }) + } +} From b50a343da5d03fb9454062377792a6b54631a84d Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Wed, 4 Mar 2026 09:01:15 +0100 Subject: [PATCH 34/57] Gracefully handle numeric parameters passed as strings (#2130) * Gracefully handle numeric parameters passed as strings --- pkg/github/copilot.go | 2 +- pkg/github/copilot_test.go | 109 ++++++++++++++++++++++++ pkg/github/discussions.go | 4 +- pkg/github/discussions_test.go | 105 ++++++++++++++++++++++++ pkg/github/params.go | 111 +++++++++++++++++++++---- pkg/github/params_test.go | 141 ++++++++++++++++++++++++++++++++ pkg/github/pullrequests.go | 4 +- pkg/github/pullrequests_test.go | 114 ++++++++++++++++++++++++++ pkg/utils/api_test.go | 2 +- 9 files changed, 571 insertions(+), 21 deletions(-) diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go index 525a58c69..d95357e73 100644 --- a/pkg/github/copilot.go +++ b/pkg/github/copilot.go @@ -209,7 +209,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server BaseRef string `mapstructure:"base_ref"` CustomInstructions string `mapstructure:"custom_instructions"` } - if err := mapstructure.Decode(args, ¶ms); err != nil { + if err := mapstructure.WeakDecode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } diff --git a/pkg/github/copilot_test.go b/pkg/github/copilot_test.go index 6b1e7c990..0a1d5ef3b 100644 --- a/pkg/github/copilot_test.go +++ b/pkg/github/copilot_test.go @@ -165,6 +165,115 @@ func TestAssignCopilotToIssue(t *testing.T) { ), ), }, + { + name: "successful assignment with string issue_number", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": "123", // Some MCP clients send numeric values as strings + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, { name: "successful assignment when there are existing assignees", requestArgs: map[string]any{ diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 6971bab07..700560b47 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -313,7 +313,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { Repo string DiscussionNumber int32 } - if err := mapstructure.Decode(args, ¶ms); err != nil { + if err := mapstructure.WeakDecode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetGQLClient(ctx) @@ -417,7 +417,7 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve Repo string DiscussionNumber int32 } - if err := mapstructure.Decode(args, ¶ms); err != nil { + if err := mapstructure.WeakDecode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 998a6471b..692ef2ec8 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -590,6 +590,50 @@ func Test_GetDiscussion(t *testing.T) { } } +func Test_GetDiscussionWithStringNumber(t *testing.T) { + // Test that WeakDecode handles string discussionNumber from MCP clients + toolDef := GetDiscussion(translations.NullTranslationHelper) + + qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,closed,isAnswered,answerChosenAt,url,category{name}}}}" + + vars := map[string]any{ + "owner": "owner", + "repo": "repo", + "discussionNumber": float64(1), + } + + matcher := githubv4mock.NewQueryMatcher(qGetDiscussion, vars, githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{"discussion": map[string]any{ + "number": 1, + "title": "Test Discussion Title", + "body": "This is a test discussion", + "url": "https://github.com/owner/repo/discussions/1", + "createdAt": "2025-04-25T12:00:00Z", + "closed": false, + "isAnswered": false, + "category": map[string]any{"name": "General"}, + }}, + })) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := toolDef.Handler(deps) + + // Send discussionNumber as a string instead of a number + reqParams := map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": "1"} + req := createMCPRequest(reqParams) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + + text := getTextResult(t, res).Text + require.False(t, res.IsError, "expected no error, got: %s", text) + + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(text), &out)) + assert.Equal(t, float64(1), out["number"]) + assert.Equal(t, "Test Discussion Title", out["title"]) +} + func Test_GetDiscussionComments(t *testing.T) { // Verify tool definition and schema toolDef := GetDiscussionComments(translations.NullTranslationHelper) @@ -675,6 +719,67 @@ func Test_GetDiscussionComments(t *testing.T) { } } +func Test_GetDiscussionCommentsWithStringNumber(t *testing.T) { + // Test that WeakDecode handles string discussionNumber from MCP clients + toolDef := GetDiscussionComments(translations.NullTranslationHelper) + + qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + + vars := map[string]any{ + "owner": "owner", + "repo": "repo", + "discussionNumber": float64(1), + "first": float64(30), + "after": (*string)(nil), + } + + mockResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "comments": map[string]any{ + "nodes": []map[string]any{ + {"body": "First comment"}, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 1, + }, + }, + }, + }) + matcher := githubv4mock.NewQueryMatcher(qGetComments, vars, mockResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := toolDef.Handler(deps) + + // Send discussionNumber as a string instead of a number + reqParams := map[string]any{ + "owner": "owner", + "repo": "repo", + "discussionNumber": "1", + } + request := createMCPRequest(reqParams) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + require.False(t, result.IsError, "expected no error, got: %s", textContent.Text) + + var out struct { + Comments []map[string]any `json:"comments"` + TotalCount int `json:"totalCount"` + } + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &out)) + assert.Len(t, out.Comments, 1) + assert.Equal(t, "First comment", out.Comments[0]["body"]) +} + func Test_ListDiscussionCategories(t *testing.T) { toolDef := ListDiscussionCategories(translations.NullTranslationHelper) tool := toolDef.Tool diff --git a/pkg/github/params.go b/pkg/github/params.go index 0dac1773f..1b45d61bd 100644 --- a/pkg/github/params.go +++ b/pkg/github/params.go @@ -3,6 +3,7 @@ package github import ( "errors" "fmt" + "math" "strconv" "github.com/google/go-github/v82/github" @@ -39,6 +40,66 @@ func isAcceptedError(err error) bool { return errors.As(err, &acceptedError) } +// toInt converts a value to int, handling both float64 and string representations. +// Some MCP clients send numeric values as strings. It rejects NaN, ±Inf, +// fractional values, and values outside the int range. +func toInt(val any) (int, error) { + var f float64 + switch v := val.(type) { + case float64: + f = v + case string: + var err error + f, err = strconv.ParseFloat(v, 64) + if err != nil { + return 0, fmt.Errorf("invalid numeric value: %s", v) + } + default: + return 0, fmt.Errorf("expected number, got %T", val) + } + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0, fmt.Errorf("non-finite numeric value") + } + if f != math.Trunc(f) { + return 0, fmt.Errorf("non-integer numeric value: %v", f) + } + if f > math.MaxInt || f < math.MinInt { + return 0, fmt.Errorf("numeric value out of int range: %v", f) + } + return int(f), nil +} + +// toInt64 converts a value to int64, handling both float64 and string representations. +// Some MCP clients send numeric values as strings. It rejects NaN, ±Inf, +// fractional values, and values that lose precision in the float64→int64 conversion. +func toInt64(val any) (int64, error) { + var f float64 + switch v := val.(type) { + case float64: + f = v + case string: + var err error + f, err = strconv.ParseFloat(v, 64) + if err != nil { + return 0, fmt.Errorf("invalid numeric value: %s", v) + } + default: + return 0, fmt.Errorf("expected number, got %T", val) + } + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0, fmt.Errorf("non-finite numeric value") + } + if f != math.Trunc(f) { + return 0, fmt.Errorf("non-integer numeric value: %v", f) + } + result := int64(f) + // Check round-trip to detect precision loss for large int64 values + if float64(result) != f { + return 0, fmt.Errorf("numeric value %v is too large to fit in int64", f) + } + return result, nil +} + // RequiredParam is a helper function that can be used to fetch a requested parameter from the request. // It does the following checks: // 1. Checks if the parameter is present in the request. @@ -68,33 +129,47 @@ func RequiredParam[T comparable](args map[string]any, p string) (T, error) { // RequiredInt is a helper function that can be used to fetch a requested parameter from the request. // It does the following checks: // 1. Checks if the parameter is present in the request. -// 2. Checks if the parameter is of the expected type. +// 2. Checks if the parameter is of the expected type (float64 or numeric string). // 3. Checks if the parameter is not empty, i.e: non-zero value func RequiredInt(args map[string]any, p string) (int, error) { - v, err := RequiredParam[float64](args, p) + v, ok := args[p] + if !ok { + return 0, fmt.Errorf("missing required parameter: %s", p) + } + + result, err := toInt(v) if err != nil { - return 0, err + return 0, fmt.Errorf("parameter %s is not a valid number: %w", p, err) + } + + if result == 0 { + return 0, fmt.Errorf("missing required parameter: %s", p) } - return int(v), nil + + return result, nil } // RequiredBigInt is a helper function that can be used to fetch a requested parameter from the request. // It does the following checks: // 1. Checks if the parameter is present in the request. -// 2. Checks if the parameter is of the expected type (float64). +// 2. Checks if the parameter is of the expected type (float64 or numeric string). // 3. Checks if the parameter is not empty, i.e: non-zero value. // 4. Validates that the float64 value can be safely converted to int64 without truncation. func RequiredBigInt(args map[string]any, p string) (int64, error) { - v, err := RequiredParam[float64](args, p) + val, ok := args[p] + if !ok { + return 0, fmt.Errorf("missing required parameter: %s", p) + } + + result, err := toInt64(val) if err != nil { - return 0, err + return 0, fmt.Errorf("parameter %s is not a valid number: %w", p, err) } - result := int64(v) - // Check if converting back produces the same value to avoid silent truncation - if float64(result) != v { - return 0, fmt.Errorf("parameter %s value %f is too large to fit in int64", p, v) + if result == 0 { + return 0, fmt.Errorf("missing required parameter: %s", p) } + return result, nil } @@ -121,13 +196,19 @@ func OptionalParam[T any](args map[string]any, p string) (T, error) { // OptionalIntParam is a helper function that can be used to fetch a requested parameter from the request. // It does the following checks: // 1. Checks if the parameter is present in the request, if not, it returns its zero-value -// 2. If it is present, it checks if the parameter is of the expected type and returns it +// 2. If it is present, it checks if the parameter is of the expected type (float64 or numeric string) and returns it func OptionalIntParam(args map[string]any, p string) (int, error) { - v, err := OptionalParam[float64](args, p) + val, ok := args[p] + if !ok { + return 0, nil + } + + result, err := toInt(val) if err != nil { - return 0, err + return 0, fmt.Errorf("parameter %s is not a valid number: %w", p, err) } - return int(v), nil + + return result, nil } // OptionalIntParamWithDefault is a helper function that can be used to fetch a requested parameter from the request diff --git a/pkg/github/params_test.go b/pkg/github/params_test.go index 5c989d55a..2254b737e 100644 --- a/pkg/github/params_test.go +++ b/pkg/github/params_test.go @@ -2,6 +2,7 @@ package github import ( "fmt" + "math" "testing" "github.com/google/go-github/v82/github" @@ -163,6 +164,13 @@ func Test_RequiredInt(t *testing.T) { expected: 42, expectError: false, }, + { + name: "valid string number parameter", + params: map[string]any{"count": "42"}, + paramName: "count", + expected: 42, + expectError: false, + }, { name: "missing parameter", params: map[string]any{}, @@ -170,6 +178,13 @@ func Test_RequiredInt(t *testing.T) { expected: 0, expectError: true, }, + { + name: "zero string parameter", + params: map[string]any{"count": "0"}, + paramName: "count", + expected: 0, + expectError: true, + }, { name: "wrong type parameter", params: map[string]any{"count": "not-a-number"}, @@ -177,6 +192,69 @@ func Test_RequiredInt(t *testing.T) { expected: 0, expectError: true, }, + { + name: "boolean type parameter", + params: map[string]any{"count": true}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "NaN string", + params: map[string]any{"count": "NaN"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "Inf string", + params: map[string]any{"count": "Inf"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "negative Inf string", + params: map[string]any{"count": "-Inf"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "fractional string", + params: map[string]any{"count": "1.5"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "fractional float64", + params: map[string]any{"count": float64(1.5)}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "NaN float64", + params: map[string]any{"count": math.NaN()}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "Inf float64", + params: map[string]any{"count": math.Inf(1)}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "MaxFloat64", + params: map[string]any{"count": math.MaxFloat64}, + paramName: "count", + expected: 0, + expectError: true, + }, } for _, tc := range tests { @@ -207,6 +285,13 @@ func Test_OptionalIntParam(t *testing.T) { expected: 42, expectError: false, }, + { + name: "valid string number parameter", + params: map[string]any{"count": "42"}, + paramName: "count", + expected: 42, + expectError: false, + }, { name: "missing parameter", params: map[string]any{}, @@ -221,6 +306,13 @@ func Test_OptionalIntParam(t *testing.T) { expected: 0, expectError: false, }, + { + name: "zero string value", + params: map[string]any{"count": "0"}, + paramName: "count", + expected: 0, + expectError: false, + }, { name: "wrong type parameter", params: map[string]any{"count": "not-a-number"}, @@ -228,6 +320,27 @@ func Test_OptionalIntParam(t *testing.T) { expected: 0, expectError: true, }, + { + name: "NaN string", + params: map[string]any{"count": "NaN"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "fractional string", + params: map[string]any{"count": "1.5"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "fractional float64", + params: map[string]any{"count": float64(1.5)}, + paramName: "count", + expected: 0, + expectError: true, + }, } for _, tc := range tests { @@ -261,6 +374,14 @@ func Test_OptionalNumberParamWithDefault(t *testing.T) { expected: 42, expectError: false, }, + { + name: "valid string number parameter", + params: map[string]any{"count": "42"}, + paramName: "count", + defaultVal: 10, + expected: 42, + expectError: false, + }, { name: "missing parameter", params: map[string]any{}, @@ -277,6 +398,14 @@ func Test_OptionalNumberParamWithDefault(t *testing.T) { expected: 10, expectError: false, }, + { + name: "zero string value uses default", + params: map[string]any{"count": "0"}, + paramName: "count", + defaultVal: 10, + expected: 10, + expectError: false, + }, { name: "wrong type parameter", params: map[string]any{"count": "not-a-number"}, @@ -486,6 +615,18 @@ func TestOptionalPaginationParams(t *testing.T) { expected: PaginationParams{}, expectError: true, }, + { + name: "string page and perPage parameters", + params: map[string]any{ + "page": "3", + "perPage": "25", + }, + expected: PaginationParams{ + Page: 3, + PerPage: 25, + }, + expectError: false, + }, } for _, tc := range tests { diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 7eb9c6d64..e5e0855ea 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1570,7 +1570,7 @@ Available methods: []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params PullRequestReviewWriteParams - if err := mapstructure.Decode(args, ¶ms); err != nil { + if err := mapstructure.WeakDecode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -1905,7 +1905,7 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S StartLine *int32 StartSide *string } - if err := mapstructure.Decode(args, ¶ms); err != nil { + if err := mapstructure.WeakDecode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 4bb31e541..537577329 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2470,6 +2470,61 @@ func TestCreateAndSubmitPullRequestReview(t *testing.T) { }, expectToolError: false, }, + { + name: "successful review creation with string pullNumber", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + } `graphql:"pullRequest(number: $prNum)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + }, + githubv4mock.DataResponse( + map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "id": "PR_kwDODKw3uc6WYN1T", + }, + }, + }, + ), + ), + githubv4mock.NewMutationMatcher( + struct { + AddPullRequestReview struct { + PullRequestReview struct { + ID githubv4.ID + } + } `graphql:"addPullRequestReview(input: $input)"` + }{}, + githubv4.AddPullRequestReviewInput{ + PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"), + Body: githubv4.NewString("This is a test review"), + Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment), + CommitOID: githubv4.NewGitObjectID("abcd1234"), + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "pullNumber": "42", // Some MCP clients send numeric values as strings + "body": "This is a test review", + "event": "COMMENT", + "commitID": "abcd1234", + }, + expectToolError: false, + }, { name: "failure to get pull request", mockedClient: githubv4mock.NewMockedHTTPClient( @@ -2873,6 +2928,65 @@ func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { ), ), }, + { + name: "successful line comment with string pullNumber and line", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": "42", // Some MCP clients send numeric values as strings + "path": "file.go", + "body": "This is a test comment", + "subjectType": "LINE", + "line": "10", // string line number + "side": "RIGHT", + "startLine": "5", // string startLine + "startSide": "RIGHT", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + viewerQuery("williammartin"), + getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{ + author: "williammartin", + owner: "owner", + repo: "repo", + prNum: 42, + + reviews: []getLatestPendingReviewQueryReview{ + { + id: "PR_kwDODKw3uc6WYN1T", + state: "PENDING", + url: "https://github.com/owner/repo/pull/42", + }, + }, + }), + githubv4mock.NewMutationMatcher( + struct { + AddPullRequestReviewThread struct { + Thread struct { + ID githubv4.String + } + } `graphql:"addPullRequestReviewThread(input: $input)"` + }{}, + githubv4.AddPullRequestReviewThreadInput{ + Path: githubv4.String("file.go"), + Body: githubv4.String("This is a test comment"), + SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine), + Line: githubv4.NewInt(10), + Side: githubv4mock.Ptr(githubv4.DiffSideRight), + StartLine: githubv4.NewInt(5), + StartSide: githubv4mock.Ptr(githubv4.DiffSideRight), + PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addPullRequestReviewThread": map[string]any{ + "thread": map[string]any{ + "id": "MDEyOlB1bGxSZXF1ZXN0UmV2aWV3VGhyZWFkMTIzNDU2", + }, + }, + }), + ), + ), + }, { name: "thread ID is nil - invalid line number", requestArgs: map[string]any{ diff --git a/pkg/utils/api_test.go b/pkg/utils/api_test.go index ad7acb0b6..40fcb8f26 100644 --- a/pkg/utils/api_test.go +++ b/pkg/utils/api_test.go @@ -1,4 +1,4 @@ -package utils +package utils //nolint:revive //TODO: figure out a better name for this package import ( "testing" From ccb9b5308d705465d4619164083667b720df7279 Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Wed, 4 Mar 2026 14:34:49 +0100 Subject: [PATCH 35/57] Fix SHA validation in create_or_update_file (#2134) * Fix SHA validation in create_or_update_file * Doc update * Handle non-404 errors * Handle directory paths * Update instructions --- README.md | 2 +- .../__toolsnaps__/create_or_update_file.snap | 4 +- pkg/github/repositories.go | 119 +++++++++--------- pkg/github/repositories_test.go | 108 +++++++--------- 4 files changed, 105 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index f83989a1e..1b926b132 100644 --- a/README.md +++ b/README.md @@ -1171,7 +1171,7 @@ The following sets of tools are available: - `owner`: Repository owner (username or organization) (string, required) - `path`: Path where to create/update the file (string, required) - `repo`: Repository name (string, required) - - `sha`: The blob SHA of the file being replaced. (string, optional) + - `sha`: The blob SHA of the file being replaced. Required if the file already exists. (string, optional) - **create_repository** - Create repository - **Required OAuth Scopes**: `repo` diff --git a/pkg/github/__toolsnaps__/create_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap index 9d28c8085..e6900c905 100644 --- a/pkg/github/__toolsnaps__/create_or_update_file.snap +++ b/pkg/github/__toolsnaps__/create_or_update_file.snap @@ -2,7 +2,7 @@ "annotations": { "title": "Create or update file" }, - "description": "Create or update a single file in a GitHub repository. \nIf updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.\n\nIn order to obtain the SHA of original file version before updating, use the following git command:\ngit ls-tree HEAD \u003cpath to file\u003e\n\nIf the SHA is not provided, the tool will attempt to acquire it by fetching the current file contents from the repository, which may lead to rewriting latest committed changes if the file has changed since last retrieval.\n", + "description": "Create or update a single file in a GitHub repository. \nIf updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.\n\nIn order to obtain the SHA of original file version before updating, use the following git command:\ngit rev-parse \u003cbranch\u003e:\u003cpath to file\u003e\n\nSHA MUST be provided for existing file updates.\n", "inputSchema": { "properties": { "branch": { @@ -30,7 +30,7 @@ "type": "string" }, "sha": { - "description": "The blob SHA of the file being replaced.", + "description": "The blob SHA of the file being replaced. Required if the file already exists.", "type": "string" } }, diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index a236609bc..6eab707f9 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "net/http" - "net/url" "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -323,9 +322,9 @@ func CreateOrUpdateFile(t translations.TranslationHelperFunc) inventory.ServerTo If updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations. In order to obtain the SHA of original file version before updating, use the following git command: -git ls-tree HEAD +git rev-parse : -If the SHA is not provided, the tool will attempt to acquire it by fetching the current file contents from the repository, which may lead to rewriting latest committed changes if the file has changed since last retrieval. +SHA MUST be provided for existing file updates. `), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"), @@ -360,7 +359,7 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the }, "sha": { Type: "string", - Description: "The blob SHA of the file being replaced.", + Description: "The blob SHA of the file being replaced. Required if the file already exists.", }, }, Required: []string{"owner", "repo", "path", "content", "message", "branch"}, @@ -420,55 +419,68 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the path = strings.TrimPrefix(path, "/") - // SHA validation using conditional HEAD request (efficient - no body transfer) - var previousSHA string - contentURL := fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, url.PathEscape(path)) - if branch != "" { - contentURL += "?ref=" + url.QueryEscape(branch) - } + // SHA validation using Contents API to fetch current file metadata (blob SHA) + getOpts := &github.RepositoryContentGetOptions{Ref: branch} if sha != "" { // User provided SHA - validate it's still current - req, err := client.NewRequest("HEAD", contentURL, nil) - if err == nil { - req.Header.Set("If-None-Match", fmt.Sprintf(`"%s"`, sha)) - resp, _ := client.Do(ctx, req, nil) - if resp != nil { - defer resp.Body.Close() - - switch resp.StatusCode { - case http.StatusNotModified: - // SHA matches current - proceed - opts.SHA = github.Ptr(sha) - case http.StatusOK: - // SHA is stale - reject with current SHA so user can check diff - currentSHA := strings.Trim(resp.Header.Get("ETag"), `"`) - return utils.NewToolResultError(fmt.Sprintf( - "SHA mismatch: provided SHA %s is stale. Current file SHA is %s. "+ - "Use get_file_contents or compare commits to review changes before updating.", - sha, currentSHA)), nil, nil - case http.StatusNotFound: - // File doesn't exist - this is a create, ignore provided SHA - } + existingFile, dirContent, respCheck, getErr := client.Repositories.GetContents(ctx, owner, repo, path, getOpts) + if respCheck != nil { + _ = respCheck.Body.Close() + } + switch { + case getErr != nil: + // 404 means file doesn't exist - proceed (new file creation) + // Any other error (403, 500, network) should be surfaced + if respCheck == nil || respCheck.StatusCode != http.StatusNotFound { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to verify file SHA", + respCheck, + getErr, + ), nil, nil + } + case dirContent != nil: + return utils.NewToolResultError(fmt.Sprintf( + "Path %s is a directory, not a file. This tool only works with files.", + path)), nil, nil + case existingFile != nil: + currentSHA := existingFile.GetSHA() + if currentSHA != sha { + return utils.NewToolResultError(fmt.Sprintf( + "SHA mismatch: provided SHA %s is stale. Current file SHA is %s. "+ + "Pull the latest changes and use git rev-parse %s:%s to get the current SHA.", + sha, currentSHA, branch, path)), nil, nil } } } else { - // No SHA provided - check if file exists to warn about blind update - req, err := client.NewRequest("HEAD", contentURL, nil) - if err == nil { - resp, _ := client.Do(ctx, req, nil) - if resp != nil { - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { - previousSHA = strings.Trim(resp.Header.Get("ETag"), `"`) - } - // 404 = new file, no previous SHA needed + // No SHA provided - check if file already exists + existingFile, dirContent, respCheck, getErr := client.Repositories.GetContents(ctx, owner, repo, path, getOpts) + if respCheck != nil { + _ = respCheck.Body.Close() + } + switch { + case getErr != nil: + // 404 means file doesn't exist - proceed with creation + // Any other error (403, 500, network) should be surfaced + if respCheck == nil || respCheck.StatusCode != http.StatusNotFound { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to check if file exists", + respCheck, + getErr, + ), nil, nil } + case dirContent != nil: + return utils.NewToolResultError(fmt.Sprintf( + "Path %s is a directory, not a file. This tool only works with files.", + path)), nil, nil + case existingFile != nil: + // File exists but no SHA was provided - reject to prevent blind overwrites + return utils.NewToolResultError(fmt.Sprintf( + "File already exists at %s. You must provide the current file's SHA when updating. "+ + "Use git rev-parse %s:%s to get the blob SHA, then retry with the sha parameter.", + path, branch, path)), nil, nil } - } - - if previousSHA != "" { - opts.SHA = github.Ptr(previousSHA) + // If file not found, no previous SHA needed (new file creation) } fileContent, resp, err := client.Repositories.CreateFile(ctx, owner, repo, path, opts) @@ -491,23 +503,6 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the minimalResponse := convertToMinimalFileContentResponse(fileContent) - // Warn if file was updated without SHA validation (blind update) - if sha == "" && previousSHA != "" { - warning, err := json.Marshal(minimalResponse) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - return utils.NewToolResultText(fmt.Sprintf( - "Warning: File updated without SHA validation. Previous file SHA was %s. "+ - `Verify no unintended changes were overwritten: -1. Extract the SHA of the local version using git ls-tree HEAD %s. -2. Compare with the previous SHA above. -3. Revert changes if shas do not match. - -%s`, - previousSHA, path, string(warning))), nil, nil - } - return MarshalledTextResult(minimalResponse), nil, nil }, ) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index fdb5780c2..a27eef5e1 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1157,6 +1157,14 @@ func Test_CreateOrUpdateFile(t *testing.T) { { name: "successful file update with SHA", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ "message": "Update example file", "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content @@ -1210,26 +1218,16 @@ func Test_CreateOrUpdateFile(t *testing.T) { expectedErrMsg: "failed to create/update file", }, { - name: "sha validation - current sha matches (304 Not Modified)", + name: "sha validation - current sha matches", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, req *http.Request) { - ifNoneMatch := req.Header.Get("If-None-Match") - if ifNoneMatch == `"abc123def456"` { - w.WriteHeader(http.StatusNotModified) - } else { - w.WriteHeader(http.StatusOK) - w.Header().Set("ETag", `"abc123def456"`) - } - }, - "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, req *http.Request) { - ifNoneMatch := req.Header.Get("If-None-Match") - if ifNoneMatch == `"abc123def456"` { - w.WriteHeader(http.StatusNotModified) - } else { - w.WriteHeader(http.StatusOK) - w.Header().Set("ETag", `"abc123def456"`) - } - }, + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ "message": "Update example file", "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", @@ -1260,16 +1258,16 @@ func Test_CreateOrUpdateFile(t *testing.T) { expectedContent: mockFileResponse, }, { - name: "sha validation - stale sha detected (200 OK with different ETag)", + name: "sha validation - stale sha detected", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("ETag", `"newsha999888"`) - w.WriteHeader(http.StatusOK) - }, - "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("ETag", `"newsha999888"`) - w.WriteHeader(http.StatusOK) - }, + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("newsha999888"), + Type: github.Ptr("file"), + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("newsha999888"), + Type: github.Ptr("file"), + }), }), requestArgs: map[string]any{ "owner": "owner", @@ -1286,7 +1284,10 @@ func Test_CreateOrUpdateFile(t *testing.T) { { name: "sha validation - file doesn't exist (404), proceed with create", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + "GET /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + "GET /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }, PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ @@ -1297,9 +1298,6 @@ func Test_CreateOrUpdateFile(t *testing.T) { }).andThen( mockResponse(t, http.StatusCreated, mockFileResponse), ), - "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - }, "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{ "message": "Create new file", "content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==", @@ -1322,32 +1320,16 @@ func Test_CreateOrUpdateFile(t *testing.T) { expectedContent: mockFileResponse, }, { - name: "no sha provided - file exists, returns warning", + name: "no sha provided - file exists, rejects update", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("ETag", `"existing123"`) - w.WriteHeader(http.StatusOK) - }, - PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ - "message": "Update without SHA", - "content": "IyBVcGRhdGVkCgpVcGRhdGVkIHdpdGhvdXQgU0hBLg==", - "branch": "main", - "sha": "existing123", // SHA is automatically added from ETag - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("ETag", `"existing123"`) - w.WriteHeader(http.StatusOK) - }, - "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{ - "message": "Update without SHA", - "content": "IyBVcGRhdGVkCgpVcGRhdGVkIHdpdGhvdXQgU0hBLg==", - "branch": "main", - "sha": "existing123", // SHA is automatically added from ETag - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("existing123"), + Type: github.Ptr("file"), + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("existing123"), + Type: github.Ptr("file"), + }), }), requestArgs: map[string]any{ "owner": "owner", @@ -1357,13 +1339,16 @@ func Test_CreateOrUpdateFile(t *testing.T) { "message": "Update without SHA", "branch": "main", }, - expectError: false, - expectedErrMsg: "Warning: File updated without SHA validation. Previous file SHA was existing123", + expectError: true, + expectedErrMsg: "File already exists at docs/example.md", }, { name: "no sha provided - file doesn't exist, no warning", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + "GET /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + "GET /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }, PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ @@ -1373,9 +1358,6 @@ func Test_CreateOrUpdateFile(t *testing.T) { }).andThen( mockResponse(t, http.StatusCreated, mockFileResponse), ), - "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - }, "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{ "message": "Create new file", "content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==", From c79439e0e32c84333515701e825b196460419f4e Mon Sep 17 00:00:00 2001 From: Jakub Janusz <32165716+kubajanusz@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:43:41 +0100 Subject: [PATCH 36/57] fix: handle empty files in get_file_contents (#2042) 1. Empty (0-byte) files caused an unhandled error because the GitHub API returns null content with base64 encoding for them; GetContent() fails with "malformed response: base64 encoding of null content". Return empty text/plain content directly, bypassing decoding entirely. Co-authored-by: Ksenia Bobrova --- pkg/github/repositories.go | 14 ++++++++++++++ pkg/github/repositories_test.go | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 6eab707f9..9376ddad4 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -726,6 +726,20 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool successNote = fmt.Sprintf(" Note: the provided ref '%s' does not exist, default branch '%s' was used instead.", originalRef, rawOpts.Ref) } + // Empty files (0 bytes) have no content to decode; return + // them directly as empty text to avoid errors from + // GetContent when the API returns null content with a + // base64 encoding field, and to avoid DetectContentType + // misclassifying them as binary. + if fileSize == 0 { + result := &mcp.ResourceContents{ + URI: resourceURI, + Text: "", + MIMEType: "text/plain", + } + return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result), nil, nil + } + // For files >= 1MB, return a ResourceLink instead of content const maxContentSize = 1024 * 1024 // 1MB if fileSize >= maxContentSize { diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index a27eef5e1..ae2ece0f6 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -351,6 +351,40 @@ func Test_GetFileContents(t *testing.T) { Title: "File: large-file.bin", }, }, + { + name: "successful empty file content fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr(".gitkeep"), + Path: github.Ptr(".gitkeep"), + SHA: github.Ptr("empty123"), + Type: github.Ptr("file"), + Content: nil, + Size: github.Ptr(0), + Encoding: github.Ptr("base64"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": ".gitkeep", + "ref": "refs/heads/main", + }, + expectError: false, + expectedResult: mcp.ResourceContents{ + URI: "repo://owner/repo/refs/heads/main/contents/.gitkeep", + Text: "", + MIMEType: "text/plain", + }, + expectedMsg: "successfully downloaded empty file", + }, { name: "content fetch fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ From c36d792eb7046456c0cd997cabb92c49fbdc45ba Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Thu, 5 Mar 2026 11:56:45 +0100 Subject: [PATCH 37/57] Cline & Roo code installation guides (#2146) * Cline & Roo code installation guides * Update docs/installation-guides/install-cline.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/installation-guides/README.md | 4 ++ docs/installation-guides/install-cline.md | 56 +++++++++++++++++++ docs/installation-guides/install-roo-code.md | 58 ++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 docs/installation-guides/install-cline.md create mode 100644 docs/installation-guides/install-roo-code.md diff --git a/docs/installation-guides/README.md b/docs/installation-guides/README.md index 4a300e3f4..ab3aede36 100644 --- a/docs/installation-guides/README.md +++ b/docs/installation-guides/README.md @@ -7,9 +7,11 @@ This directory contains detailed installation instructions for the GitHub MCP Se - **[GitHub Copilot in other IDEs](install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot - **[Antigravity](install-antigravity.md)** - Installation for Google Antigravity IDE - **[Claude Applications](install-claude.md)** - Installation guide for Claude Web, Claude Desktop and Claude Code CLI +- **[Cline](install-cline.md)** - Installation guide for Cline - **[Cursor](install-cursor.md)** - Installation guide for Cursor IDE - **[Google Gemini CLI](install-gemini-cli.md)** - Installation guide for Google Gemini CLI - **[OpenAI Codex](install-codex.md)** - Installation guide for OpenAI Codex +- **[Roo Code](install-roo-code.md)** - Installation guide for Roo Code - **[Windsurf](install-windsurf.md)** - Installation guide for Windsurf IDE ## Support by Host Application @@ -23,8 +25,10 @@ This directory contains detailed installation instructions for the GitHub MCP Se | Copilot in JetBrains | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: JetBrains Copilot Extension v1.5.53+ | Easy | | Claude Code | ✅ | ✅ PAT + ❌ No OAuth| GitHub MCP Server binary or remote URL, GitHub PAT | Easy | | Claude Desktop | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Moderate | +| Cline | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Cursor | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Google Gemini CLI | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | +| Roo Code | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Windsurf | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Copilot in Xcode | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: Copilot for Xcode 0.41.0+ | Easy | | Copilot in Eclipse | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: Eclipse Plug-in for Copilot 0.10.0+ | Easy | diff --git a/docs/installation-guides/install-cline.md b/docs/installation-guides/install-cline.md new file mode 100644 index 000000000..6bc643cb6 --- /dev/null +++ b/docs/installation-guides/install-cline.md @@ -0,0 +1,56 @@ +# Install GitHub MCP Server in Cline + +[Cline](https://github.com/cline/cline) is an AI coding assistant that runs in VS Code-compatible editors (VS Code, Cursor, Windsurf, etc.). For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md). + +## Remote Server + +Cline stores MCP settings in `cline_mcp_settings.json`. To edit it, click the Cline icon in your editor's sidebar, open the menu in the top right corner of the Cline panel, and select **"MCP Servers"**. You can add a remote server through the **"Remote Servers"** tab, or click **"Configure MCP Servers"** to edit the JSON directly. + +```json +{ + "mcpServers": { + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "type": "streamableHttp", + "disabled": false, + "headers": { + "Authorization": "Bearer " + }, + "autoApprove": [] + } + } +} +``` + +Replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens). To customize toolsets, add server-side headers like `X-MCP-Toolsets` or `X-MCP-Readonly` to the `headers` object — see [Server Configuration Guide](../server-configuration.md). + +> **Important:** The transport type must be `"streamableHttp"` (camelCase, no hyphen). Using `"streamable-http"` or omitting the type will cause Cline to fall back to SSE, resulting in a `405` error. + +## Local Server (Docker) + +1. Click the Cline icon in your editor's sidebar (or open the command palette and search for "Cline"), then click the **MCP Servers** icon (server stack icon at the top of the Cline panel), and click **"Configure MCP Servers"** to open `cline_mcp_settings.json`. +2. Add the configuration below, replacing `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens). + +```json +{ + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT" + } + } + } +} +``` + +## Troubleshooting + +- **SSE error 405 with remote server**: Ensure `"type"` is set to `"streamableHttp"` (camelCase, no hyphen) in `cline_mcp_settings.json`. Using `"streamable-http"` or omitting `"type"` causes Cline to fall back to SSE, which this server does not support. +- **Authentication failures**: Verify your PAT has the required scopes +- **Docker issues**: Ensure Docker Desktop is installed and running diff --git a/docs/installation-guides/install-roo-code.md b/docs/installation-guides/install-roo-code.md new file mode 100644 index 000000000..77513fb55 --- /dev/null +++ b/docs/installation-guides/install-roo-code.md @@ -0,0 +1,58 @@ +# Install GitHub MCP Server in Roo Code + +[Roo Code](https://github.com/RooCodeInc/Roo-Code) is an AI coding assistant that runs in VS Code-compatible editors (VS Code, Cursor, Windsurf, etc.). For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md). + +## Remote Server + +### Step-by-step setup + +1. Click the **Roo Code icon** in your editor's sidebar to open the Roo Code pane +2. Click the **gear icon** (⚙️) in the top navigation of the Roo Code pane, then click on **"MCP Servers"** icon on the left. +3. Scroll to the bottom and click **"Edit Global MCP"** (for all projects) or **"Edit Project MCP"** (for the current project only) +4. Add the configuration below to the opened file (`mcp_settings.json` or `.roo/mcp.json`) +5. Replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens) +6. Save the file — the server should connect automatically + +```json +{ + "mcpServers": { + "github": { + "type": "streamable-http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } +} +``` + +> **Important:** The `type` must be `"streamable-http"` (with hyphen). Using `"http"` or omitting the type will fail. + +To customize toolsets, add server-side headers like `X-MCP-Toolsets` or `X-MCP-Readonly` to the `headers` object — see [Server Configuration Guide](../server-configuration.md). + +## Local Server (Docker) + +```json +{ + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT" + } + } + } +} +``` + +## Troubleshooting + +- **Connection failures**: Ensure `type` is `streamable-http`, not `http` +- **Authentication failures**: Verify PAT is prefixed with `Bearer ` in the `Authorization` header +- **Docker issues**: Ensure Docker Desktop is running From f58208394a9e6ed12c1069f234f567d90c395bfa Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Thu, 5 Mar 2026 15:30:54 +0100 Subject: [PATCH 38/57] Correctly wrap GraphQl error (#2149) --- pkg/github/issues.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index b5bc4ebb8..9709a852c 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -62,7 +62,7 @@ func fetchIssueIDs(ctx context.Context, gqlClient *githubv4.Client, owner, repo } if err := gqlClient.Query(ctx, &query, vars); err != nil { - return "", "", fmt.Errorf("failed to get issue ID") + return "", "", fmt.Errorf("failed to get issue ID: %w", err) } return query.Repository.Issue.ID, "", nil @@ -84,7 +84,7 @@ func fetchIssueIDs(ctx context.Context, gqlClient *githubv4.Client, owner, repo vars["duplicateOf"] = githubv4.Int(duplicateOf) // #nosec G115 - issue numbers are always small positive integers if err := gqlClient.Query(ctx, &query, vars); err != nil { - return "", "", fmt.Errorf("failed to get issue ID") + return "", "", fmt.Errorf("failed to get issue ID: %w", err) } return query.Repository.Issue.ID, query.Repository.DuplicateIssue.ID, nil From 7848af8f929d847fff2c2823dd208fe0b93e63ea Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 6 Mar 2026 12:57:00 +0100 Subject: [PATCH 39/57] Add JavaScript support to code scanning workflow (#2157) --- .github/workflows/code-scanning.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index 453a7b7e6..e58a45e71 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -35,6 +35,10 @@ jobs: category: /language:go build-mode: autobuild runner: '["ubuntu-22.04"]' + - language: javascript + category: /language:javascript + build-mode: none + runner: '["ubuntu-22.04"]' steps: - name: Checkout repository uses: actions/checkout@v6 @@ -75,7 +79,7 @@ jobs: cache: false - name: Set up Node.js - if: matrix.language == 'go' + if: matrix.language == 'go' || matrix.language == 'javascript' uses: actions/setup-node@v4 with: node-version: "20" From 50a04616eaccc32dfa1a648dacdbfe39a7e8562e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:04:08 +0000 Subject: [PATCH 40/57] Skip MCP Apps UI form when update includes a state change When using issue_write with method "update" and a state parameter (e.g. "closed"), the MCP Apps UI form was incorrectly shown. The form only handles title/body editing and would lose the state transition. Now when a state change is requested, the UI form is skipped and the update executes directly. Fixes github/github-mcp-server#798 Co-authored-by: mattdholloway <918573+mattdholloway@users.noreply.github.com> --- pkg/github/issues.go | 17 ++++-- pkg/github/issues_test.go | 106 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 9709a852c..980c355df 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1080,13 +1080,20 @@ Options are: if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(ctx, req) && !uiSubmitted { if method == "update" { - issueNumber, numErr := RequiredInt(args, "issue_number") - if numErr != nil { - return utils.NewToolResultError("issue_number is required for update method"), nil, nil + // Skip the UI form when a state change is requested because + // the form only handles title/body editing and would lose the + // state transition (e.g. closing or reopening the issue). + state, _ := OptionalParam[string](args, "state") + if state == "" { + issueNumber, numErr := RequiredInt(args, "issue_number") + if numErr != nil { + return utils.NewToolResultError("issue_number is required for update method"), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil + } else { + return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil } title, err := OptionalParam[string](args, "title") diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index e78a03fcb..d06721be7 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1000,6 +1000,112 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", "non-UI client should execute directly") }) + + t.Run("UI client with state change skips form and executes directly", func(t *testing.T) { + mockBaseIssue := &github.Issue{ + Number: github.Ptr(1), + Title: github.Ptr("Test"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/1"), + } + issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": "I_kwDOA0xdyM50BPaO", + }, + }, + }) + closeSuccessResponse := githubv4mock.DataResponse(map[string]any{ + "closeIssue": map[string]any{ + "issue": map[string]any{ + "id": "I_kwDOA0xdyM50BPaO", + "number": 1, + "url": "https://github.com/owner/repo/issues/1", + "state": "CLOSED", + }, + }, + }) + completedReason := IssueClosedStateReasonCompleted + + closeClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + })) + closeGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(1), + }, + issueIDQueryResponse, + ), + githubv4mock.NewMutationMatcher( + struct { + CloseIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + State githubv4.String + } + } `graphql:"closeIssue(input: $input)"` + }{}, + CloseIssueInput{ + IssueID: "I_kwDOA0xdyM50BPaO", + StateReason: &completedReason, + }, + nil, + closeSuccessResponse, + ), + )) + + closeDeps := BaseDeps{ + Client: closeClient, + GQLClient: closeGQLClient, + Flags: FeatureFlags{InsidersMode: true}, + } + closeHandler := serverTool.Handler(closeDeps) + + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "closed", + "state_reason": "completed", + }) + result, err := closeHandler(ContextWithDeps(context.Background(), closeDeps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "Ready to update issue", + "state change should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "state change should execute directly and return issue URL") + }) + + t.Run("UI client update without state change returns form message", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "title": "New Title", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "Ready to update issue #1", + "update without state should show UI form") + }) } func Test_ListIssues(t *testing.T) { From f09dd5e77478a564999ca24992146bbcbec2dada Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:39:03 +0000 Subject: [PATCH 41/57] Use key presence check instead of OptionalParam in UI gate Check for the "state" key directly in the args map rather than using OptionalParam and ignoring its error. This ensures that a wrongly-typed state value bypasses the UI form (falling through to the normal validation path) instead of silently showing the form. Co-authored-by: mattdholloway <918573+mattdholloway@users.noreply.github.com> --- pkg/github/issues.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 980c355df..05af64cab 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1083,8 +1083,7 @@ Options are: // Skip the UI form when a state change is requested because // the form only handles title/body editing and would lose the // state transition (e.g. closing or reopening the issue). - state, _ := OptionalParam[string](args, "state") - if state == "" { + if _, hasState := args["state"]; !hasState { issueNumber, numErr := RequiredInt(args, "issue_number") if numErr != nil { return utils.NewToolResultError("issue_number is required for update method"), nil, nil From 486e9fedb6e1b2eb5edd3b48d41475a457595252 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:57:58 +0000 Subject: [PATCH 42/57] build(deps): bump reproducible-containers/buildkit-cache-dance Bumps [reproducible-containers/buildkit-cache-dance](https://github.com/reproducible-containers/buildkit-cache-dance) from 3.3.1 to 3.3.2. - [Release notes](https://github.com/reproducible-containers/buildkit-cache-dance/releases) - [Commits](https://github.com/reproducible-containers/buildkit-cache-dance/compare/6f699a72a59e4252f05a7435430009b77e25fe06...1b8ab18fbda5ad3646e3fcc9ed9dd41ce2f297b4) --- updated-dependencies: - dependency-name: reproducible-containers/buildkit-cache-dance dependency-version: 3.3.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index de53eb0aa..f03d08121 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -93,7 +93,7 @@ jobs: key: ${{ runner.os }}-go-build-cache-${{ hashFiles('**/go.sum') }} - name: Inject go-build-cache - uses: reproducible-containers/buildkit-cache-dance@6f699a72a59e4252f05a7435430009b77e25fe06 # v3.3.1 + uses: reproducible-containers/buildkit-cache-dance@1b8ab18fbda5ad3646e3fcc9ed9dd41ce2f297b4 # v3.3.2 with: cache-map: | { From 98099e6ae47e5e40f61227cd330d3803af64dceb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:32:42 +0000 Subject: [PATCH 43/57] build(deps): bump docker/build-push-action from 6.18.0 to 6.19.2 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.18.0 to 6.19.2. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/263435318d21b8e681c14492fe198d362a7d2c83...10e90e3645eae34f1e60eeb005ba3a3d33f178e8) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 6.19.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f03d08121..734b41110 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -106,7 +106,7 @@ jobs: # https://github.com/docker/build-push-action - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . push: ${{ github.event_name != 'pull_request' }} From 801648b10273b1b47742671ea69c45592e5cf6c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:09:24 +0000 Subject: [PATCH 44/57] build(deps): bump golang from 1.25.7-alpine to 1.25.8-alpine Bumps golang from 1.25.7-alpine to 1.25.8-alpine. --- updated-dependencies: - dependency-name: golang dependency-version: 1.25.8-alpine dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 90c8b4007..b13ae62d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY ui/ ./ui/ RUN mkdir -p ./pkg/github/ui_dist && \ cd ui && npm run build -FROM golang:1.25.7-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS build +FROM golang:1.25.8-alpine@sha256:8e02eb337d9e0ea459e041f1ee5eece41cbb61f1d83e7d883a3e2fb4862063fa AS build ARG VERSION="dev" # Set the working directory From 1da41fa6947f2412b41d104b4eab39336e48ee1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:49:09 +0000 Subject: [PATCH 45/57] build(deps): bump sigstore/cosign-installer from 4.0.0 to 4.1.0 Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.0.0 to 4.1.0. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](https://github.com/sigstore/cosign-installer/compare/faadad0cce49287aee09b3a48701e75088a2c6ad...ba7bc0a3fef59531c69a25acd34668d6d3fe6f22) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 734b41110..f946bea53 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -46,7 +46,7 @@ jobs: # https://github.com/sigstore/cosign-installer - name: Install cosign if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad #v4.0.0 + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 #v4.1.0 with: cosign-release: "v2.2.4" From f93e5260a23384a6ff59929ea3f63660ce3ce6d8 Mon Sep 17 00:00:00 2001 From: Patrick Walters Date: Tue, 10 Feb 2026 14:25:13 -0600 Subject: [PATCH 46/57] feat: add resolve/unresolve review thread methods Adds `resolve_thread` and `unresolve_thread` methods to the `pull_request_review_write` tool, enabling users to resolve and unresolve PR review threads via GraphQL mutations. - Add ThreadID field to PullRequestReviewWriteParams struct - Add threadId parameter and new methods to tool schema - Implement ResolveReviewThread function using GraphQL mutations - Add switch cases for resolve_thread and unresolve_thread methods - Add unit tests covering success, error, empty and omitted threadId - Document that owner/repo/pullNumber are unused for these methods - Document idempotency (resolving already-resolved is a no-op) - Update toolsnaps and generated docs Fixes #1768 Co-Authored-By: Claude Opus 4.6 --- README.md | 1 + .../pull_request_review_write.snap | 10 +- pkg/github/pullrequests.go | 69 ++++++- pkg/github/pullrequests_test.go | 195 ++++++++++++++++++ 4 files changed, 272 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1b926b132..9b08f93e4 100644 --- a/README.md +++ b/README.md @@ -1119,6 +1119,7 @@ The following sets of tools are available: - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) + - `threadId`: The node ID of the review thread (e.g., PRRT_kwDOxxx). Required for resolve_thread and unresolve_thread methods. Get thread IDs from pull_request_read with method get_review_comments. (string, optional) - **search_pull_requests** - Search pull requests - **Required OAuth Scopes**: `repo` diff --git a/pkg/github/__toolsnaps__/pull_request_review_write.snap b/pkg/github/__toolsnaps__/pull_request_review_write.snap index 7b533f472..7e314005f 100644 --- a/pkg/github/__toolsnaps__/pull_request_review_write.snap +++ b/pkg/github/__toolsnaps__/pull_request_review_write.snap @@ -2,7 +2,7 @@ "annotations": { "title": "Write operations (create, submit, delete) on pull request reviews." }, - "description": "Create and/or submit, delete review of a pull request.\n\nAvailable methods:\n- create: Create a new review of a pull request. If \"event\" parameter is provided, the review is submitted. If \"event\" is omitted, a pending review is created.\n- submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The \"body\" and \"event\" parameters are used when submitting the review.\n- delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request.\n", + "description": "Create and/or submit, delete review of a pull request.\n\nAvailable methods:\n- create: Create a new review of a pull request. If \"event\" parameter is provided, the review is submitted. If \"event\" is omitted, a pending review is created.\n- submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The \"body\" and \"event\" parameters are used when submitting the review.\n- delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request.\n- resolve_thread: Resolve a review thread. Requires only \"threadId\" parameter with the thread's node ID (e.g., PRRT_kwDOxxx). The owner, repo, and pullNumber parameters are not used for this method. Resolving an already-resolved thread is a no-op.\n- unresolve_thread: Unresolve a previously resolved review thread. Requires only \"threadId\" parameter. The owner, repo, and pullNumber parameters are not used for this method. Unresolving an already-unresolved thread is a no-op.\n", "inputSchema": { "properties": { "body": { @@ -27,7 +27,9 @@ "enum": [ "create", "submit_pending", - "delete_pending" + "delete_pending", + "resolve_thread", + "unresolve_thread" ], "type": "string" }, @@ -42,6 +44,10 @@ "repo": { "description": "Repository name", "type": "string" + }, + "threadId": { + "description": "The node ID of the review thread (e.g., PRRT_kwDOxxx). Required for resolve_thread and unresolve_thread methods. Get thread IDs from pull_request_read with method get_review_comments.", + "type": "string" } }, "required": [ diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index e5e0855ea..731db4931 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1507,6 +1507,7 @@ type PullRequestReviewWriteParams struct { Body string Event string CommitID *string + ThreadID string } func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.ServerTool { @@ -1519,7 +1520,7 @@ func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.Serv "method": { Type: "string", Description: `The write operation to perform on pull request review.`, - Enum: []any{"create", "submit_pending", "delete_pending"}, + Enum: []any{"create", "submit_pending", "delete_pending", "resolve_thread", "unresolve_thread"}, }, "owner": { Type: "string", @@ -1546,6 +1547,10 @@ func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.Serv Type: "string", Description: "SHA of commit to review", }, + "threadId": { + Type: "string", + Description: "The node ID of the review thread (e.g., PRRT_kwDOxxx). Required for resolve_thread and unresolve_thread methods. Get thread IDs from pull_request_read with method get_review_comments.", + }, }, Required: []string{"method", "owner", "repo", "pullNumber"}, } @@ -1560,6 +1565,8 @@ Available methods: - create: Create a new review of a pull request. If "event" parameter is provided, the review is submitted. If "event" is omitted, a pending review is created. - submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The "body" and "event" parameters are used when submitting the review. - delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. +- resolve_thread: Resolve a review thread. Requires only "threadId" parameter with the thread's node ID (e.g., PRRT_kwDOxxx). The owner, repo, and pullNumber parameters are not used for this method. Resolving an already-resolved thread is a no-op. +- unresolve_thread: Unresolve a previously resolved review thread. Requires only "threadId" parameter. The owner, repo, and pullNumber parameters are not used for this method. Unresolving an already-unresolved thread is a no-op. `), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_PULL_REQUEST_REVIEW_WRITE_USER_TITLE", "Write operations (create, submit, delete) on pull request reviews."), @@ -1590,6 +1597,12 @@ Available methods: case "delete_pending": result, err := DeletePendingPullRequestReview(ctx, client, params) return result, nil, err + case "resolve_thread": + result, err := ResolveReviewThread(ctx, client, params.ThreadID, true) + return result, nil, err + case "unresolve_thread": + result, err := ResolveReviewThread(ctx, client, params.ThreadID, false) + return result, nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", params.Method)), nil, nil } @@ -1819,6 +1832,60 @@ func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client return utils.NewToolResultText("pending pull request review successfully deleted"), nil } +// ResolveReviewThread resolves or unresolves a PR review thread using GraphQL mutations. +func ResolveReviewThread(ctx context.Context, client *githubv4.Client, threadID string, resolve bool) (*mcp.CallToolResult, error) { + if threadID == "" { + return utils.NewToolResultError("threadId is required for resolve_thread and unresolve_thread methods"), nil + } + + if resolve { + var mutation struct { + ResolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"resolveReviewThread(input: $input)"` + } + + input := githubv4.ResolveReviewThreadInput{ + ThreadID: githubv4.ID(threadID), + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to resolve review thread", + err, + ), nil + } + + return utils.NewToolResultText("review thread resolved successfully"), nil + } + + // Unresolve + var mutation struct { + UnresolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"unresolveReviewThread(input: $input)"` + } + + input := githubv4.UnresolveReviewThreadInput{ + ThreadID: githubv4.ID(threadID), + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to unresolve review thread", + err, + ), nil + } + + return utils.NewToolResultText("review thread unresolved successfully"), nil +} + // AddCommentToPendingReview creates a tool to add a comment to a pull request review. func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 537577329..801122dca 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -3609,3 +3609,198 @@ func TestAddReplyToPullRequestComment(t *testing.T) { }) } } + +func TestResolveReviewThread(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + expectedResult string + }{ + { + name: "successful resolve thread", + requestArgs: map[string]any{ + "method": "resolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "PRRT_kwDOTest123", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + ResolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"resolveReviewThread(input: $input)"` + }{}, + githubv4.ResolveReviewThreadInput{ + ThreadID: githubv4.ID("PRRT_kwDOTest123"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "resolveReviewThread": map[string]any{ + "thread": map[string]any{ + "id": "PRRT_kwDOTest123", + "isResolved": true, + }, + }, + }), + ), + ), + expectedResult: "review thread resolved successfully", + }, + { + name: "successful unresolve thread", + requestArgs: map[string]any{ + "method": "unresolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "PRRT_kwDOTest123", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UnresolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"unresolveReviewThread(input: $input)"` + }{}, + githubv4.UnresolveReviewThreadInput{ + ThreadID: githubv4.ID("PRRT_kwDOTest123"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "unresolveReviewThread": map[string]any{ + "thread": map[string]any{ + "id": "PRRT_kwDOTest123", + "isResolved": false, + }, + }, + }), + ), + ), + expectedResult: "review thread unresolved successfully", + }, + { + name: "empty threadId for resolve", + requestArgs: map[string]any{ + "method": "resolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "threadId is required", + }, + { + name: "empty threadId for unresolve", + requestArgs: map[string]any{ + "method": "unresolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "threadId is required", + }, + { + name: "omitted threadId for resolve", + requestArgs: map[string]any{ + "method": "resolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "threadId is required", + }, + { + name: "omitted threadId for unresolve", + requestArgs: map[string]any{ + "method": "unresolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "threadId is required", + }, + { + name: "thread not found", + requestArgs: map[string]any{ + "method": "resolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "PRRT_invalid", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + ResolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"resolveReviewThread(input: $input)"` + }{}, + githubv4.ResolveReviewThreadInput{ + ThreadID: githubv4.ID("PRRT_invalid"), + }, + nil, + githubv4mock.ErrorResponse("Could not resolve to a PullRequestReviewThread with the id of 'PRRT_invalid'"), + ), + ), + expectToolError: true, + expectedToolErrMsg: "Could not resolve to a PullRequestReviewThread", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup client with mock + client := githubv4.NewClient(tc.mockedClient) + serverTool := PullRequestReviewWrite(translations.NullTranslationHelper) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + if tc.expectToolError { + require.True(t, result.IsError) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + require.False(t, result.IsError) + assert.Equal(t, tc.expectedResult, textContent.Text) + }) + } +} From 0fda6f15091324a0b0b91ff8086b259d3f5d42ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:41:39 +0000 Subject: [PATCH 47/57] Add configurable server name and title via env/flag Allows users running multiple GitHub MCP Server instances (e.g., for github.com and GitHub Enterprise Server) to override the server name and title in the MCP initialization response. - Add --server-name / GITHUB_SERVER_NAME flag+env to override name - Add --server-title / GITHUB_SERVER_TITLE flag+env to override title - Defaults remain "github-mcp-server" and "GitHub MCP Server" - Applies to both stdio and HTTP server modes - Add tests for default and custom name/title Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- cmd/github-mcp-server/main.go | 8 ++++ internal/ghmcp/server.go | 10 +++++ pkg/github/server.go | 23 ++++++++++-- pkg/github/server_test.go | 69 ++++++++++++++++++++++++++++++++++- pkg/http/handler.go | 2 + pkg/http/server.go | 8 ++++ 6 files changed, 115 insertions(+), 5 deletions(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 05c2c6e0b..220e8e3fb 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -80,6 +80,8 @@ var ( ttl := viper.GetDuration("repo-access-cache-ttl") stdioServerConfig := ghmcp.StdioServerConfig{ Version: version, + Name: viper.GetString("server-name"), + Title: viper.GetString("server-title"), Host: viper.GetString("host"), Token: token, EnabledToolsets: enabledToolsets, @@ -108,6 +110,8 @@ var ( ttl := viper.GetDuration("repo-access-cache-ttl") httpConfig := ghhttp.ServerConfig{ Version: version, + Name: viper.GetString("server-name"), + Title: viper.GetString("server-title"), Host: viper.GetString("host"), Port: viper.GetInt("port"), BaseURL: viper.GetString("base-url"), @@ -133,6 +137,8 @@ func init() { rootCmd.SetVersionTemplate("{{.Short}}\n{{.Version}}\n") // Add global flags that will be shared by all commands + rootCmd.PersistentFlags().String("server-name", "", "Override the server name in the MCP initialization response") + rootCmd.PersistentFlags().String("server-title", "", "Override the server title in the MCP initialization response") rootCmd.PersistentFlags().StringSlice("toolsets", nil, github.GenerateToolsetsHelp()) rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable") rootCmd.PersistentFlags().StringSlice("exclude-tools", nil, "Comma-separated list of tool names to disable regardless of other settings") @@ -155,6 +161,8 @@ func init() { httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses") // Bind flag to viper + _ = viper.BindPFlag("server-name", rootCmd.PersistentFlags().Lookup("server-name")) + _ = viper.BindPFlag("server-title", rootCmd.PersistentFlags().Lookup("server-title")) _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) _ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools")) _ = viper.BindPFlag("exclude_tools", rootCmd.PersistentFlags().Lookup("exclude-tools")) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 5c4e7f6f1..a987171dd 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -171,6 +171,14 @@ type StdioServerConfig struct { // Version of the server Version string + // Name overrides the server name in the MCP initialization response. + // If empty, defaults to "github-mcp-server". + Name string + + // Title overrides the server title in the MCP initialization response. + // If empty, defaults to "GitHub MCP Server". + Title string + // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) Host string @@ -266,6 +274,8 @@ func RunStdioServer(cfg StdioServerConfig) error { ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{ Version: cfg.Version, + Name: cfg.Name, + Title: cfg.Title, Host: cfg.Host, Token: cfg.Token, EnabledToolsets: cfg.EnabledToolsets, diff --git a/pkg/github/server.go b/pkg/github/server.go index 06c12575d..97c201a28 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -20,6 +20,14 @@ type MCPServerConfig struct { // Version of the server Version string + // Name overrides the server name in the MCP initialization response. + // If empty, defaults to "github-mcp-server". + Name string + + // Title overrides the server title in the MCP initialization response. + // If empty, defaults to "GitHub MCP Server". + Title string + // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) Host string @@ -101,7 +109,7 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci } } - ghServer := NewServer(cfg.Version, serverOpts) + ghServer := NewServer(cfg.Version, cfg.Name, cfg.Title, serverOpts) // Add middlewares. Order matters - for example, the error context middleware should be applied last so that it runs FIRST (closest to the handler) to ensure all errors are captured, // and any middleware that needs to read or modify the context should be before it. @@ -177,15 +185,22 @@ func addGitHubAPIErrorToContext(next mcp.MethodHandler) mcp.MethodHandler { } // NewServer creates a new GitHub MCP server with the specified GH client and logger. -func NewServer(version string, opts *mcp.ServerOptions) *mcp.Server { +func NewServer(version, name, title string, opts *mcp.ServerOptions) *mcp.Server { if opts == nil { opts = &mcp.ServerOptions{} } + if name == "" { + name = "github-mcp-server" + } + if title == "" { + title = "GitHub MCP Server" + } + // Create a new MCP server s := mcp.NewServer(&mcp.Implementation{ - Name: "github-mcp-server", - Title: "GitHub MCP Server", + Name: name, + Title: title, Version: version, Icons: octicons.Icons("mark-github"), }, opts) diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 2b99cab12..768049f50 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -13,6 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v82/github" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -150,7 +151,73 @@ func TestNewMCPServer_CreatesSuccessfully(t *testing.T) { // is already tested in pkg/github/*_test.go. } -// TestResolveEnabledToolsets verifies the toolset resolution logic. +// TestNewServer_NameAndTitle verifies that the server name and title can be +// overridden and fall back to sensible defaults when empty. +func TestNewServer_NameAndTitle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + serverName string + serverTitle string + expectedName string + expectedTitle string + }{ + { + name: "defaults when empty", + serverName: "", + serverTitle: "", + expectedName: "github-mcp-server", + expectedTitle: "GitHub MCP Server", + }, + { + name: "custom name and title", + serverName: "my-github-server", + serverTitle: "My GitHub MCP Server", + expectedName: "my-github-server", + expectedTitle: "My GitHub MCP Server", + }, + { + name: "custom name only", + serverName: "ghes-server", + serverTitle: "", + expectedName: "ghes-server", + expectedTitle: "GitHub MCP Server", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := NewServer("v1.0.0", tt.serverName, tt.serverTitle, nil) + require.NotNil(t, srv) + + // Connect a client to retrieve the initialize result and verify ServerInfo. + st, ct := mcp.NewInMemoryTransports() + client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil) + + var initResult *mcp.InitializeResult + go func() { + cs, err := client.Connect(context.Background(), ct, nil) + if err == nil { + initResult = cs.InitializeResult() + } + }() + + _, err := srv.Connect(context.Background(), st, nil) + require.NoError(t, err) + + // Give the goroutine time to complete + // (In-memory transport is synchronous, so this is safe) + require.Eventually(t, func() bool { return initResult != nil }, time.Second, 10*time.Millisecond) + require.NotNil(t, initResult.ServerInfo) + assert.Equal(t, tt.expectedName, initResult.ServerInfo.Name) + assert.Equal(t, tt.expectedTitle, initResult.ServerInfo.Title) + }) + } +} + func TestResolveEnabledToolsets(t *testing.T) { t.Parallel() diff --git a/pkg/http/handler.go b/pkg/http/handler.go index 2e828211d..e8506f75b 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -201,6 +201,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ghServer, err := h.githubMcpServerFactory(r, h.deps, invToUse, &github.MCPServerConfig{ Version: h.config.Version, + Name: h.config.Name, + Title: h.config.Title, Translator: h.t, ContentWindowSize: h.config.ContentWindowSize, Logger: h.logger, diff --git a/pkg/http/server.go b/pkg/http/server.go index 872303940..37ccbad2c 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -31,6 +31,14 @@ type ServerConfig struct { // Version of the server Version string + // Name overrides the server name in the MCP initialization response. + // If empty, defaults to "github-mcp-server". + Name string + + // Title overrides the server title in the MCP initialization response. + // If empty, defaults to "GitHub MCP Server". + Title string + // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) Host string From 74507a00ab562d261dc7feb58aa6cf57277360a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:50:06 +0000 Subject: [PATCH 48/57] Use translation strings for server name/title override Instead of new CLI flags (--server-name, --server-title), reuse the existing string override mechanism that already supports tool title/ description overrides throughout the codebase. Users can now configure the server name and title via: - GITHUB_MCP_SERVER_NAME / GITHUB_MCP_SERVER_TITLE env vars - "SERVER_NAME" / "SERVER_TITLE" keys in github-mcp-server-config.json This is consistent with how all other user-visible strings are overridden (e.g. GITHUB_MCP_TOOL_GET_ME_USER_TITLE). No new struct fields or CLI flags are needed. Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- cmd/github-mcp-server/main.go | 8 ----- internal/ghmcp/server.go | 10 ------ pkg/github/server.go | 10 +----- pkg/github/server_test.go | 67 ++++++++++++++++++++++------------- pkg/http/handler.go | 2 -- pkg/http/server.go | 8 ----- 6 files changed, 44 insertions(+), 61 deletions(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 220e8e3fb..05c2c6e0b 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -80,8 +80,6 @@ var ( ttl := viper.GetDuration("repo-access-cache-ttl") stdioServerConfig := ghmcp.StdioServerConfig{ Version: version, - Name: viper.GetString("server-name"), - Title: viper.GetString("server-title"), Host: viper.GetString("host"), Token: token, EnabledToolsets: enabledToolsets, @@ -110,8 +108,6 @@ var ( ttl := viper.GetDuration("repo-access-cache-ttl") httpConfig := ghhttp.ServerConfig{ Version: version, - Name: viper.GetString("server-name"), - Title: viper.GetString("server-title"), Host: viper.GetString("host"), Port: viper.GetInt("port"), BaseURL: viper.GetString("base-url"), @@ -137,8 +133,6 @@ func init() { rootCmd.SetVersionTemplate("{{.Short}}\n{{.Version}}\n") // Add global flags that will be shared by all commands - rootCmd.PersistentFlags().String("server-name", "", "Override the server name in the MCP initialization response") - rootCmd.PersistentFlags().String("server-title", "", "Override the server title in the MCP initialization response") rootCmd.PersistentFlags().StringSlice("toolsets", nil, github.GenerateToolsetsHelp()) rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable") rootCmd.PersistentFlags().StringSlice("exclude-tools", nil, "Comma-separated list of tool names to disable regardless of other settings") @@ -161,8 +155,6 @@ func init() { httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses") // Bind flag to viper - _ = viper.BindPFlag("server-name", rootCmd.PersistentFlags().Lookup("server-name")) - _ = viper.BindPFlag("server-title", rootCmd.PersistentFlags().Lookup("server-title")) _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) _ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools")) _ = viper.BindPFlag("exclude_tools", rootCmd.PersistentFlags().Lookup("exclude-tools")) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index a987171dd..5c4e7f6f1 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -171,14 +171,6 @@ type StdioServerConfig struct { // Version of the server Version string - // Name overrides the server name in the MCP initialization response. - // If empty, defaults to "github-mcp-server". - Name string - - // Title overrides the server title in the MCP initialization response. - // If empty, defaults to "GitHub MCP Server". - Title string - // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) Host string @@ -274,8 +266,6 @@ func RunStdioServer(cfg StdioServerConfig) error { ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{ Version: cfg.Version, - Name: cfg.Name, - Title: cfg.Title, Host: cfg.Host, Token: cfg.Token, EnabledToolsets: cfg.EnabledToolsets, diff --git a/pkg/github/server.go b/pkg/github/server.go index 97c201a28..16ba6c951 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -20,14 +20,6 @@ type MCPServerConfig struct { // Version of the server Version string - // Name overrides the server name in the MCP initialization response. - // If empty, defaults to "github-mcp-server". - Name string - - // Title overrides the server title in the MCP initialization response. - // If empty, defaults to "GitHub MCP Server". - Title string - // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) Host string @@ -109,7 +101,7 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci } } - ghServer := NewServer(cfg.Version, cfg.Name, cfg.Title, serverOpts) + ghServer := NewServer(cfg.Version, cfg.Translator("SERVER_NAME", "github-mcp-server"), cfg.Translator("SERVER_TITLE", "GitHub MCP Server"), serverOpts) // Add middlewares. Order matters - for example, the error context middleware should be applied last so that it runs FIRST (closest to the handler) to ensure all errors are captured, // and any middleware that needs to read or modify the context should be before it. diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 768049f50..c815d5fa0 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -151,36 +151,48 @@ func TestNewMCPServer_CreatesSuccessfully(t *testing.T) { // is already tested in pkg/github/*_test.go. } -// TestNewServer_NameAndTitle verifies that the server name and title can be -// overridden and fall back to sensible defaults when empty. -func TestNewServer_NameAndTitle(t *testing.T) { +// TestNewServer_NameAndTitleViaTranslation verifies that server name and title +// can be overridden via the translation helper (GITHUB_MCP_SERVER_NAME / +// GITHUB_MCP_SERVER_TITLE env vars or github-mcp-server-config.json) and +// fall back to sensible defaults when not overridden. +func TestNewServer_NameAndTitleViaTranslation(t *testing.T) { t.Parallel() tests := []struct { name string - serverName string - serverTitle string + translator translations.TranslationHelperFunc expectedName string expectedTitle string }{ { - name: "defaults when empty", - serverName: "", - serverTitle: "", + name: "defaults when using NullTranslationHelper", + translator: translations.NullTranslationHelper, expectedName: "github-mcp-server", expectedTitle: "GitHub MCP Server", }, { - name: "custom name and title", - serverName: "my-github-server", - serverTitle: "My GitHub MCP Server", + name: "custom name and title via translator", + translator: func(key, defaultValue string) string { + switch key { + case "SERVER_NAME": + return "my-github-server" + case "SERVER_TITLE": + return "My GitHub MCP Server" + default: + return defaultValue + } + }, expectedName: "my-github-server", expectedTitle: "My GitHub MCP Server", }, { - name: "custom name only", - serverName: "ghes-server", - serverTitle: "", + name: "custom name only via translator", + translator: func(key, defaultValue string) string { + if key == "SERVER_NAME" { + return "ghes-server" + } + return defaultValue + }, expectedName: "ghes-server", expectedTitle: "GitHub MCP Server", }, @@ -190,34 +202,41 @@ func TestNewServer_NameAndTitle(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - srv := NewServer("v1.0.0", tt.serverName, tt.serverTitle, nil) + srv := NewServer("v1.0.0", tt.translator("SERVER_NAME", "github-mcp-server"), tt.translator("SERVER_TITLE", "GitHub MCP Server"), nil) require.NotNil(t, srv) // Connect a client to retrieve the initialize result and verify ServerInfo. st, ct := mcp.NewInMemoryTransports() client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil) - var initResult *mcp.InitializeResult + type clientResult struct { + result *mcp.InitializeResult + err error + } + clientResultCh := make(chan clientResult, 1) go func() { cs, err := client.Connect(context.Background(), ct, nil) - if err == nil { - initResult = cs.InitializeResult() + if err != nil { + clientResultCh <- clientResult{err: err} + return } + clientResultCh <- clientResult{result: cs.InitializeResult()} }() _, err := srv.Connect(context.Background(), st, nil) require.NoError(t, err) - // Give the goroutine time to complete - // (In-memory transport is synchronous, so this is safe) - require.Eventually(t, func() bool { return initResult != nil }, time.Second, 10*time.Millisecond) - require.NotNil(t, initResult.ServerInfo) - assert.Equal(t, tt.expectedName, initResult.ServerInfo.Name) - assert.Equal(t, tt.expectedTitle, initResult.ServerInfo.Title) + got := <-clientResultCh + require.NoError(t, got.err) + require.NotNil(t, got.result) + require.NotNil(t, got.result.ServerInfo) + assert.Equal(t, tt.expectedName, got.result.ServerInfo.Name) + assert.Equal(t, tt.expectedTitle, got.result.ServerInfo.Title) }) } } +// TestResolveEnabledToolsets verifies the toolset resolution logic. func TestResolveEnabledToolsets(t *testing.T) { t.Parallel() diff --git a/pkg/http/handler.go b/pkg/http/handler.go index e8506f75b..2e828211d 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -201,8 +201,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ghServer, err := h.githubMcpServerFactory(r, h.deps, invToUse, &github.MCPServerConfig{ Version: h.config.Version, - Name: h.config.Name, - Title: h.config.Title, Translator: h.t, ContentWindowSize: h.config.ContentWindowSize, Logger: h.logger, diff --git a/pkg/http/server.go b/pkg/http/server.go index 37ccbad2c..872303940 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -31,14 +31,6 @@ type ServerConfig struct { // Version of the server Version string - // Name overrides the server name in the MCP initialization response. - // If empty, defaults to "github-mcp-server". - Name string - - // Title overrides the server title in the MCP initialization response. - // If empty, defaults to "GitHub MCP Server". - Title string - // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) Host string From 61a34c14547169d8f8aacf30ca70dc4377e261e9 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 13 Mar 2026 09:38:46 +0100 Subject: [PATCH 49/57] docs: document SERVER_NAME and SERVER_TITLE overrides Add documentation for the server name and title customization feature to the README i18n section and server-configuration.md quick reference. This helps users running multiple GitHub MCP Server instances discover how to configure unique identities via environment variables or the config JSON file. Co-authored-by: Anika Reiter <1503135+Anika-Sol@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 28 ++++++++++++++++++++++++++++ docs/server-configuration.md | 1 + pkg/github/server.go | 4 +++- pkg/github/server_test.go | 4 +++- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9b08f93e4..e9992694e 100644 --- a/README.md +++ b/README.md @@ -1537,6 +1537,34 @@ set the following environment variable: export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description" ``` +### Overriding Server Name and Title + +The same override mechanism can be used to customize the MCP server's `name` and +`title` fields in the initialization response. This is useful when running +multiple GitHub MCP Server instances (e.g., one for github.com and one for +GitHub Enterprise Server) so that agents can distinguish between them. + +| Key | Environment Variable | Default | +|-----|---------------------|---------| +| `SERVER_NAME` | `GITHUB_MCP_SERVER_NAME` | `github-mcp-server` | +| `SERVER_TITLE` | `GITHUB_MCP_SERVER_TITLE` | `GitHub MCP Server` | + +For example, to configure a server instance for GitHub Enterprise Server: + +```json +{ + "SERVER_NAME": "ghes-mcp-server", + "SERVER_TITLE": "GHES MCP Server" +} +``` + +Or using environment variables: + +```sh +export GITHUB_MCP_SERVER_NAME="ghes-mcp-server" +export GITHUB_MCP_SERVER_TITLE="GHES MCP Server" +``` + ## 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/server-configuration.md b/docs/server-configuration.md index a334eb1a2..87d48e01e 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -15,6 +15,7 @@ We currently support the following ways in which the GitHub MCP Server can be co | Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var | | Insiders Mode | `X-MCP-Insiders` header or `/insiders` URL | `--insiders` flag or `GITHUB_INSIDERS` env var | | Scope Filtering | Always enabled | Always enabled | +| Server Name/Title | Not available | `GITHUB_MCP_SERVER_NAME` / `GITHUB_MCP_SERVER_TITLE` env vars or `github-mcp-server-config.json` | > **Default behavior:** If you don't specify any configuration, the server uses the **default toolsets**: `context`, `issues`, `pull_requests`, `repos`, `users`. diff --git a/pkg/github/server.go b/pkg/github/server.go index 16ba6c951..ee41e90e9 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -176,7 +176,9 @@ func addGitHubAPIErrorToContext(next mcp.MethodHandler) mcp.MethodHandler { } } -// NewServer creates a new GitHub MCP server with the specified GH client and logger. +// NewServer creates a new GitHub MCP server with the given version, server +// name, display title, and options. If name or title are empty the defaults +// "github-mcp-server" and "GitHub MCP Server" are used. func NewServer(version, name, title string, opts *mcp.ServerOptions) *mcp.Server { if opts == nil { opts = &mcp.ServerOptions{} diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index c815d5fa0..325900732 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -220,11 +220,13 @@ func TestNewServer_NameAndTitleViaTranslation(t *testing.T) { clientResultCh <- clientResult{err: err} return } + t.Cleanup(func() { _ = cs.Close() }) clientResultCh <- clientResult{result: cs.InitializeResult()} }() - _, err := srv.Connect(context.Background(), st, nil) + ss, err := srv.Connect(context.Background(), st, nil) require.NoError(t, err) + t.Cleanup(func() { _ = ss.Close() }) got := <-clientResultCh require.NoError(t, got.err) From b6ba673ea15e457898c984dde484b12d91e0fd01 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:14:44 +0000 Subject: [PATCH 50/57] build(deps): bump docker/build-push-action from 6.19.2 to 7.0.0 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.0.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...d08e5c354a6adb9ed34480a06d141179aa583294) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f946bea53..0defe3e9f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -106,7 +106,7 @@ jobs: # https://github.com/docker/build-push-action - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . push: ${{ github.event_name != 'pull_request' }} From b1575edfefde09e3cf7c805aea79a92131271659 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:14:49 +0000 Subject: [PATCH 51/57] build(deps): bump docker/metadata-action from 5.10.0 to 6.0.0 Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.10.0 to 6.0.0. - [Release notes](https://github.com/docker/metadata-action/releases) - [Commits](https://github.com/docker/metadata-action/compare/c299e40c65443455700f0fdfc63efafe5b349051...030e881283bb7a6894de51c315a6bfe6a94e05cf) --- updated-dependencies: - dependency-name: docker/metadata-action dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0defe3e9f..4ce7356f3 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -70,7 +70,7 @@ jobs: # https://github.com/docker/metadata-action - name: Extract Docker metadata id: meta - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | From 24ede6975c51bd19b86bf6030d03c2b067d23d0e Mon Sep 17 00:00:00 2001 From: Lucas Bustamante Date: Tue, 24 Mar 2026 15:48:44 -0300 Subject: [PATCH 52/57] feat: add path, since, and until filters to list_commits Add three optional parameters to the list_commits tool that map to existing GitHub API query parameters: - path: filter commits to those touching a specific file or directory - since: only commits after this date (ISO 8601) - until: only commits before this date (ISO 8601) These parameters are already supported by the go-github library's CommitsListOptions struct but were not exposed by the MCP tool. The path filter is particularly useful for monorepo workflows where commit history needs to be scoped to a specific project subdirectory. Time parsing uses the existing parseISOTimestamp helper (shared with list_issues and list_gists) which accepts both YYYY-MM-DDTHH:MM:SSZ and YYYY-MM-DD formats. Closes #197 --- pkg/github/__toolsnaps__/list_commits.snap | 12 ++++ pkg/github/repositories.go | 39 +++++++++++ pkg/github/repositories_test.go | 77 ++++++++++++++++++++++ 3 files changed, 128 insertions(+) diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap index 38b63736f..1a773f217 100644 --- a/pkg/github/__toolsnaps__/list_commits.snap +++ b/pkg/github/__toolsnaps__/list_commits.snap @@ -19,6 +19,10 @@ "minimum": 1, "type": "number" }, + "path": { + "description": "Only commits containing this file path will be returned", + "type": "string" + }, "perPage": { "description": "Results per page for pagination (min 1, max 100)", "maximum": 100, @@ -32,6 +36,14 @@ "sha": { "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" + }, + "since": { + "description": "Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + "type": "string" + }, + "until": { + "description": "Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + "type": "string" } }, "required": [ diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 9376ddad4..305189650 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -147,6 +147,18 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { Type: "string", Description: "Author username or email address to filter commits by", }, + "path": { + Type: "string", + Description: "Only commits containing this file path will be returned", + }, + "since": { + Type: "string", + Description: "Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + }, + "until": { + Type: "string", + Description: "Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + }, }, Required: []string{"owner", "repo"}, }), @@ -169,6 +181,18 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + path, err := OptionalParam[string](args, "path") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + sinceStr, err := OptionalParam[string](args, "since") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + untilStr, err := OptionalParam[string](args, "until") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } pagination, err := OptionalPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -180,12 +204,27 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { } opts := &github.CommitsListOptions{ SHA: sha, + Path: path, Author: author, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: perPage, }, } + if sinceStr != "" { + sinceTime, err := parseISOTimestamp(sinceStr) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid since timestamp: %s", err)), nil, nil + } + opts.Since = sinceTime + } + if untilStr != "" { + untilTime, err := parseISOTimestamp(untilStr) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid until timestamp: %s", err)), nil, nil + } + opts.Until = untilTime + } client, err := deps.GetClient(ctx) if err != nil { diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index ae2ece0f6..d7bb48738 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -900,6 +900,9 @@ func Test_ListCommits(t *testing.T) { assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "sha") assert.Contains(t, schema.Properties, "author") + assert.Contains(t, schema.Properties, "path") + assert.Contains(t, schema.Properties, "since") + assert.Contains(t, schema.Properties, "until") assert.Contains(t, schema.Properties, "page") assert.Contains(t, schema.Properties, "perPage") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) @@ -1020,6 +1023,80 @@ func Test_ListCommits(t *testing.T) { expectError: false, expectedCommits: mockCommits, }, + { + name: "successful commits fetch with path filter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "path": "src/main.go", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "src/main.go", + }, + expectError: false, + expectedCommits: mockCommits, + }, + { + name: "successful commits fetch with since and until", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "since": "2023-01-01T00:00:00Z", + "until": "2023-12-31T23:59:59Z", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "since": "2023-01-01T00:00:00Z", + "until": "2023-12-31T23:59:59Z", + }, + expectError: false, + expectedCommits: mockCommits, + }, + { + name: "successful commits fetch with path, since, and author", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "path": "projects/plugins/boost", + "since": "2023-06-15T00:00:00Z", + "author": "username", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "projects/plugins/boost", + "since": "2023-06-15T00:00:00Z", + "author": "username", + }, + expectError: false, + expectedCommits: mockCommits, + }, + { + name: "invalid since timestamp returns error", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "since": "not-a-date", + }, + expectError: true, + expectedErrMsg: "invalid since timestamp", + }, { name: "successful commits fetch with pagination", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ From 2a1eaaca39daf7e6538cf21e0cb0f7dcd73bd88b Mon Sep 17 00:00:00 2001 From: Lucas Bustamante Date: Tue, 24 Mar 2026 16:00:43 -0300 Subject: [PATCH 53/57] docs: regenerate README with new list_commits parameters --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index e9992694e..419f89297 100644 --- a/README.md +++ b/README.md @@ -1242,9 +1242,12 @@ The following sets of tools are available: - `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) + - `path`: Only commits containing this file path will be returned (string, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `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) + - `since`: Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD) (string, optional) + - `until`: Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD) (string, optional) - **list_releases** - List releases - **Required OAuth Scopes**: `repo` From b01f7f5b6aa4c251136f9adbc51d489f241a07a4 Mon Sep 17 00:00:00 2001 From: Artem Sierikov Date: Tue, 24 Mar 2026 17:01:20 +0100 Subject: [PATCH 54/57] fix: additionalProperties in push_files schema (#2011) Some MCP clients require array item schemas to explicitly set `additionalProperties: false`. Without this, `push_files` calls will fail. Fixes #2011 Research and fix was initially done by @04cb --- pkg/github/__toolsnaps__/push_files.snap | 1 + pkg/github/repositories.go | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/github/__toolsnaps__/push_files.snap b/pkg/github/__toolsnaps__/push_files.snap index c36c236f9..df6c4d1e7 100644 --- a/pkg/github/__toolsnaps__/push_files.snap +++ b/pkg/github/__toolsnaps__/push_files.snap @@ -12,6 +12,7 @@ "files": { "description": "Array of file objects to push, each object with path (string) and content (string)", "items": { + "additionalProperties": false, "properties": { "content": { "description": "file content", diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 305189650..9577b37b6 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1272,7 +1272,8 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { Type: "array", Description: "Array of file objects to push, each object with path (string) and content (string)", Items: &jsonschema.Schema{ - Type: "object", + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, Properties: map[string]*jsonschema.Schema{ "path": { Type: "string", From dd239d84430e711cb62de093581ff91816eba62b Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 31 Mar 2026 13:10:22 +0100 Subject: [PATCH 55/57] Initial OSS logging adapter for http (#2008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial logging stack for http * add metrics adapter * fix linter issues * make log fields generic * Update pkg/github/server_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove unused SlogMetrics adapter The slog-based metrics adapter was never used — OSS always uses NoopMetrics and the remote server has its own DataDog-backed adapter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update pkg/github/dependencies.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fmt * change to use slog * address feedback * rename noop adapter to noop sink * Update pkg/http/server.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * [WIP] [WIP] Address feedback on OSS logging adapter for http implementation (#2264) * Initial plan * Fix BaseDeps.Logger and BaseDeps.Metrics to return safe defaults when Obsv is nil Agent-Logs-Url: https://github.com/github/github-mcp-server/sessions/53221b0b-abb4-4138-a147-3ce9e13b379a Co-authored-by: mattdholloway <918573+mattdholloway@users.noreply.github.com> * Fix nil metrics in server.go by passing metrics.NewNoopMetrics() to NewExporters Agent-Logs-Url: https://github.com/github/github-mcp-server/sessions/53221b0b-abb4-4138-a147-3ce9e13b379a Co-authored-by: mattdholloway <918573+mattdholloway@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mattdholloway <918573+mattdholloway@users.noreply.github.com> Co-authored-by: Matt Holloway * replace nil with stubs --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- internal/ghmcp/server.go | 7 ++++ pkg/github/context_tools_test.go | 11 +++-- pkg/github/dependencies.go | 41 ++++++++++++++++++ pkg/github/dependencies_test.go | 12 ++++++ pkg/github/dynamic_tools_test.go | 2 +- pkg/github/feature_flags_test.go | 2 + pkg/github/server_test.go | 19 ++++++++- pkg/http/server.go | 8 ++++ pkg/observability/metrics/metrics.go | 13 ++++++ pkg/observability/metrics/noop_sink.go | 19 +++++++++ pkg/observability/metrics/noop_sink_test.go | 42 +++++++++++++++++++ pkg/observability/observability.go | 46 +++++++++++++++++++++ pkg/observability/observability_test.go | 46 +++++++++++++++++++++ 13 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 pkg/observability/metrics/metrics.go create mode 100644 pkg/observability/metrics/noop_sink.go create mode 100644 pkg/observability/metrics/noop_sink_test.go create mode 100644 pkg/observability/observability.go create mode 100644 pkg/observability/observability_test.go diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 5c4e7f6f1..5dfaf596c 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -18,6 +18,8 @@ import ( "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/lockdown" mcplog "github.com/github/github-mcp-server/pkg/log" + "github.com/github/github-mcp-server/pkg/observability" + "github.com/github/github-mcp-server/pkg/observability/metrics" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -116,6 +118,10 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se featureChecker := createFeatureChecker(cfg.EnabledFeatures) // Create dependencies for tool handlers + obs, err := observability.NewExporters(cfg.Logger, metrics.NewNoopMetrics()) + if err != nil { + return nil, fmt.Errorf("failed to create observability exporters: %w", err) + } deps := github.NewBaseDeps( clients.rest, clients.gql, @@ -128,6 +134,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se }, cfg.ContentWindowSize, featureChecker, + obs, ) // Build and register the tool/resource/prompt inventory inventoryBuilder := github.NewInventory(cfg.Translator). diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 392501985..39f2058be 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -96,9 +96,10 @@ func Test_GetMe(t *testing.T) { t.Run(tc.name, func(t *testing.T) { var deps ToolDependencies if tc.clientErr != "" { - deps = stubDeps{clientFn: stubClientFnErr(tc.clientErr)} + deps = stubDeps{clientFn: stubClientFnErr(tc.clientErr), obsv: stubExporters()} } else { - deps = BaseDeps{Client: github.NewClient(tc.mockedClient)} + obs := stubExporters() + deps = BaseDeps{Client: github.NewClient(tc.mockedClient), Obsv: obs} } handler := serverTool.Handler(deps) @@ -304,7 +305,7 @@ func Test_GetTeams(t *testing.T) { { name: "getting client fails", makeDeps: func() ToolDependencies { - return stubDeps{clientFn: stubClientFnErr("expected test error")} + return stubDeps{clientFn: stubClientFnErr("expected test error"), obsv: stubExporters()} }, requestArgs: map[string]any{}, expectToolError: true, @@ -315,6 +316,7 @@ func Test_GetTeams(t *testing.T) { makeDeps: func() ToolDependencies { return BaseDeps{ Client: github.NewClient(httpClientUserFails()), + Obsv: stubExporters(), } }, requestArgs: map[string]any{}, @@ -327,6 +329,7 @@ func Test_GetTeams(t *testing.T) { return stubDeps{ clientFn: stubClientFnFromHTTP(httpClientWithUser()), gqlClientFn: stubGQLClientFnErr("GraphQL client error"), + obsv: stubExporters(), } }, requestArgs: map[string]any{}, @@ -469,7 +472,7 @@ func Test_GetTeamMembers(t *testing.T) { }, { name: "getting GraphQL client fails", - deps: stubDeps{gqlClientFn: stubGQLClientFnErr("GraphQL client error")}, + deps: stubDeps{gqlClientFn: stubGQLClientFnErr("GraphQL client error"), obsv: stubExporters()}, requestArgs: map[string]any{ "org": "testorg", "team_slug": "testteam", diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index f966c531e..57c6133a8 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" "os" @@ -11,6 +12,8 @@ import ( "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/lockdown" + "github.com/github/github-mcp-server/pkg/observability" + "github.com/github/github-mcp-server/pkg/observability/metrics" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -94,6 +97,14 @@ type ToolDependencies interface { // IsFeatureEnabled checks if a feature flag is enabled. IsFeatureEnabled(ctx context.Context, flagName string) bool + + // Logger returns the structured logger, optionally enriched with + // request-scoped data from ctx. Integrators provide their own slog.Handler + // to control where logs are sent. + Logger(ctx context.Context) *slog.Logger + + // Metrics returns the metrics client + Metrics(ctx context.Context) metrics.Metrics } // BaseDeps is the standard implementation of ToolDependencies for the local server. @@ -113,6 +124,9 @@ type BaseDeps struct { // Feature flag checker for runtime checks featureChecker inventory.FeatureFlagChecker + + // Observability exporters (includes logger) + Obsv observability.Exporters } // Compile-time assertion to verify that BaseDeps implements the ToolDependencies interface. @@ -128,6 +142,7 @@ func NewBaseDeps( flags FeatureFlags, contentWindowSize int, featureChecker inventory.FeatureFlagChecker, + obsv observability.Exporters, ) *BaseDeps { return &BaseDeps{ Client: client, @@ -138,6 +153,7 @@ func NewBaseDeps( Flags: flags, ContentWindowSize: contentWindowSize, featureChecker: featureChecker, + Obsv: obsv, } } @@ -170,6 +186,16 @@ func (d BaseDeps) GetFlags(_ context.Context) FeatureFlags { return d.Flags } // GetContentWindowSize implements ToolDependencies. func (d BaseDeps) GetContentWindowSize() int { return d.ContentWindowSize } +// Logger implements ToolDependencies. +func (d BaseDeps) Logger(_ context.Context) *slog.Logger { + return d.Obsv.Logger() +} + +// Metrics implements ToolDependencies. +func (d BaseDeps) Metrics(ctx context.Context) metrics.Metrics { + return d.Obsv.Metrics(ctx) +} + // IsFeatureEnabled checks if a feature flag is enabled. // Returns false if the feature checker is nil, flag name is empty, or an error occurs. // This allows tools to conditionally change behavior based on feature flags. @@ -247,6 +273,9 @@ type RequestDeps struct { // Feature flag checker for runtime checks featureChecker inventory.FeatureFlagChecker + + // Observability exporters (includes logger) + obsv observability.Exporters } // NewRequestDeps creates a RequestDeps with the provided clients and configuration. @@ -258,6 +287,7 @@ func NewRequestDeps( t translations.TranslationHelperFunc, contentWindowSize int, featureChecker inventory.FeatureFlagChecker, + obsv observability.Exporters, ) *RequestDeps { return &RequestDeps{ apiHosts: apiHosts, @@ -267,6 +297,7 @@ func NewRequestDeps( T: t, ContentWindowSize: contentWindowSize, featureChecker: featureChecker, + obsv: obsv, } } @@ -374,6 +405,16 @@ func (d *RequestDeps) GetFlags(ctx context.Context) FeatureFlags { // GetContentWindowSize implements ToolDependencies. func (d *RequestDeps) GetContentWindowSize() int { return d.ContentWindowSize } +// Logger implements ToolDependencies. +func (d *RequestDeps) Logger(_ context.Context) *slog.Logger { + return d.obsv.Logger() +} + +// Metrics implements ToolDependencies. +func (d *RequestDeps) Metrics(ctx context.Context) metrics.Metrics { + return d.obsv.Metrics(ctx) +} + // IsFeatureEnabled checks if a feature flag is enabled. func (d *RequestDeps) IsFeatureEnabled(ctx context.Context, flagName string) bool { if d.featureChecker == nil || flagName == "" { diff --git a/pkg/github/dependencies_test.go b/pkg/github/dependencies_test.go index d13160d4c..1d747cae4 100644 --- a/pkg/github/dependencies_test.go +++ b/pkg/github/dependencies_test.go @@ -3,13 +3,21 @@ package github_test import ( "context" "errors" + "log/slog" "testing" "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/observability" + "github.com/github/github-mcp-server/pkg/observability/metrics" "github.com/github/github-mcp-server/pkg/translations" "github.com/stretchr/testify/assert" ) +func testExporters() observability.Exporters { + obs, _ := observability.NewExporters(slog.New(slog.DiscardHandler), metrics.NewNoopMetrics()) + return obs +} + func TestIsFeatureEnabled_WithEnabledFlag(t *testing.T) { t.Parallel() @@ -28,6 +36,7 @@ func TestIsFeatureEnabled_WithEnabledFlag(t *testing.T) { github.FeatureFlags{}, 0, // contentWindowSize checker, // featureChecker + testExporters(), ) // Test enabled flag @@ -52,6 +61,7 @@ func TestIsFeatureEnabled_WithoutChecker(t *testing.T) { github.FeatureFlags{}, 0, // contentWindowSize nil, // featureChecker (nil) + testExporters(), ) // Should return false when checker is nil @@ -76,6 +86,7 @@ func TestIsFeatureEnabled_EmptyFlagName(t *testing.T) { github.FeatureFlags{}, 0, // contentWindowSize checker, // featureChecker + testExporters(), ) // Should return false for empty flag name @@ -100,6 +111,7 @@ func TestIsFeatureEnabled_CheckerError(t *testing.T) { github.FeatureFlags{}, 0, // contentWindowSize checker, // featureChecker + testExporters(), ) // Should return false and log error (not crash) diff --git a/pkg/github/dynamic_tools_test.go b/pkg/github/dynamic_tools_test.go index 3e63c5d7b..ec559099e 100644 --- a/pkg/github/dynamic_tools_test.go +++ b/pkg/github/dynamic_tools_test.go @@ -136,7 +136,7 @@ func TestDynamicTools_EnableToolset(t *testing.T) { deps := DynamicToolDependencies{ Server: server, Inventory: reg, - ToolDeps: NewBaseDeps(nil, nil, nil, nil, translations.NullTranslationHelper, FeatureFlags{}, 0, nil), + ToolDeps: NewBaseDeps(nil, nil, nil, nil, translations.NullTranslationHelper, FeatureFlags{}, 0, nil, stubExporters()), T: translations.NullTranslationHelper, } diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go index 2f0a435c9..0f08c4f12 100644 --- a/pkg/github/feature_flags_test.go +++ b/pkg/github/feature_flags_test.go @@ -104,6 +104,7 @@ func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) { FeatureFlags{}, 0, checker, + stubExporters(), ) // Get the tool and its handler @@ -166,6 +167,7 @@ func TestHelloWorld_ConditionalBehavior_Config(t *testing.T) { FeatureFlags{InsidersMode: tt.insidersMode}, 0, nil, + stubExporters(), ) // Get the tool and its handler diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 325900732..bf29ed132 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -5,11 +5,14 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net/http" "testing" "time" "github.com/github/github-mcp-server/pkg/lockdown" + "github.com/github/github-mcp-server/pkg/observability" + "github.com/github/github-mcp-server/pkg/observability/metrics" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v82/github" @@ -30,6 +33,7 @@ type stubDeps struct { t translations.TranslationHelperFunc flags FeatureFlags contentWindowSize int + obsv observability.Exporters } func (s stubDeps) GetClient(ctx context.Context) (*gogithub.Client, error) { @@ -60,8 +64,21 @@ func (s stubDeps) GetT() translations.TranslationHelperFunc { return s. func (s stubDeps) GetFlags(_ context.Context) FeatureFlags { return s.flags } func (s stubDeps) GetContentWindowSize() int { return s.contentWindowSize } func (s stubDeps) IsFeatureEnabled(_ context.Context, _ string) bool { return false } +func (s stubDeps) Logger(_ context.Context) *slog.Logger { + return s.obsv.Logger() +} +func (s stubDeps) Metrics(ctx context.Context) metrics.Metrics { + return s.obsv.Metrics(ctx) +} // Helper functions to create stub client functions for error testing + +// stubExporters returns a discard-logger + noop-metrics Exporters for tests. +func stubExporters() observability.Exporters { + obs, _ := observability.NewExporters(slog.New(slog.DiscardHandler), metrics.NewNoopMetrics()) + return obs +} + func stubClientFnFromHTTP(httpClient *http.Client) func(context.Context) (*gogithub.Client, error) { return func(_ context.Context) (*gogithub.Client, error) { return gogithub.NewClient(httpClient), nil @@ -125,7 +142,7 @@ func TestNewMCPServer_CreatesSuccessfully(t *testing.T) { InsidersMode: false, } - deps := stubDeps{} + deps := stubDeps{obsv: stubExporters()} // Build inventory inv, err := NewInventory(cfg.Translator). diff --git a/pkg/http/server.go b/pkg/http/server.go index 872303940..55aed1c61 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -17,6 +17,8 @@ import ( "github.com/github/github-mcp-server/pkg/http/oauth" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/lockdown" + "github.com/github/github-mcp-server/pkg/observability" + "github.com/github/github-mcp-server/pkg/observability/metrics" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" @@ -106,6 +108,11 @@ func RunHTTPServer(cfg ServerConfig) error { featureChecker := createHTTPFeatureChecker() + obs, err := observability.NewExporters(logger, metrics.NewNoopMetrics()) + if err != nil { + return fmt.Errorf("failed to create observability exporters: %w", err) + } + deps := github.NewRequestDeps( apiHost, cfg.Version, @@ -114,6 +121,7 @@ func RunHTTPServer(cfg ServerConfig) error { t, cfg.ContentWindowSize, featureChecker, + obs, ) // Initialize the global tool scope map diff --git a/pkg/observability/metrics/metrics.go b/pkg/observability/metrics/metrics.go new file mode 100644 index 000000000..5e861b3e0 --- /dev/null +++ b/pkg/observability/metrics/metrics.go @@ -0,0 +1,13 @@ +package metrics + +import "time" + +// Metrics is a backend-agnostic interface for emitting metrics. +// Implementations can route to DataDog, log to slog, or discard (noop). +type Metrics interface { + Increment(key string, tags map[string]string) + Counter(key string, tags map[string]string, value int64) + Distribution(key string, tags map[string]string, value float64) + DistributionMs(key string, tags map[string]string, value time.Duration) + WithTags(tags map[string]string) Metrics +} diff --git a/pkg/observability/metrics/noop_sink.go b/pkg/observability/metrics/noop_sink.go new file mode 100644 index 000000000..4ce9e337d --- /dev/null +++ b/pkg/observability/metrics/noop_sink.go @@ -0,0 +1,19 @@ +package metrics + +import "time" + +// NoopMetrics is a no-op implementation of the Metrics interface. +type NoopMetrics struct{} + +var _ Metrics = (*NoopMetrics)(nil) + +// NewNoopMetrics returns a new NoopMetrics. +func NewNoopMetrics() *NoopMetrics { + return &NoopMetrics{} +} + +func (n *NoopMetrics) Increment(_ string, _ map[string]string) {} +func (n *NoopMetrics) Counter(_ string, _ map[string]string, _ int64) {} +func (n *NoopMetrics) Distribution(_ string, _ map[string]string, _ float64) {} +func (n *NoopMetrics) DistributionMs(_ string, _ map[string]string, _ time.Duration) {} +func (n *NoopMetrics) WithTags(_ map[string]string) Metrics { return n } diff --git a/pkg/observability/metrics/noop_sink_test.go b/pkg/observability/metrics/noop_sink_test.go new file mode 100644 index 000000000..21d3dccd6 --- /dev/null +++ b/pkg/observability/metrics/noop_sink_test.go @@ -0,0 +1,42 @@ +package metrics + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNoopMetrics_ImplementsInterface(_ *testing.T) { + var _ Metrics = (*NoopMetrics)(nil) +} + +func TestNoopMetrics_NoPanics(t *testing.T) { + m := NewNoopMetrics() + + assert.NotPanics(t, func() { + m.Increment("key", map[string]string{"a": "b"}) + m.Counter("key", map[string]string{"a": "b"}, 1) + m.Distribution("key", map[string]string{"a": "b"}, 1.5) + m.DistributionMs("key", map[string]string{"a": "b"}, time.Second) + }) +} + +func TestNoopMetrics_NilTags(t *testing.T) { + m := NewNoopMetrics() + + assert.NotPanics(t, func() { + m.Increment("key", nil) + m.Counter("key", nil, 1) + m.Distribution("key", nil, 1.5) + m.DistributionMs("key", nil, time.Second) + }) +} + +func TestNoopMetrics_WithTags(t *testing.T) { + m := NewNoopMetrics() + tagged := m.WithTags(map[string]string{"env": "prod"}) + + assert.NotNil(t, tagged) + assert.Equal(t, m, tagged) +} diff --git a/pkg/observability/observability.go b/pkg/observability/observability.go new file mode 100644 index 000000000..3741b05c7 --- /dev/null +++ b/pkg/observability/observability.go @@ -0,0 +1,46 @@ +package observability + +import ( + "context" + "errors" + "log/slog" + + "github.com/github/github-mcp-server/pkg/observability/metrics" +) + +// Exporters bundles observability primitives (logger + metrics) for dependency injection. +// The logger is Go's stdlib *slog.Logger — integrators provide their own slog.Handler. +type Exporters interface { + Logger() *slog.Logger + Metrics(context.Context) metrics.Metrics +} + +type exporters struct { + logger *slog.Logger + metrics metrics.Metrics +} + +// NewExporters creates an Exporters bundle. Pass a configured *slog.Logger +// (with whatever slog.Handler you need) and a Metrics implementation. +// Neither may be nil; use slog.New(slog.DiscardHandler) and metrics.NewNoopMetrics() +// if logging or metrics are unwanted. +func NewExporters(logger *slog.Logger, m metrics.Metrics) (Exporters, error) { + if logger == nil { + return nil, errors.New("logger must not be nil: use slog.New(slog.DiscardHandler) to discard logs") + } + if m == nil { + return nil, errors.New("metrics must not be nil: use metrics.NewNoopMetrics() to discard metrics") + } + return &exporters{ + logger: logger, + metrics: m, + }, nil +} + +func (e *exporters) Logger() *slog.Logger { + return e.logger +} + +func (e *exporters) Metrics(_ context.Context) metrics.Metrics { + return e.metrics +} diff --git a/pkg/observability/observability_test.go b/pkg/observability/observability_test.go new file mode 100644 index 000000000..c8949fdbd --- /dev/null +++ b/pkg/observability/observability_test.go @@ -0,0 +1,46 @@ +package observability + +import ( + "context" + "log/slog" + "testing" + + "github.com/github/github-mcp-server/pkg/observability/metrics" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewExporters(t *testing.T) { + logger := slog.Default() + m := metrics.NewNoopMetrics() + exp, err := NewExporters(logger, m) + ctx := context.Background() + + require.NoError(t, err) + assert.NotNil(t, exp) + assert.Equal(t, logger, exp.Logger()) + assert.Equal(t, m, exp.Metrics(ctx)) +} + +func TestNewExporters_WithNilLogger(t *testing.T) { + _, err := NewExporters(nil, metrics.NewNoopMetrics()) + require.Error(t, err) + assert.Contains(t, err.Error(), "logger must not be nil") +} + +func TestNewExporters_WithNilMetrics(t *testing.T) { + _, err := NewExporters(slog.New(slog.DiscardHandler), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "metrics must not be nil") +} + +func TestNewExporters_WithDiscardLogger(t *testing.T) { + logger := slog.New(slog.DiscardHandler) + m := metrics.NewNoopMetrics() + exp, err := NewExporters(logger, m) + + require.NoError(t, err) + assert.NotNil(t, exp) + assert.Equal(t, logger, exp.Logger()) + assert.Equal(t, m, exp.Metrics(context.Background())) +} From 15315b99137372aa626b79742dab82ae945a8ef4 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 2 Apr 2026 10:17:17 +0100 Subject: [PATCH 56/57] Add MCP Insiders Feedback issue template (#2280) * Update issue templates * Update insiders-feedback.md --- .github/ISSUE_TEMPLATE/insiders-feedback.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/insiders-feedback.md diff --git a/.github/ISSUE_TEMPLATE/insiders-feedback.md b/.github/ISSUE_TEMPLATE/insiders-feedback.md new file mode 100644 index 000000000..5b1f87f8c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/insiders-feedback.md @@ -0,0 +1,14 @@ +--- +name: Insiders Feedback +about: Give feedback related to a GitHub MCP Server Insiders feature +title: "Insiders Feedback: " +labels: '' +assignees: '' + +--- + +Version: Insiders + +Feature: + +Feedback: From 95726adfc4b3c0f7c83d143dcfde1a8a9f730644 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 2 Apr 2026 11:15:56 +0100 Subject: [PATCH 57/57] add feedback link (#2281) --- ui/src/components/AppProvider.tsx | 6 +++++- ui/src/components/FeedbackFooter.tsx | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 ui/src/components/FeedbackFooter.tsx diff --git a/ui/src/components/AppProvider.tsx b/ui/src/components/AppProvider.tsx index 7848c3819..18e81c5b0 100644 --- a/ui/src/components/AppProvider.tsx +++ b/ui/src/components/AppProvider.tsx @@ -1,6 +1,7 @@ import { ThemeProvider, BaseStyles, Box } from "@primer/react"; import type { ReactNode } from "react"; import { useEffect } from "react"; +import { FeedbackFooter } from "./FeedbackFooter"; interface AppProviderProps { children: ReactNode; @@ -19,7 +20,10 @@ export function AppProvider({ children }: AppProviderProps) { return ( - {children} + + {children} + + ); diff --git a/ui/src/components/FeedbackFooter.tsx b/ui/src/components/FeedbackFooter.tsx new file mode 100644 index 000000000..10fbdf44e --- /dev/null +++ b/ui/src/components/FeedbackFooter.tsx @@ -0,0 +1,17 @@ +import { Box, Text } from "@primer/react"; + +export function FeedbackFooter() { + return ( + + + Help us improve MCP Apps support in the GitHub MCP Server +
+ github.com/github/github-mcp-server/issues/new?template=insiders-feedback.md +
+
+ ); +}