Skip to content
Open
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
42 changes: 16 additions & 26 deletions pkg/github/discussions.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,23 @@
"github.com/shurcooL/githubv4"
)

func discussionOwnerOption() mcp.ToolOption {
return mcp.WithString("owner", mcp.Required(), mcp.Description(DescriptionRepositoryOwner))
}

func discussionRepoOption() mcp.ToolOption {
return mcp.WithString("repo", mcp.Required(), mcp.Description(DescriptionRepositoryName))
}

func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_discussions",
mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"),
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
discussionOwnerOption(),
discussionRepoOption(),
mcp.WithString("category",
mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."),
),
Expand Down Expand Up @@ -67,7 +69,7 @@
// Query with category filter (server-side filtering)
var query struct {
Repository struct {
Discussions struct {

Check warning on line 72 in pkg/github/discussions.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested anonymous struct into a named type for better readability and reusability.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvqPiwpZtFjN7cLojI&open=AZrvqPiwpZtFjN7cLojI&pullRequest=67
Nodes []struct {
Number githubv4.Int
Title githubv4.String
Expand Down Expand Up @@ -108,7 +110,7 @@
// Query without category filter
var query struct {
Repository struct {
Discussions struct {

Check warning on line 113 in pkg/github/discussions.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested anonymous struct into a named type for better readability and reusability.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvqPiwpZtFjN7cLojH&open=AZrvqPiwpZtFjN7cLojH&pullRequest=67
Nodes []struct {
Number githubv4.Int
Title githubv4.String
Expand Down Expand Up @@ -155,21 +157,15 @@
}
}

func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {

Check warning on line 160 in pkg/github/discussions.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the 'Get' prefix from this function name.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvqPiwpZtFjN7cLojF&open=AZrvqPiwpZtFjN7cLojF&pullRequest=67
return mcp.NewTool("get_discussion",
mcp.WithDescription(t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"),
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
discussionOwnerOption(),
discussionRepoOption(),
mcp.WithNumber("discussionNumber",
mcp.Required(),
mcp.Description("Discussion Number"),
Expand All @@ -192,13 +188,13 @@

var q struct {
Repository struct {
Discussion struct {

Check warning on line 191 in pkg/github/discussions.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested anonymous struct into a named type for better readability and reusability.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvqPiwpZtFjN7cLojJ&open=AZrvqPiwpZtFjN7cLojJ&pullRequest=67
Number githubv4.Int
Body githubv4.String
State githubv4.String
CreatedAt githubv4.DateTime
URL githubv4.String `graphql:"url"`
Category struct {

Check warning on line 197 in pkg/github/discussions.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested anonymous struct into a named type for better readability and reusability.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvqPiwpZtFjN7cLojK&open=AZrvqPiwpZtFjN7cLojK&pullRequest=67
Name githubv4.String
} `graphql:"category"`
} `graphql:"discussion(number: $discussionNumber)"`
Expand Down Expand Up @@ -234,15 +230,15 @@
}
}

func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {

Check warning on line 233 in pkg/github/discussions.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the 'Get' prefix from this function name.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvqPiwpZtFjN7cLojG&open=AZrvqPiwpZtFjN7cLojG&pullRequest=67
return mcp.NewTool("get_discussion_comments",
mcp.WithDescription(t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"),
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
discussionOwnerOption(),
discussionRepoOption(),
mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
Expand All @@ -263,8 +259,8 @@

var q struct {
Repository struct {
Discussion struct {

Check warning on line 262 in pkg/github/discussions.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested anonymous struct into a named type for better readability and reusability.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvqPiwpZtFjN7cLojL&open=AZrvqPiwpZtFjN7cLojL&pullRequest=67
Comments struct {

Check warning on line 263 in pkg/github/discussions.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested anonymous struct into a named type for better readability and reusability.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvqPiwpZtFjN7cLojM&open=AZrvqPiwpZtFjN7cLojM&pullRequest=67
Nodes []struct {
Body githubv4.String
}
Expand Down Expand Up @@ -301,14 +297,8 @@
Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"),
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
discussionOwnerOption(),
discussionRepoOption(),
mcp.WithNumber("first",
mcp.Description("Number of categories to return per page (min 1, max 100)"),
mcp.Min(1),
Expand Down Expand Up @@ -360,7 +350,7 @@
}
var q struct {
Repository struct {
DiscussionCategories struct {

Check warning on line 353 in pkg/github/discussions.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested anonymous struct into a named type for better readability and reusability.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvqPiwpZtFjN7cLojN&open=AZrvqPiwpZtFjN7cLojN&pullRequest=67
Nodes []struct {
ID githubv4.ID
Name githubv4.String
Expand Down
208 changes: 116 additions & 92 deletions pkg/github/repository_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
)

// GetRepositoryResourceContent defines the resource template and handler for getting repository content.
func GetRepositoryResourceContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {

Check warning on line 23 in pkg/github/repository_resource.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the 'Get' prefix from this function name.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvttwapZtFjN7cLxh1&open=AZrvttwapZtFjN7cLxh1&pullRequest=67
return mcp.NewResourceTemplate(
"repo://{owner}/{repo}/contents{/path*}", // Resource template
t("RESOURCE_REPOSITORY_CONTENT_DESCRIPTION", "Repository Content"),
Expand All @@ -29,7 +29,7 @@
}

// GetRepositoryResourceBranchContent defines the resource template and handler for getting repository content for a branch.
func GetRepositoryResourceBranchContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {

Check warning on line 32 in pkg/github/repository_resource.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the 'Get' prefix from this function name.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvttwapZtFjN7cLxh2&open=AZrvttwapZtFjN7cLxh2&pullRequest=67
return mcp.NewResourceTemplate(
"repo://{owner}/{repo}/refs/heads/{branch}/contents{/path*}", // Resource template
t("RESOURCE_REPOSITORY_CONTENT_BRANCH_DESCRIPTION", "Repository Content for specific branch"),
Expand All @@ -38,7 +38,7 @@
}

// GetRepositoryResourceCommitContent defines the resource template and handler for getting repository content for a commit.
func GetRepositoryResourceCommitContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {

Check warning on line 41 in pkg/github/repository_resource.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the 'Get' prefix from this function name.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvttwapZtFjN7cLxh3&open=AZrvttwapZtFjN7cLxh3&pullRequest=67
return mcp.NewResourceTemplate(
"repo://{owner}/{repo}/sha/{sha}/contents{/path*}", // Resource template
t("RESOURCE_REPOSITORY_CONTENT_COMMIT_DESCRIPTION", "Repository Content for specific commit"),
Expand All @@ -47,7 +47,7 @@
}

// GetRepositoryResourceTagContent defines the resource template and handler for getting repository content for a tag.
func GetRepositoryResourceTagContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {

Check warning on line 50 in pkg/github/repository_resource.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the 'Get' prefix from this function name.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvttwapZtFjN7cLxh4&open=AZrvttwapZtFjN7cLxh4&pullRequest=67
return mcp.NewResourceTemplate(
"repo://{owner}/{repo}/refs/tags/{tag}/contents{/path*}", // Resource template
t("RESOURCE_REPOSITORY_CONTENT_TAG_DESCRIPTION", "Repository Content for specific tag"),
Expand All @@ -56,7 +56,7 @@
}

// GetRepositoryResourcePrContent defines the resource template and handler for getting repository content for a pull request.
func GetRepositoryResourcePrContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {

Check warning on line 59 in pkg/github/repository_resource.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the 'Get' prefix from this function name.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvttwapZtFjN7cLxh5&open=AZrvttwapZtFjN7cLxh5&pullRequest=67
return mcp.NewResourceTemplate(
"repo://{owner}/{repo}/refs/pull/{prNumber}/head/contents{/path*}", // Resource template
t("RESOURCE_REPOSITORY_CONTENT_PR_DESCRIPTION", "Repository Content for specific pull request"),
Expand All @@ -64,129 +64,153 @@
RepositoryResourceContentsHandler(getClient, getRawClient)
}

// RepositoryResourceContentsHandler returns a handler function for repository content requests.
func RepositoryResourceContentsHandler(getClient GetClientFn, getRawClient raw.GetRawClientFn) func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
// the matcher will give []string with one element
// https://github.com/mark3labs/mcp-go/pull/54
o, ok := request.Params.Arguments["owner"].([]string)
if !ok || len(o) == 0 {
return nil, errors.New("owner is required")
}
owner := o[0]
// extractStringArg extracts a string argument from the request arguments
func extractStringArg(args map[string]any, key string) (string, bool) {
v, ok := args[key].([]string)
if !ok || len(v) == 0 {
return "", false
}
return v[0], true
}

r, ok := request.Params.Arguments["repo"].([]string)
if !ok || len(r) == 0 {
return nil, errors.New("repo is required")
}
repo := r[0]
// extractPathArg extracts and joins path parts from the request arguments
func extractPathArg(args map[string]any) string {
p, ok := args["path"].([]string)
if !ok {
return ""
}
return strings.Join(p, "/")
}

// path should be a joined list of the path parts
path := ""
p, ok := request.Params.Arguments["path"].([]string)
if ok {
path = strings.Join(p, "/")
}
// resolveRefOptions resolves the ref options based on the request arguments
func resolveRefOptions(ctx context.Context, args map[string]any, owner, repo string, getClient GetClientFn) (*github.RepositoryContentGetOptions, *raw.ContentOpts, error) {
opts := &github.RepositoryContentGetOptions{}
rawOpts := &raw.ContentOpts{}

opts := &github.RepositoryContentGetOptions{}
rawOpts := &raw.ContentOpts{}
if sha, ok := extractStringArg(args, "sha"); ok {
opts.Ref = sha
rawOpts.SHA = sha
return opts, rawOpts, nil
}

if branch, ok := extractStringArg(args, "branch"); ok {
opts.Ref = "refs/heads/" + branch
rawOpts.Ref = "refs/heads/" + branch
return opts, rawOpts, nil
}

sha, ok := request.Params.Arguments["sha"].([]string)
if ok && len(sha) > 0 {
opts.Ref = sha[0]
rawOpts.SHA = sha[0]
if tag, ok := extractStringArg(args, "tag"); ok {
opts.Ref = "refs/tags/" + tag
rawOpts.Ref = "refs/tags/" + tag
return opts, rawOpts, nil
}

if prNumberStr, ok := extractStringArg(args, "prNumber"); ok {
githubClient, err := getClient(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
prNum, err := strconv.Atoi(prNumberStr)
if err != nil {
return nil, nil, fmt.Errorf("invalid pull request number: %w", err)
}
pr, _, err := githubClient.PullRequests.Get(ctx, owner, repo, prNum)
if err != nil {
return nil, nil, fmt.Errorf("failed to get pull request: %w", err)
}
sha := pr.GetHead().GetSHA()
rawOpts.SHA = sha
opts.Ref = sha
}

return opts, rawOpts, nil
}

// determineMimeType determines the MIME type for a file based on extension and response headers
func determineMimeType(path string, contentTypeHeader string) string {

Check warning on line 130 in pkg/github/repository_resource.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Group together these consecutive parameters of the same type.

See more on https://sonarcloud.io/project/issues?id=COG-GTM_github-mcp-server&issues=AZrvttwapZtFjN7cLxh6&open=AZrvttwapZtFjN7cLxh6&pullRequest=67
ext := filepath.Ext(path)
if ext == ".md" {
return "text/markdown"
}
if contentTypeHeader != "" {
return contentTypeHeader
}
return mime.TypeByExtension(ext)
}

branch, ok := request.Params.Arguments["branch"].([]string)
if ok && len(branch) > 0 {
opts.Ref = "refs/heads/" + branch[0]
rawOpts.Ref = "refs/heads/" + branch[0]
// buildResourceContents builds the appropriate resource contents based on MIME type
func buildResourceContents(uri, mimeType string, content []byte) []mcp.ResourceContents {
if strings.HasPrefix(mimeType, "text") || strings.HasPrefix(mimeType, "application") {
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: uri,
MIMEType: mimeType,
Text: string(content),
},
}
}
return []mcp.ResourceContents{
mcp.BlobResourceContents{
URI: uri,
MIMEType: mimeType,
Blob: base64.StdEncoding.EncodeToString(content),
},
}
}

// RepositoryResourceContentsHandler returns a handler function for repository content requests.
func RepositoryResourceContentsHandler(getClient GetClientFn, getRawClient raw.GetRawClientFn) func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
args := request.Params.Arguments

tag, ok := request.Params.Arguments["tag"].([]string)
if ok && len(tag) > 0 {
opts.Ref = "refs/tags/" + tag[0]
rawOpts.Ref = "refs/tags/" + tag[0]
owner, ok := extractStringArg(args, "owner")
if !ok {
return nil, errors.New("owner is required")
}
prNumber, ok := request.Params.Arguments["prNumber"].([]string)
if ok && len(prNumber) > 0 {
// fetch the PR from the API to get the latest commit and use SHA
githubClient, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
prNum, err := strconv.Atoi(prNumber[0])
if err != nil {
return nil, fmt.Errorf("invalid pull request number: %w", err)
}
pr, _, err := githubClient.PullRequests.Get(ctx, owner, repo, prNum)
if err != nil {
return nil, fmt.Errorf("failed to get pull request: %w", err)
}
sha := pr.GetHead().GetSHA()
rawOpts.SHA = sha
opts.Ref = sha

repo, ok := extractStringArg(args, "repo")
if !ok {
return nil, errors.New("repo is required")
}
// if it's a directory

path := extractPathArg(args)
if path == "" || strings.HasSuffix(path, "/") {
return nil, fmt.Errorf("directories are not supported: %s", path)
}
rawClient, err := getRawClient(ctx)

_, rawOpts, err := resolveRefOptions(ctx, args, owner, repo, getClient)
if err != nil {
return nil, err
}

rawClient, err := getRawClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub raw content client: %w", err)
}

resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts)
defer func() {
_ = resp.Body.Close()
}()
// If the raw content is not found, we will fall back to the GitHub API (in case it is a directory)
switch {
case err != nil:
if err != nil {
return nil, fmt.Errorf("failed to get raw content: %w", err)
case resp.StatusCode == http.StatusOK:
ext := filepath.Ext(path)
mimeType := resp.Header.Get("Content-Type")
if ext == ".md" {
mimeType = "text/markdown"
} else if mimeType == "" {
mimeType = mime.TypeByExtension(ext)
}
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode == http.StatusOK {
content, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read file content: %w", err)
}
mimeType := determineMimeType(path, resp.Header.Get("Content-Type"))
return buildResourceContents(request.Params.URI, mimeType, content), nil
}

switch {
case strings.HasPrefix(mimeType, "text"), strings.HasPrefix(mimeType, "application"):
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: request.Params.URI,
MIMEType: mimeType,
Text: string(content),
},
}, nil
default:
return []mcp.ResourceContents{
mcp.BlobResourceContents{
URI: request.Params.URI,
MIMEType: mimeType,
Blob: base64.StdEncoding.EncodeToString(content),
},
}, nil
}
case resp.StatusCode != http.StatusNotFound:
// If we got a response but it is not 200 OK, we return an error
if resp.StatusCode != http.StatusNotFound {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return nil, fmt.Errorf("failed to fetch raw content: %s", string(body))
default:
// This should be unreachable because GetContents should return an error if neither file nor directory content is found.
return nil, errors.New("404 Not Found")
}

return nil, errors.New("404 Not Found")
}
}