From dcc6da5afd7d2196ef60e934958d4a652ab59088 Mon Sep 17 00:00:00 2001 From: Avinash Sridhar Date: Sun, 13 Apr 2025 10:37:37 -0400 Subject: [PATCH 1/3] feat: implement get_repository_discussions tool with GraphQL support --- cmd/github-mcp-server/main.go | 25 ++- go.mod | 3 + go.sum | 6 + pkg/github/discussions.go | 199 ++++++++++++++++++++++ pkg/github/server.go | 8 +- script/test_get_repository_discussions.sh | 12 ++ 6 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 pkg/github/discussions.go create mode 100755 script/test_get_repository_discussions.sh diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 354ec3a9..459b9586 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -15,9 +15,11 @@ import ( gogithub "github.com/google/go-github/v69/github" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" + "golang.org/x/oauth2" ) var version = "version" @@ -119,9 +121,20 @@ func runStdioServer(cfg runConfig) error { if token == "" { cfg.logger.Fatal("GITHUB_PERSONAL_ACCESS_TOKEN not set") } - ghClient := gogithub.NewClient(nil).WithAuthToken(token) + + // Create OAuth2 token source + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + httpClient := oauth2.NewClient(ctx, ts) + + // Create REST API client + ghClient := gogithub.NewClient(httpClient) ghClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", version) + // Create GraphQL client + graphqlClient := githubv4.NewClient(httpClient) + // Check GH_HOST env var first, then fall back to viper config host := os.Getenv("GH_HOST") if host == "" { @@ -134,6 +147,9 @@ func runStdioServer(cfg runConfig) error { if err != nil { return fmt.Errorf("failed to create GitHub client with host: %w", err) } + + // Also update GraphQL endpoint for enterprise if needed + graphqlClient = githubv4.NewEnterpriseClient(fmt.Sprintf("https://%s/api/graphql", host), httpClient) } t, dumpTranslations := translations.TranslationHelper() @@ -146,11 +162,16 @@ func runStdioServer(cfg runConfig) error { return ghClient, nil // closing over client } + // Add function to get GraphQL client + getGraphQLClient := func(_ context.Context) (*githubv4.Client, error) { + return graphqlClient, nil // closing over graphql client + } + hooks := &server.Hooks{ OnBeforeInitialize: []server.OnBeforeInitializeFunc{beforeInit}, } // Create - ghServer := github.NewServer(getClient, version, cfg.readOnly, t, server.WithHooks(hooks)) + ghServer := github.NewServer(getClient, getGraphQLClient, version, cfg.readOnly, t, server.WithHooks(hooks)) stdioServer := server.NewStdioServer(ghServer) stdLogger := stdlog.New(cfg.logger.Writer(), "stdioserver", 0) diff --git a/go.mod b/go.mod index 858690cd..253f65ff 100644 --- a/go.mod +++ b/go.mod @@ -8,10 +8,12 @@ require ( github.com/google/go-github/v69 v69.2.0 github.com/mark3labs/mcp-go v0.18.0 github.com/migueleliasweb/go-github-mock v1.1.0 + github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 + golang.org/x/oauth2 v0.29.0 ) require ( @@ -41,6 +43,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect + github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.7.1 // indirect diff --git a/go.sum b/go.sum index 19d368de..7202d24a 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,10 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= +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= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= @@ -138,6 +142,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= +golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go new file mode 100644 index 00000000..074619dd --- /dev/null +++ b/pkg/github/discussions.go @@ -0,0 +1,199 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" +) + +// Discussion represents a GitHub Discussion with its essential fields +type Discussion struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + URL string `json:"url"` + Category string `json:"category"` + Author string `json:"author"` + Locked bool `json:"locked"` + UpvoteCount int `json:"upvoteCount"` +} + +// GetRepositoryDiscussions creates a tool to fetch discussions from a specific repository. +func GetRepositoryDiscussions(getGraphQLClient GetGraphQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_repository_discussions", + mcp.WithDescription(t("TOOL_GET_REPOSITORY_DISCUSSIONS_DESCRIPTION", "Get discussions from a specific GitHub repository")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := requiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + repo, err := requiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + categoryId, err := OptionalParam[string](request, "categoryId") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get GraphQL client + client, err := getGraphQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err) + } + + // Define GraphQL query variables + variables := map[string]interface{}{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + "first": githubv4.Int(pagination.perPage), + "after": (*githubv4.String)(nil), // For pagination - null means first page + "categoryId": (*githubv4.ID)(nil), // For category ID - null means no filter + } + + // For pagination beyond the first page + // TODO: Fix this to use actual cursor values + // This is a placeholder for the cursor logic + // In a real implementation, you should store and use actual cursor values + if pagination.perPage > 0 && pagination.page > 1 { + if pagination.page > 1 { + // We'd need an actual cursor here, but for simplicity we'll compute a rough offset + // In real implementation, you should store and use actual cursor values + cursorStr := githubv4.String(fmt.Sprintf("%d", (pagination.page-1)*pagination.perPage)) + variables["after"] = &cursorStr + } + + if categoryId != "" { + variables["categoryId"] = githubv4.ID(categoryId) + } + + // Define the GraphQL query structure + var query struct { + Repository struct { + Discussions struct { + TotalCount int + Nodes []struct { + ID githubv4.ID + Number int + Title string + Body string + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + URL githubv4.URI + Category struct { + Name string + } + Author struct { + Login string + } + Locked bool + UpvoteCount int + } + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + } `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + // Only include categoryId in the query if it was provided + if categoryId == "" { + // Redefine the query without the categoryId filter + query.Repository.Discussions = struct { + TotalCount int + Nodes []struct { + ID githubv4.ID + Number int + Title string + Body string + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + URL githubv4.URI + Category struct { + Name string + } + Author struct { + Login string + } + Locked bool + UpvoteCount int + } + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + }{} + } + + // Execute the GraphQL query + err = client.Query(ctx, &query, variables) + if err != nil { + return nil, fmt.Errorf("failed to query discussions: %w", err) + } + + // Convert the GraphQL response to our Discussion type + discussions := make([]Discussion, 0, len(query.Repository.Discussions.Nodes)) + for _, node := range query.Repository.Discussions.Nodes { + discussion := Discussion{ + ID: fmt.Sprintf("%v", node.ID), + Number: node.Number, + Title: node.Title, + Body: node.Body, + CreatedAt: node.CreatedAt.String(), + UpdatedAt: node.UpdatedAt.String(), + URL: node.URL.String(), + Category: node.Category.Name, + Author: node.Author.Login, + Locked: node.Locked, + UpvoteCount: node.UpvoteCount, + } + discussions = append(discussions, discussion) + } + + // Create the response + result := struct { + TotalCount int `json:"totalCount"` + Discussions []Discussion `json:"discussions"` + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + }{ + TotalCount: query.Repository.Discussions.TotalCount, + Discussions: discussions, + HasNextPage: query.Repository.Discussions.PageInfo.HasNextPage, + EndCursor: string(query.Repository.Discussions.PageInfo.EndCursor), + } + + // Marshal the result to JSON + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal discussions result: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/server.go b/pkg/github/server.go index da916b98..807acd12 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -12,12 +12,14 @@ import ( "github.com/google/go-github/v69/github" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" ) type GetClientFn func(context.Context) (*github.Client, error) +type GetGraphQLClientFn func(context.Context) (*githubv4.Client, error) // NewServer creates a new GitHub MCP server with the specified GH client and logger. -func NewServer(getClient GetClientFn, version string, readOnly bool, t translations.TranslationHelperFunc, opts ...server.ServerOption) *server.MCPServer { +func NewServer(getClient GetClientFn, getGraphQLClient GetGraphQLClientFn, version string, readOnly bool, t translations.TranslationHelperFunc, opts ...server.ServerOption) *server.MCPServer { // Add default options defaultOpts := []server.ServerOption{ server.WithResourceCapabilities(true, true), @@ -90,6 +92,10 @@ func NewServer(getClient GetClientFn, version string, readOnly bool, t translati // Add GitHub tools - Code Scanning s.AddTool(GetCodeScanningAlert(getClient, t)) s.AddTool(ListCodeScanningAlerts(getClient, t)) + + // Add GitHub tools - Discussions (GraphQL) + s.AddTool(GetRepositoryDiscussions(getGraphQLClient, t)) + return s } diff --git a/script/test_get_repository_discussions.sh b/script/test_get_repository_discussions.sh new file mode 100755 index 00000000..5bf94944 --- /dev/null +++ b/script/test_get_repository_discussions.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Test script for the get_repository_discussions function + +# Ensure the script exits on any error +set -e + +# Run the command and capture the output +echo '{"jsonrpc":"2.0","id":5,"params":{"name":"get_repository_discussions", "arguments":{"owner":"github", "repo":"engineering"}},"method":"tools/call"}' | go run ./cmd/github-mcp-server/main.go stdio | jq . + +# Print a message indicating the test is complete +echo "Test for get_repository_discussions completed." From 28729c80553d48b6b81fc55caaf4e37ff2a72e4a Mon Sep 17 00:00:00 2001 From: Avinash Sridhar Date: Sun, 13 Apr 2025 17:27:25 -0400 Subject: [PATCH 2/3] fix typo --- pkg/github/discussions.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 074619dd..7e1e1480 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -76,10 +76,7 @@ func GetRepositoryDiscussions(getGraphQLClient GetGraphQLClientFn, t translation } // For pagination beyond the first page - // TODO: Fix this to use actual cursor values - // This is a placeholder for the cursor logic - // In a real implementation, you should store and use actual cursor values - if pagination.perPage > 0 && pagination.page > 1 { + // TODO Fix if pagination.page > 1 { // We'd need an actual cursor here, but for simplicity we'll compute a rough offset // In real implementation, you should store and use actual cursor values From e01477937474264972d2e15c19681fece6129eaf Mon Sep 17 00:00:00 2001 From: Avinash Sridhar Date: Sun, 13 Apr 2025 18:36:57 -0400 Subject: [PATCH 3/3] feat: enhance GetRepositoryDiscussions to include comments and comment count --- pkg/github/discussions.go | 180 ++++++++++++++++++++++++++------------ 1 file changed, 122 insertions(+), 58 deletions(-) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 7e1e1480..f171cfc8 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -11,19 +11,29 @@ import ( "github.com/shurcooL/githubv4" ) +// Comment represents a comment on a GitHub Discussion +type Comment struct { + ID string `json:"id"` + Body string `json:"body"` + CreatedAt string `json:"createdAt"` + Author string `json:"author"` +} + // Discussion represents a GitHub Discussion with its essential fields type Discussion struct { - ID string `json:"id"` - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - URL string `json:"url"` - Category string `json:"category"` - Author string `json:"author"` - Locked bool `json:"locked"` - UpvoteCount int `json:"upvoteCount"` + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + URL string `json:"url"` + Category string `json:"category"` + Author string `json:"author"` + Locked bool `json:"locked"` + UpvoteCount int `json:"upvoteCount"` + CommentCount int `json:"commentCount"` + Comments []Comment `json:"comments,omitempty"` } // GetRepositoryDiscussions creates a tool to fetch discussions from a specific repository. @@ -68,11 +78,10 @@ func GetRepositoryDiscussions(getGraphQLClient GetGraphQLClientFn, t translation // Define GraphQL query variables variables := map[string]interface{}{ - "owner": githubv4.String(owner), - "name": githubv4.String(repo), - "first": githubv4.Int(pagination.perPage), - "after": (*githubv4.String)(nil), // For pagination - null means first page - "categoryId": (*githubv4.ID)(nil), // For category ID - null means no filter + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + "first": githubv4.Int(pagination.perPage), + "after": (*githubv4.String)(nil), // For pagination - null means first page } // For pagination beyond the first page @@ -84,11 +93,7 @@ func GetRepositoryDiscussions(getGraphQLClient GetGraphQLClientFn, t translation variables["after"] = &cursorStr } - if categoryId != "" { - variables["categoryId"] = githubv4.ID(categoryId) - } - - // Define the GraphQL query structure + // Define the GraphQL query structure and query string based on whether categoryId is provided var query struct { Repository struct { Discussions struct { @@ -109,42 +114,87 @@ func GetRepositoryDiscussions(getGraphQLClient GetGraphQLClientFn, t translation } Locked bool UpvoteCount int + Comments struct { + TotalCount int + Nodes []struct { + ID githubv4.ID + Body string + CreatedAt githubv4.DateTime + Author struct { + Login string + } + } + } `graphql:"comments(first: 10)"` } PageInfo struct { EndCursor githubv4.String HasNextPage bool } - } `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"` + } `graphql:"discussions(first: $first, after: $after)"` } `graphql:"repository(owner: $owner, name: $name)"` } - // Only include categoryId in the query if it was provided - if categoryId == "" { - // Redefine the query without the categoryId filter - query.Repository.Discussions = struct { - TotalCount int - Nodes []struct { - ID githubv4.ID - Number int - Title string - Body string - CreatedAt githubv4.DateTime - UpdatedAt githubv4.DateTime - URL githubv4.URI - Category struct { - Name string - } - Author struct { - Login string - } - Locked bool - UpvoteCount int + // Define a type for the Discussions GraphQL query to avoid duplication + type discussionQueryType struct { + TotalCount int + Nodes []struct { + ID githubv4.ID + Number int + Title string + Body string + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + URL githubv4.URI + Category struct { + Name string } - PageInfo struct { - EndCursor githubv4.String - HasNextPage bool + Author struct { + Login string } - }{} + Locked bool + UpvoteCount int + Comments struct { + TotalCount int + Nodes []struct { + ID githubv4.ID + Body string + CreatedAt githubv4.DateTime + Author struct { + Login string + } + } + } `graphql:"comments(first: 10)"` + } + PageInfo struct { + EndCursor githubv4.String + HasNextPage bool + } + } + + // Add categoryId to query if it was provided + if categoryId != "" { + variables["categoryId"] = githubv4.ID(categoryId) + // Use a separate query structure that includes the categoryId parameter + var queryWithCategory struct { + Repository struct { + Discussions discussionQueryType `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + // Execute the query with categoryId + err = client.Query(ctx, &queryWithCategory, variables) + if err != nil { + return nil, fmt.Errorf("failed to query discussions with category: %w", err) + } + + // Copy the results to our main query structure + query.Repository.Discussions = queryWithCategory.Repository.Discussions + } else { + // Execute the original query without categoryId + err = client.Query(ctx, &query, variables) + if err != nil { + return nil, fmt.Errorf("failed to query discussions: %w", err) + } } // Execute the GraphQL query @@ -156,18 +206,32 @@ func GetRepositoryDiscussions(getGraphQLClient GetGraphQLClientFn, t translation // Convert the GraphQL response to our Discussion type discussions := make([]Discussion, 0, len(query.Repository.Discussions.Nodes)) for _, node := range query.Repository.Discussions.Nodes { + // Process comments for this discussion + comments := make([]Comment, 0, len(node.Comments.Nodes)) + for _, commentNode := range node.Comments.Nodes { + comment := Comment{ + ID: fmt.Sprintf("%v", commentNode.ID), + Body: commentNode.Body, + CreatedAt: commentNode.CreatedAt.String(), + Author: commentNode.Author.Login, + } + comments = append(comments, comment) + } + discussion := Discussion{ - ID: fmt.Sprintf("%v", node.ID), - Number: node.Number, - Title: node.Title, - Body: node.Body, - CreatedAt: node.CreatedAt.String(), - UpdatedAt: node.UpdatedAt.String(), - URL: node.URL.String(), - Category: node.Category.Name, - Author: node.Author.Login, - Locked: node.Locked, - UpvoteCount: node.UpvoteCount, + ID: fmt.Sprintf("%v", node.ID), + Number: node.Number, + Title: node.Title, + Body: node.Body, + CreatedAt: node.CreatedAt.String(), + UpdatedAt: node.UpdatedAt.String(), + URL: node.URL.String(), + Category: node.Category.Name, + Author: node.Author.Login, + Locked: node.Locked, + UpvoteCount: node.UpvoteCount, + CommentCount: node.Comments.TotalCount, + Comments: comments, } discussions = append(discussions, discussion) }