1108 lines
36 KiB
Go
1108 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/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(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": string(d.Title),
|
|
"body": 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 := MinimalDiscussionComment{
|
|
ID: fmt.Sprintf("%v", c.ID),
|
|
Body: string(c.Body),
|
|
IsAnswer: bool(c.IsAnswer),
|
|
ReplyTotalCount: c.Replies.TotalCount,
|
|
}
|
|
for _, r := range c.Replies.Nodes {
|
|
comment.Replies = append(comment.Replies, MinimalDiscussionComment{
|
|
ID: fmt.Sprintf("%v", r.ID),
|
|
Body: string(r.Body),
|
|
IsAnswer: 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, MinimalDiscussionComment{
|
|
ID: fmt.Sprintf("%v", c.ID),
|
|
Body: string(c.Body),
|
|
IsAnswer: 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
|
|
},
|
|
)
|
|
}
|