56bfeec0a9
Sanitization was previously applied ad hoc at a handful of tool call sites (GetIssue, GetPullRequest, ListPullRequests) rather than in the shared convertToMinimal* converters, so equivalent user-authored text returned by other tools (issue comments, PR reviews, review comments, releases, commit messages, discussions, project item titles) was returned unsanitized. - Apply sanitize.Sanitize inside the convertToMinimal* helpers in minimal_types.go for issue/PR titles and bodies, issue comments, PR reviews, review comments, releases, commit messages, and project item content titles. This is the single, shared conversion point used by nearly every read tool, so fixing it there covers get/list issues, pull requests, comments, reviews, review comments, releases, commits, and project items consistently. - Add a sanitizeIssueTitleAndBody helper and use it for the two response paths that marshal a raw *github.Issue directly instead of a Minimal* type: search_issues (SearchIssueResult.MarshalJSON) and search_pull_requests (searchHandler). - Sanitize discussion titles/bodies/comments (list_discussions, get_discussion, get_discussion_comments), which previously had no sanitization at all, via a new newMinimalDiscussionComment constructor and inline fixes. - Sanitize project status update bodies. - Remove the now-redundant scattered sanitize calls in GetIssue, GetPullRequest, and ListPullRequests now that the shared converters sanitize on their own. Patches, diffs, and raw file contents are intentionally left untouched to preserve fidelity. Adds table-driven regression tests covering every touched converter, the search_issues/search_pull_requests raw-passthrough paths, and a fidelity check that patches/diffs are not altered. Fixes #3106 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1097 lines
36 KiB
Go
1097 lines
36 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/github/github-mcp-server/pkg/ifc"
|
|
"github.com/github/github-mcp-server/pkg/inventory"
|
|
"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/v89/github"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"github.com/shurcooL/githubv4"
|
|
)
|
|
|
|
const DefaultGraphQLPageSize = 30
|
|
|
|
// Common interface for all discussion query types
|
|
type DiscussionQueryResult interface {
|
|
GetDiscussionFragment() DiscussionFragment
|
|
}
|
|
|
|
// Implement the interface for all query types
|
|
func (q *BasicNoOrder) GetDiscussionFragment() DiscussionFragment {
|
|
return q.Repository.Discussions
|
|
}
|
|
|
|
func (q *BasicWithOrder) GetDiscussionFragment() DiscussionFragment {
|
|
return q.Repository.Discussions
|
|
}
|
|
|
|
func (q *WithCategoryAndOrder) GetDiscussionFragment() DiscussionFragment {
|
|
return q.Repository.Discussions
|
|
}
|
|
|
|
func (q *WithCategoryNoOrder) GetDiscussionFragment() DiscussionFragment {
|
|
return q.Repository.Discussions
|
|
}
|
|
|
|
type DiscussionFragment struct {
|
|
Nodes []NodeFragment
|
|
PageInfo PageInfoFragment
|
|
TotalCount githubv4.Int
|
|
}
|
|
|
|
type NodeFragment struct {
|
|
Number githubv4.Int
|
|
Title githubv4.String
|
|
CreatedAt githubv4.DateTime
|
|
UpdatedAt githubv4.DateTime
|
|
Closed githubv4.Boolean
|
|
IsAnswered githubv4.Boolean
|
|
AnswerChosenAt *githubv4.DateTime
|
|
Author struct {
|
|
Login githubv4.String
|
|
}
|
|
Category struct {
|
|
Name githubv4.String
|
|
} `graphql:"category"`
|
|
URL githubv4.String `graphql:"url"`
|
|
}
|
|
|
|
type PageInfoFragment struct {
|
|
HasNextPage bool
|
|
HasPreviousPage bool
|
|
StartCursor githubv4.String
|
|
EndCursor githubv4.String
|
|
}
|
|
|
|
type BasicNoOrder struct {
|
|
Repository struct {
|
|
Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after)"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
|
|
type BasicWithOrder struct {
|
|
Repository struct {
|
|
Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection })"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
|
|
type WithCategoryAndOrder struct {
|
|
Repository struct {
|
|
Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection })"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
|
|
type WithCategoryNoOrder struct {
|
|
Repository struct {
|
|
Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
|
|
func fragmentToDiscussion(fragment NodeFragment) *github.Discussion {
|
|
return &github.Discussion{
|
|
Number: github.Ptr(int(fragment.Number)),
|
|
Title: github.Ptr(sanitize.Sanitize(string(fragment.Title))),
|
|
HTMLURL: github.Ptr(string(fragment.URL)),
|
|
CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time},
|
|
UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time},
|
|
User: &github.User{
|
|
Login: github.Ptr(string(fragment.Author.Login)),
|
|
},
|
|
DiscussionCategory: &github.DiscussionCategory{
|
|
Name: github.Ptr(string(fragment.Category.Name)),
|
|
},
|
|
}
|
|
}
|
|
|
|
func getQueryType(useOrdering bool, categoryID *githubv4.ID) any {
|
|
if categoryID != nil && useOrdering {
|
|
return &WithCategoryAndOrder{}
|
|
}
|
|
if categoryID != nil && !useOrdering {
|
|
return &WithCategoryNoOrder{}
|
|
}
|
|
if categoryID == nil && useOrdering {
|
|
return &BasicWithOrder{}
|
|
}
|
|
return &BasicNoOrder{}
|
|
}
|
|
|
|
func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
return NewTool(
|
|
ToolsetMetadataDiscussions,
|
|
mcp.Tool{
|
|
Name: "list_discussions",
|
|
Description: t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"),
|
|
ReadOnlyHint: true,
|
|
},
|
|
InputSchema: WithCursorPagination(&jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name. If not provided, discussions will be queried at the organisation level.",
|
|
},
|
|
"category": {
|
|
Type: "string",
|
|
Description: "Optional filter by discussion category ID. If provided, only discussions with this category are listed.",
|
|
},
|
|
"orderBy": {
|
|
Type: "string",
|
|
Description: "Order discussions by field. If provided, the 'direction' also needs to be provided.",
|
|
Enum: []any{"CREATED_AT", "UPDATED_AT"},
|
|
},
|
|
"direction": {
|
|
Type: "string",
|
|
Description: "Order direction.",
|
|
Enum: []any{"ASC", "DESC"},
|
|
},
|
|
},
|
|
Required: []string{"owner"},
|
|
}),
|
|
},
|
|
[]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 := OptionalParam[string](args, "repo")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
// when not provided, default to the .github repository
|
|
// this will query discussions at the organisation level
|
|
if repo == "" {
|
|
repo = ".github"
|
|
}
|
|
|
|
category, err := OptionalParam[string](args, "category")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
orderBy, err := OptionalParam[string](args, "orderBy")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
direction, err := OptionalParam[string](args, "direction")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
// Get pagination parameters and convert to GraphQL format
|
|
pagination, err := OptionalCursorPaginationParams(args)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
paginationParams, err := pagination.ToGraphQLParams()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
client, err := deps.GetGQLClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
|
|
}
|
|
|
|
var categoryID *githubv4.ID
|
|
if category != "" {
|
|
id := githubv4.ID(category)
|
|
categoryID = &id
|
|
}
|
|
|
|
vars := map[string]any{
|
|
"owner": githubv4.String(owner),
|
|
"repo": githubv4.String(repo),
|
|
"first": githubv4.Int(*paginationParams.First),
|
|
}
|
|
if paginationParams.After != nil {
|
|
vars["after"] = githubv4.String(*paginationParams.After)
|
|
} else {
|
|
vars["after"] = (*githubv4.String)(nil)
|
|
}
|
|
|
|
// this is an extra check in case the tool description is misinterpreted, because
|
|
// we shouldn't use ordering unless both a 'field' and 'direction' are provided
|
|
useOrdering := orderBy != "" && direction != ""
|
|
if useOrdering {
|
|
vars["orderByField"] = githubv4.DiscussionOrderField(orderBy)
|
|
vars["orderByDirection"] = githubv4.OrderDirection(direction)
|
|
}
|
|
|
|
if categoryID != nil {
|
|
vars["categoryId"] = *categoryID
|
|
}
|
|
|
|
discussionQuery := getQueryType(useOrdering, categoryID)
|
|
if err := client.Query(ctx, discussionQuery, vars); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
// Extract and convert all discussion nodes using the common interface
|
|
var discussions []*github.Discussion
|
|
var pageInfo PageInfoFragment
|
|
var totalCount githubv4.Int
|
|
if queryResult, ok := discussionQuery.(DiscussionQueryResult); ok {
|
|
fragment := queryResult.GetDiscussionFragment()
|
|
for _, node := range fragment.Nodes {
|
|
discussions = append(discussions, fragmentToDiscussion(node))
|
|
}
|
|
pageInfo = fragment.PageInfo
|
|
totalCount = fragment.TotalCount
|
|
}
|
|
|
|
// Create response with pagination info
|
|
response := map[string]any{
|
|
"discussions": discussions,
|
|
"pageInfo": map[string]any{
|
|
"hasNextPage": pageInfo.HasNextPage,
|
|
"hasPreviousPage": pageInfo.HasPreviousPage,
|
|
"startCursor": string(pageInfo.StartCursor),
|
|
"endCursor": string(pageInfo.EndCursor),
|
|
},
|
|
"totalCount": totalCount,
|
|
}
|
|
|
|
out, err := json.Marshal(response)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal discussions: %w", err)
|
|
}
|
|
result := utils.NewToolResultText(string(out))
|
|
// Discussion content is user-authored (untrusted); confidentiality
|
|
// follows repo visibility.
|
|
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoUserContent)
|
|
return result, nil, nil
|
|
},
|
|
)
|
|
}
|
|
|
|
func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
return NewTool(
|
|
ToolsetMetadataDiscussions,
|
|
mcp.Tool{
|
|
Name: "get_discussion",
|
|
Description: t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID"),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"),
|
|
ReadOnlyHint: true,
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"discussionNumber": {
|
|
Type: "number",
|
|
Description: "Discussion Number",
|
|
},
|
|
},
|
|
Required: []string{"owner", "repo", "discussionNumber"},
|
|
},
|
|
},
|
|
[]scopes.Scope{scopes.Repo},
|
|
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
|
|
// Decode params
|
|
var params struct {
|
|
Owner string
|
|
Repo string
|
|
DiscussionNumber int32
|
|
}
|
|
if err := mapstructure.WeakDecode(args, ¶ms); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
client, err := deps.GetGQLClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
|
|
}
|
|
|
|
var q struct {
|
|
Repository struct {
|
|
Discussion struct {
|
|
Number githubv4.Int
|
|
Title githubv4.String
|
|
Body githubv4.String
|
|
CreatedAt githubv4.DateTime
|
|
Closed githubv4.Boolean
|
|
IsAnswered githubv4.Boolean
|
|
AnswerChosenAt *githubv4.DateTime
|
|
URL githubv4.String `graphql:"url"`
|
|
Category struct {
|
|
Name githubv4.String
|
|
} `graphql:"category"`
|
|
} `graphql:"discussion(number: $discussionNumber)"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
vars := map[string]any{
|
|
"owner": githubv4.String(params.Owner),
|
|
"repo": githubv4.String(params.Repo),
|
|
"discussionNumber": githubv4.Int(params.DiscussionNumber),
|
|
}
|
|
if err := client.Query(ctx, &q, vars); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
d := q.Repository.Discussion
|
|
|
|
// Build response as map to include fields not present in go-github's Discussion struct.
|
|
// The go-github library's Discussion type lacks isAnswered and answerChosenAt fields,
|
|
// so we use map[string]interface{} for the response (consistent with other functions
|
|
// like ListDiscussions and GetDiscussionComments).
|
|
response := map[string]any{
|
|
"number": int(d.Number),
|
|
"title": sanitize.Sanitize(string(d.Title)),
|
|
"body": sanitize.Sanitize(string(d.Body)),
|
|
"url": string(d.URL),
|
|
"closed": bool(d.Closed),
|
|
"isAnswered": bool(d.IsAnswered),
|
|
"createdAt": d.CreatedAt.Time,
|
|
"category": map[string]any{
|
|
"name": string(d.Category.Name),
|
|
},
|
|
}
|
|
|
|
// Add optional timestamp fields if present
|
|
if d.AnswerChosenAt != nil {
|
|
response["answerChosenAt"] = d.AnswerChosenAt.Time
|
|
}
|
|
|
|
out, err := json.Marshal(response)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err)
|
|
}
|
|
|
|
result := utils.NewToolResultText(string(out))
|
|
// Discussion content is user-authored (untrusted); confidentiality
|
|
// follows repo visibility.
|
|
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent)
|
|
return result, nil, nil
|
|
},
|
|
)
|
|
}
|
|
|
|
func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
return NewTool(
|
|
ToolsetMetadataDiscussions,
|
|
mcp.Tool{
|
|
Name: "get_discussion_comments",
|
|
Description: t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion"),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"),
|
|
ReadOnlyHint: true,
|
|
},
|
|
InputSchema: WithCursorPagination(&jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name",
|
|
},
|
|
"discussionNumber": {
|
|
Type: "number",
|
|
Description: "Discussion Number",
|
|
},
|
|
"includeReplies": {
|
|
Type: "boolean",
|
|
Description: "When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false.",
|
|
},
|
|
},
|
|
Required: []string{"owner", "repo", "discussionNumber"},
|
|
}),
|
|
},
|
|
[]scopes.Scope{scopes.Repo},
|
|
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
|
|
// Decode params
|
|
var params struct {
|
|
Owner string
|
|
Repo string
|
|
DiscussionNumber int32
|
|
}
|
|
if err := mapstructure.WeakDecode(args, ¶ms); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
includeReplies, err := OptionalParam[bool](args, "includeReplies")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
// Get pagination parameters and convert to GraphQL format
|
|
pagination, err := OptionalCursorPaginationParams(args)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Check if pagination parameters were explicitly provided
|
|
_, perPageProvided := args["perPage"]
|
|
paginationExplicit := perPageProvided
|
|
|
|
paginationParams, err := pagination.ToGraphQLParams()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Use default of 30 if pagination was not explicitly provided
|
|
if !paginationExplicit {
|
|
defaultFirst := int32(DefaultGraphQLPageSize)
|
|
paginationParams.First = &defaultFirst
|
|
}
|
|
|
|
client, err := deps.GetGQLClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
|
|
}
|
|
|
|
vars := map[string]any{
|
|
"owner": githubv4.String(params.Owner),
|
|
"repo": githubv4.String(params.Repo),
|
|
"discussionNumber": githubv4.Int(params.DiscussionNumber),
|
|
"first": githubv4.Int(*paginationParams.First),
|
|
}
|
|
if paginationParams.After != nil {
|
|
vars["after"] = githubv4.String(*paginationParams.After)
|
|
} else {
|
|
vars["after"] = (*githubv4.String)(nil)
|
|
}
|
|
|
|
var comments []MinimalDiscussionComment
|
|
var pageInfo struct {
|
|
HasNextPage githubv4.Boolean
|
|
HasPreviousPage githubv4.Boolean
|
|
StartCursor githubv4.String
|
|
EndCursor githubv4.String
|
|
}
|
|
var totalCount int
|
|
|
|
if includeReplies {
|
|
var q struct {
|
|
Repository struct {
|
|
Discussion struct {
|
|
Comments struct {
|
|
Nodes []struct {
|
|
ID githubv4.ID
|
|
Body githubv4.String
|
|
IsAnswer githubv4.Boolean
|
|
Replies struct {
|
|
Nodes []struct {
|
|
ID githubv4.ID
|
|
Body githubv4.String
|
|
IsAnswer githubv4.Boolean
|
|
}
|
|
TotalCount int
|
|
} `graphql:"replies(first: 100)"`
|
|
}
|
|
PageInfo struct {
|
|
HasNextPage githubv4.Boolean
|
|
HasPreviousPage githubv4.Boolean
|
|
StartCursor githubv4.String
|
|
EndCursor githubv4.String
|
|
}
|
|
TotalCount int
|
|
} `graphql:"comments(first: $first, after: $after)"`
|
|
} `graphql:"discussion(number: $discussionNumber)"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
if err := client.Query(ctx, &q, vars); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
for _, c := range q.Repository.Discussion.Comments.Nodes {
|
|
comment := newMinimalDiscussionComment(fmt.Sprintf("%v", c.ID), string(c.Body), bool(c.IsAnswer))
|
|
comment.ReplyTotalCount = c.Replies.TotalCount
|
|
for _, r := range c.Replies.Nodes {
|
|
comment.Replies = append(comment.Replies, newMinimalDiscussionComment(fmt.Sprintf("%v", r.ID), string(r.Body), bool(r.IsAnswer)))
|
|
}
|
|
comments = append(comments, comment)
|
|
}
|
|
pageInfo = q.Repository.Discussion.Comments.PageInfo
|
|
totalCount = q.Repository.Discussion.Comments.TotalCount
|
|
} else {
|
|
var q struct {
|
|
Repository struct {
|
|
Discussion struct {
|
|
Comments struct {
|
|
Nodes []struct {
|
|
ID githubv4.ID
|
|
Body githubv4.String
|
|
IsAnswer githubv4.Boolean
|
|
}
|
|
PageInfo struct {
|
|
HasNextPage githubv4.Boolean
|
|
HasPreviousPage githubv4.Boolean
|
|
StartCursor githubv4.String
|
|
EndCursor githubv4.String
|
|
}
|
|
TotalCount int
|
|
} `graphql:"comments(first: $first, after: $after)"`
|
|
} `graphql:"discussion(number: $discussionNumber)"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
if err := client.Query(ctx, &q, vars); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
for _, c := range q.Repository.Discussion.Comments.Nodes {
|
|
comments = append(comments, newMinimalDiscussionComment(fmt.Sprintf("%v", c.ID), string(c.Body), bool(c.IsAnswer)))
|
|
}
|
|
pageInfo = q.Repository.Discussion.Comments.PageInfo
|
|
totalCount = q.Repository.Discussion.Comments.TotalCount
|
|
}
|
|
|
|
// Create response with pagination info
|
|
response := map[string]any{
|
|
"comments": comments,
|
|
"pageInfo": map[string]any{
|
|
"hasNextPage": pageInfo.HasNextPage,
|
|
"hasPreviousPage": pageInfo.HasPreviousPage,
|
|
"startCursor": string(pageInfo.StartCursor),
|
|
"endCursor": string(pageInfo.EndCursor),
|
|
},
|
|
"totalCount": totalCount,
|
|
}
|
|
|
|
out, err := json.Marshal(response)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal comments: %w", err)
|
|
}
|
|
|
|
result := utils.NewToolResultText(string(out))
|
|
// Discussion comments are user-authored (untrusted); confidentiality
|
|
// follows repo visibility.
|
|
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent)
|
|
return result, nil, nil
|
|
},
|
|
)
|
|
}
|
|
|
|
func DiscussionCommentWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
return NewTool(
|
|
ToolsetMetadataDiscussions,
|
|
mcp.Tool{
|
|
Name: "discussion_comment_write",
|
|
Description: t("TOOL_DISCUSSION_COMMENT_WRITE_DESCRIPTION", `Write operations for discussion comments.
|
|
Supports adding top-level comments, replying to existing comments, updating comment content, deleting comments, and marking or unmarking comments as the answer.`),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_DISCUSSION_COMMENT_WRITE_USER_TITLE", "Manage discussion comments"),
|
|
ReadOnlyHint: false,
|
|
DestructiveHint: jsonschema.Ptr(true),
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"method": {
|
|
Type: "string",
|
|
Description: `Write operation to perform on a discussion comment.
|
|
Options are:
|
|
- 'add' - adds a new top-level comment to a discussion.
|
|
- 'reply' - replies to a top-level discussion comment (GitHub Discussions only support one level of nesting).
|
|
- 'update' - updates an existing discussion comment.
|
|
- 'delete' - deletes a discussion comment.
|
|
- 'mark_answer' - marks a discussion comment as the answer (Q&A only).
|
|
- 'unmark_answer' - unmarks a discussion comment as the answer (Q&A only).
|
|
`,
|
|
Enum: []any{"add", "reply", "update", "delete", "mark_answer", "unmark_answer"},
|
|
},
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (required for 'add' and 'reply' methods)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name (required for 'add' and 'reply' methods)",
|
|
},
|
|
"discussionNumber": {
|
|
Type: "number",
|
|
Description: "Discussion number (required for 'add' and 'reply' methods)",
|
|
},
|
|
"body": {
|
|
Type: "string",
|
|
Description: "Comment content (required for 'add', 'reply', and 'update' methods)",
|
|
},
|
|
"commentNodeID": {
|
|
Type: "string",
|
|
Description: "The Node ID of the discussion comment (required for 'reply', 'update', 'delete', 'mark_answer', and 'unmark_answer' methods). For 'reply', this is the top-level comment to reply to; GitHub Discussions only support one level of nesting.",
|
|
},
|
|
},
|
|
Required: []string{"method"},
|
|
},
|
|
},
|
|
[]scopes.Scope{scopes.Repo},
|
|
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
|
|
method, err := RequiredParam[string](args, "method")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
client, err := deps.GetGQLClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
|
|
}
|
|
|
|
switch method {
|
|
case "add":
|
|
return addDiscussionComment(ctx, client, args)
|
|
case "reply":
|
|
return replyToDiscussionComment(ctx, client, args)
|
|
case "update":
|
|
return updateDiscussionComment(ctx, client, args)
|
|
case "delete":
|
|
return deleteDiscussionComment(ctx, client, args)
|
|
case "mark_answer":
|
|
return markDiscussionCommentAsAnswer(ctx, client, args)
|
|
case "unmark_answer":
|
|
return unmarkDiscussionCommentAsAnswer(ctx, client, args)
|
|
default:
|
|
return utils.NewToolResultError("invalid method, must be one of: 'add', 'reply', 'update', 'delete', 'mark_answer', 'unmark_answer'"), nil, nil
|
|
}
|
|
})
|
|
}
|
|
|
|
func addDiscussionComment(ctx context.Context, client *githubv4.Client, 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
|
|
}
|
|
discussionNumber, err := RequiredInt(args, "discussionNumber")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
body, err := RequiredParam[string](args, "body")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
// Get the discussion's node ID using its number
|
|
var q struct {
|
|
Repository struct {
|
|
Discussion struct {
|
|
ID githubv4.ID
|
|
} `graphql:"discussion(number: $discussionNumber)"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
vars := map[string]any{
|
|
"owner": githubv4.String(owner),
|
|
"repo": githubv4.String(repo),
|
|
"discussionNumber": githubv4.Int(discussionNumber), // #nosec G115 - discussion numbers are always small positive integers
|
|
}
|
|
if err := client.Query(ctx, &q, vars); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
input := githubv4.AddDiscussionCommentInput{
|
|
DiscussionID: q.Repository.Discussion.ID,
|
|
Body: githubv4.String(body),
|
|
}
|
|
|
|
var mutation struct {
|
|
AddDiscussionComment struct {
|
|
Comment struct {
|
|
ID githubv4.ID
|
|
URL githubv4.String `graphql:"url"`
|
|
}
|
|
} `graphql:"addDiscussionComment(input: $input)"`
|
|
}
|
|
|
|
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
comment := mutation.AddDiscussionComment.Comment
|
|
out, err := json.Marshal(MinimalResponse{
|
|
ID: fmt.Sprintf("%v", comment.ID),
|
|
URL: string(comment.URL),
|
|
})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal comment: %w", err)
|
|
}
|
|
|
|
return utils.NewToolResultText(string(out)), nil, nil
|
|
}
|
|
|
|
func requiredCommentNodeID(args map[string]any) (string, error) {
|
|
commentNodeID, err := RequiredParam[string](args, "commentNodeID")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if strings.TrimSpace(commentNodeID) == "" {
|
|
return "", fmt.Errorf("commentNodeID cannot be blank")
|
|
}
|
|
return commentNodeID, nil
|
|
}
|
|
|
|
func replyToDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
|
|
commentNodeID, err := requiredCommentNodeID(args)
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
discussionNumber, err := RequiredInt(args, "discussionNumber")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
body, err := RequiredParam[string](args, "body")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
// The GitHub API silently ignores an invalid ReplyToID and creates a top-level
|
|
// comment instead of returning an error, so we validate upfront that the node
|
|
// exists and is a DiscussionComment to give callers a clear failure.
|
|
var nodeQuery struct {
|
|
Node struct {
|
|
DiscussionComment struct {
|
|
ID *githubv4.ID
|
|
Discussion struct {
|
|
ID githubv4.ID
|
|
} `graphql:"discussion"`
|
|
} `graphql:"... on DiscussionComment"`
|
|
} `graphql:"node(id: $replyToID)"`
|
|
}
|
|
if err := client.Query(ctx, &nodeQuery, map[string]any{
|
|
"replyToID": githubv4.ID(commentNodeID),
|
|
}); err != nil {
|
|
return utils.NewToolResultError(fmt.Sprintf("failed to validate commentNodeID: %v", err)), nil, nil
|
|
}
|
|
if nodeQuery.Node.DiscussionComment.ID == nil || *nodeQuery.Node.DiscussionComment.ID == "" {
|
|
return utils.NewToolResultError(fmt.Sprintf("commentNodeID %q does not resolve to a valid discussion comment", commentNodeID)), nil, nil
|
|
}
|
|
|
|
// Get the discussion's node ID using its number
|
|
var q struct {
|
|
Repository struct {
|
|
Discussion struct {
|
|
ID githubv4.ID
|
|
} `graphql:"discussion(number: $discussionNumber)"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
vars := map[string]any{
|
|
"owner": githubv4.String(owner),
|
|
"repo": githubv4.String(repo),
|
|
"discussionNumber": githubv4.Int(discussionNumber), // #nosec G115 - discussion numbers are always small positive integers
|
|
}
|
|
if err := client.Query(ctx, &q, vars); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
if nodeQuery.Node.DiscussionComment.Discussion.ID != q.Repository.Discussion.ID {
|
|
return utils.NewToolResultError(
|
|
fmt.Sprintf("commentNodeID %q does not belong to discussion #%d in %s/%s", commentNodeID, discussionNumber, owner, repo),
|
|
), nil, nil
|
|
}
|
|
|
|
replyToID := githubv4.ID(commentNodeID)
|
|
input := githubv4.AddDiscussionCommentInput{
|
|
DiscussionID: nodeQuery.Node.DiscussionComment.Discussion.ID,
|
|
Body: githubv4.String(body),
|
|
ReplyToID: &replyToID,
|
|
}
|
|
|
|
var mutation struct {
|
|
AddDiscussionComment struct {
|
|
Comment struct {
|
|
ID githubv4.ID
|
|
URL githubv4.String `graphql:"url"`
|
|
}
|
|
} `graphql:"addDiscussionComment(input: $input)"`
|
|
}
|
|
|
|
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
comment := mutation.AddDiscussionComment.Comment
|
|
out, err := json.Marshal(MinimalResponse{
|
|
ID: fmt.Sprintf("%v", comment.ID),
|
|
URL: string(comment.URL),
|
|
})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal comment: %w", err)
|
|
}
|
|
|
|
return utils.NewToolResultText(string(out)), nil, nil
|
|
}
|
|
|
|
func updateDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
|
|
commentNodeID, err := requiredCommentNodeID(args)
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
body, err := RequiredParam[string](args, "body")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
input := githubv4.UpdateDiscussionCommentInput{
|
|
CommentID: githubv4.ID(commentNodeID),
|
|
Body: githubv4.String(body),
|
|
}
|
|
|
|
var mutation struct {
|
|
UpdateDiscussionComment struct {
|
|
Comment struct {
|
|
ID githubv4.ID
|
|
URL githubv4.String `graphql:"url"`
|
|
}
|
|
} `graphql:"updateDiscussionComment(input: $input)"`
|
|
}
|
|
|
|
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
comment := mutation.UpdateDiscussionComment.Comment
|
|
out, err := json.Marshal(MinimalResponse{
|
|
ID: fmt.Sprintf("%v", comment.ID),
|
|
URL: string(comment.URL),
|
|
})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal comment: %w", err)
|
|
}
|
|
|
|
return utils.NewToolResultText(string(out)), nil, nil
|
|
}
|
|
|
|
func deleteDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
|
|
commentNodeID, err := requiredCommentNodeID(args)
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
input := githubv4.DeleteDiscussionCommentInput{
|
|
ID: githubv4.ID(commentNodeID),
|
|
}
|
|
|
|
var mutation struct {
|
|
DeleteDiscussionComment struct {
|
|
Comment struct {
|
|
ID githubv4.ID
|
|
URL githubv4.String `graphql:"url"`
|
|
}
|
|
} `graphql:"deleteDiscussionComment(input: $input)"`
|
|
}
|
|
|
|
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
comment := mutation.DeleteDiscussionComment.Comment
|
|
out, err := json.Marshal(MinimalResponse{
|
|
ID: fmt.Sprintf("%v", comment.ID),
|
|
URL: string(comment.URL),
|
|
})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal comment: %w", err)
|
|
}
|
|
|
|
return utils.NewToolResultText(string(out)), nil, nil
|
|
}
|
|
|
|
func markDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
|
|
commentNodeID, err := requiredCommentNodeID(args)
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
input := githubv4.MarkDiscussionCommentAsAnswerInput{
|
|
ID: githubv4.ID(commentNodeID),
|
|
}
|
|
var mutation struct {
|
|
MarkDiscussionCommentAsAnswer struct {
|
|
Discussion struct {
|
|
ID githubv4.ID
|
|
URL githubv4.String `graphql:"url"`
|
|
}
|
|
} `graphql:"markDiscussionCommentAsAnswer(input: $input)"`
|
|
}
|
|
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
out, err := json.Marshal(struct {
|
|
DiscussionID string `json:"discussionID"`
|
|
DiscussionURL string `json:"discussionURL"`
|
|
}{
|
|
DiscussionID: fmt.Sprintf("%v", mutation.MarkDiscussionCommentAsAnswer.Discussion.ID),
|
|
DiscussionURL: string(mutation.MarkDiscussionCommentAsAnswer.Discussion.URL),
|
|
})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err)
|
|
}
|
|
|
|
return utils.NewToolResultText(string(out)), nil, nil
|
|
}
|
|
|
|
func unmarkDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) {
|
|
commentNodeID, err := requiredCommentNodeID(args)
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
input := githubv4.UnmarkDiscussionCommentAsAnswerInput{
|
|
ID: githubv4.ID(commentNodeID),
|
|
}
|
|
var mutation struct {
|
|
UnmarkDiscussionCommentAsAnswer struct {
|
|
Discussion struct {
|
|
ID githubv4.ID
|
|
URL githubv4.String `graphql:"url"`
|
|
}
|
|
} `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"`
|
|
}
|
|
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
out, err := json.Marshal(struct {
|
|
DiscussionID string `json:"discussionID"`
|
|
DiscussionURL string `json:"discussionURL"`
|
|
}{
|
|
DiscussionID: fmt.Sprintf("%v", mutation.UnmarkDiscussionCommentAsAnswer.Discussion.ID),
|
|
DiscussionURL: string(mutation.UnmarkDiscussionCommentAsAnswer.Discussion.URL),
|
|
})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err)
|
|
}
|
|
|
|
return utils.NewToolResultText(string(out)), nil, nil
|
|
}
|
|
|
|
func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
return NewTool(
|
|
ToolsetMetadataDiscussions,
|
|
mcp.Tool{
|
|
Name: "list_discussion_categories",
|
|
Description: t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"),
|
|
ReadOnlyHint: true,
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name. If not provided, discussion categories will be queried at the organisation level.",
|
|
},
|
|
},
|
|
Required: []string{"owner"},
|
|
},
|
|
},
|
|
[]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 := OptionalParam[string](args, "repo")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
// when not provided, default to the .github repository
|
|
// this will query discussion categories at the organisation level
|
|
if repo == "" {
|
|
repo = ".github"
|
|
}
|
|
|
|
client, err := deps.GetGQLClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
|
|
}
|
|
|
|
var q struct {
|
|
Repository struct {
|
|
DiscussionCategories struct {
|
|
Nodes []struct {
|
|
ID githubv4.ID
|
|
Name githubv4.String
|
|
}
|
|
PageInfo struct {
|
|
HasNextPage githubv4.Boolean
|
|
HasPreviousPage githubv4.Boolean
|
|
StartCursor githubv4.String
|
|
EndCursor githubv4.String
|
|
}
|
|
TotalCount int
|
|
} `graphql:"discussionCategories(first: $first)"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
vars := map[string]any{
|
|
"owner": githubv4.String(owner),
|
|
"repo": githubv4.String(repo),
|
|
"first": githubv4.Int(25),
|
|
}
|
|
if err := client.Query(ctx, &q, vars); err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
var categories []map[string]string
|
|
for _, c := range q.Repository.DiscussionCategories.Nodes {
|
|
categories = append(categories, map[string]string{
|
|
"id": fmt.Sprint(c.ID),
|
|
"name": string(c.Name),
|
|
})
|
|
}
|
|
|
|
// Create response with pagination info
|
|
response := map[string]any{
|
|
"categories": categories,
|
|
"pageInfo": map[string]any{
|
|
"hasNextPage": q.Repository.DiscussionCategories.PageInfo.HasNextPage,
|
|
"hasPreviousPage": q.Repository.DiscussionCategories.PageInfo.HasPreviousPage,
|
|
"startCursor": string(q.Repository.DiscussionCategories.PageInfo.StartCursor),
|
|
"endCursor": string(q.Repository.DiscussionCategories.PageInfo.EndCursor),
|
|
},
|
|
"totalCount": q.Repository.DiscussionCategories.TotalCount,
|
|
}
|
|
|
|
out, err := json.Marshal(response)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal discussion categories: %w", err)
|
|
}
|
|
result := utils.NewToolResultText(string(out))
|
|
// Discussion categories are repo-defined structural metadata
|
|
// (trusted); confidentiality follows repo visibility.
|
|
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata)
|
|
return result, nil, nil
|
|
},
|
|
)
|
|
}
|