Skip to content

feat: implement get_repository_discussions tool with GraphQL support #261

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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()
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
260 changes: 260 additions & 0 deletions pkg/github/discussions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
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"
)

// 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"`
CommentCount int `json:"commentCount"`
Comments []Comment `json:"comments,omitempty"`

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really glad we would be able to also have access to the comments

}

// 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")

Check failure on line 63 in pkg/github/discussions.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: var categoryId should be categoryID (revive)

Check failure on line 63 in pkg/github/discussions.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: var categoryId should be categoryID (revive)
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),

Check failure on line 83 in pkg/github/discussions.go

View workflow job for this annotation

GitHub Actions / lint

G115: integer overflow conversion int -> int32 (gosec)

Check failure on line 83 in pkg/github/discussions.go

View workflow job for this annotation

GitHub Actions / lint

G115: integer overflow conversion int -> int32 (gosec)
"after": (*githubv4.String)(nil), // For pagination - null means first page
}

// For pagination beyond the first page
// 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
cursorStr := githubv4.String(fmt.Sprintf("%d", (pagination.page-1)*pagination.perPage))
variables["after"] = &cursorStr
}

// Define the GraphQL query structure and query string based on whether categoryId is provided
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
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)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}

// 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
}
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
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 {
// 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,
CommentCount: node.Comments.TotalCount,
Comments: comments,
}
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
}
}
8 changes: 7 additions & 1 deletion pkg/github/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading