Files

323 lines
11 KiB
Go

package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/github/github-mcp-server/pkg/translations"
"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, getGraphQLClient GetGraphQLClientFn, version string, readOnly bool, t translations.TranslationHelperFunc, opts ...server.ServerOption) *server.MCPServer {
// Add default options
defaultOpts := []server.ServerOption{
server.WithResourceCapabilities(true, true),
server.WithLogging(),
}
opts = append(defaultOpts, opts...)
// Create a new MCP server
s := server.NewMCPServer(
"github-mcp-server",
version,
opts...,
)
// Add GitHub Resources
s.AddResourceTemplate(GetRepositoryResourceContent(getClient, t))
s.AddResourceTemplate(GetRepositoryResourceBranchContent(getClient, t))
s.AddResourceTemplate(GetRepositoryResourceCommitContent(getClient, t))
s.AddResourceTemplate(GetRepositoryResourceTagContent(getClient, t))
s.AddResourceTemplate(GetRepositoryResourcePrContent(getClient, t))
// Add GitHub tools - Issues
s.AddTool(GetIssue(getClient, t))
s.AddTool(SearchIssues(getClient, t))
s.AddTool(ListIssues(getClient, t))
s.AddTool(GetIssueComments(getClient, t))
if !readOnly {
s.AddTool(CreateIssue(getClient, t))
s.AddTool(AddIssueComment(getClient, t))
s.AddTool(UpdateIssue(getClient, t))
}
// Add GitHub tools - Pull Requests
s.AddTool(GetPullRequest(getClient, t))
s.AddTool(ListPullRequests(getClient, t))
s.AddTool(GetPullRequestFiles(getClient, t))
s.AddTool(GetPullRequestStatus(getClient, t))
s.AddTool(GetPullRequestComments(getClient, t))
s.AddTool(GetPullRequestReviews(getClient, t))
if !readOnly {
s.AddTool(MergePullRequest(getClient, t))
s.AddTool(UpdatePullRequestBranch(getClient, t))
s.AddTool(CreatePullRequestReview(getClient, t))
s.AddTool(CreatePullRequest(getClient, t))
s.AddTool(UpdatePullRequest(getClient, t))
s.AddTool(AddPullRequestReviewComment(getClient, t))
}
// Add GitHub tools - Repositories
s.AddTool(SearchRepositories(getClient, t))
s.AddTool(GetFileContents(getClient, t))
s.AddTool(GetCommit(getClient, t))
s.AddTool(ListCommits(getClient, t))
s.AddTool(ListBranches(getClient, t))
if !readOnly {
s.AddTool(CreateOrUpdateFile(getClient, t))
s.AddTool(CreateRepository(getClient, t))
s.AddTool(ForkRepository(getClient, t))
s.AddTool(CreateBranch(getClient, t))
s.AddTool(PushFiles(getClient, t))
}
// Add GitHub tools - Search
s.AddTool(SearchCode(getClient, t))
s.AddTool(SearchUsers(getClient, t))
// Add GitHub tools - Users
s.AddTool(GetMe(getClient, t))
// 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
}
// GetMe creates a tool to get details of the authenticated user.
func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_me",
mcp.WithDescription(t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request include \"me\", \"my\"...")),
mcp.WithString("reason",
mcp.Description("Optional: reason the session was created"),
),
),
func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
user, resp, err := client.Users.Get(ctx, "")
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to get user: %s", string(body))), nil
}
r, err := json.Marshal(user)
if err != nil {
return nil, fmt.Errorf("failed to marshal user: %w", err)
}
return mcp.NewToolResultText(string(r)), nil
}
}
// OptionalParamOK is a helper function that can be used to fetch a requested parameter from the request.
// It returns the value, a boolean indicating if the parameter was present, and an error if the type is wrong.
func OptionalParamOK[T any](r mcp.CallToolRequest, p string) (value T, ok bool, err error) {
// Check if the parameter is present in the request
val, exists := r.Params.Arguments[p]
if !exists {
// Not present, return zero value, false, no error
return
}
// Check if the parameter is of the expected type
value, ok = val.(T)
if !ok {
// Present but wrong type
err = fmt.Errorf("parameter %s is not of type %T, is %T", p, value, val)
ok = true // Set ok to true because the parameter *was* present, even if wrong type
return
}
// Present and correct type
ok = true
return
}
// isAcceptedError checks if the error is an accepted error.
func isAcceptedError(err error) bool {
var acceptedError *github.AcceptedError
return errors.As(err, &acceptedError)
}
// requiredParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request.
// 2. Checks if the parameter is of the expected type.
// 3. Checks if the parameter is not empty, i.e: non-zero value
func requiredParam[T comparable](r mcp.CallToolRequest, p string) (T, error) {
var zero T
// Check if the parameter is present in the request
if _, ok := r.Params.Arguments[p]; !ok {
return zero, fmt.Errorf("missing required parameter: %s", p)
}
// Check if the parameter is of the expected type
if _, ok := r.Params.Arguments[p].(T); !ok {
return zero, fmt.Errorf("parameter %s is not of type %T", p, zero)
}
if r.Params.Arguments[p].(T) == zero {
return zero, fmt.Errorf("missing required parameter: %s", p)
}
return r.Params.Arguments[p].(T), nil
}
// RequiredInt is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request.
// 2. Checks if the parameter is of the expected type.
// 3. Checks if the parameter is not empty, i.e: non-zero value
func RequiredInt(r mcp.CallToolRequest, p string) (int, error) {
v, err := requiredParam[float64](r, p)
if err != nil {
return 0, err
}
return int(v), nil
}
// OptionalParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
// 2. If it is present, it checks if the parameter is of the expected type and returns it
func OptionalParam[T any](r mcp.CallToolRequest, p string) (T, error) {
var zero T
// Check if the parameter is present in the request
if _, ok := r.Params.Arguments[p]; !ok {
return zero, nil
}
// Check if the parameter is of the expected type
if _, ok := r.Params.Arguments[p].(T); !ok {
return zero, fmt.Errorf("parameter %s is not of type %T, is %T", p, zero, r.Params.Arguments[p])
}
return r.Params.Arguments[p].(T), nil
}
// OptionalIntParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
// 2. If it is present, it checks if the parameter is of the expected type and returns it
func OptionalIntParam(r mcp.CallToolRequest, p string) (int, error) {
v, err := OptionalParam[float64](r, p)
if err != nil {
return 0, err
}
return int(v), nil
}
// OptionalIntParamWithDefault is a helper function that can be used to fetch a requested parameter from the request
// similar to optionalIntParam, but it also takes a default value.
func OptionalIntParamWithDefault(r mcp.CallToolRequest, p string, d int) (int, error) {
v, err := OptionalIntParam(r, p)
if err != nil {
return 0, err
}
if v == 0 {
return d, nil
}
return v, nil
}
// OptionalStringArrayParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
// 2. If it is present, iterates the elements and checks each is a string
func OptionalStringArrayParam(r mcp.CallToolRequest, p string) ([]string, error) {
// Check if the parameter is present in the request
if _, ok := r.Params.Arguments[p]; !ok {
return []string{}, nil
}
switch v := r.Params.Arguments[p].(type) {
case nil:
return []string{}, nil
case []string:
return v, nil
case []any:
strSlice := make([]string, len(v))
for i, v := range v {
s, ok := v.(string)
if !ok {
return []string{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v)
}
strSlice[i] = s
}
return strSlice, nil
default:
return []string{}, fmt.Errorf("parameter %s could not be coerced to []string, is %T", p, r.Params.Arguments[p])
}
}
// WithPagination returns a ToolOption that adds "page" and "perPage" parameters to the tool.
// The "page" parameter is optional, min 1. The "perPage" parameter is optional, min 1, max 100.
func WithPagination() mcp.ToolOption {
return func(tool *mcp.Tool) {
mcp.WithNumber("page",
mcp.Description("Page number for pagination (min 1)"),
mcp.Min(1),
)(tool)
mcp.WithNumber("perPage",
mcp.Description("Results per page for pagination (min 1, max 100)"),
mcp.Min(1),
mcp.Max(100),
)(tool)
}
}
type PaginationParams struct {
page int
perPage int
}
// OptionalPaginationParams returns the "page" and "perPage" parameters from the request,
// or their default values if not present, "page" default is 1, "perPage" default is 30.
// In future, we may want to make the default values configurable, or even have this
// function returned from `withPagination`, where the defaults are provided alongside
// the min/max values.
func OptionalPaginationParams(r mcp.CallToolRequest) (PaginationParams, error) {
page, err := OptionalIntParamWithDefault(r, "page", 1)
if err != nil {
return PaginationParams{}, err
}
perPage, err := OptionalIntParamWithDefault(r, "perPage", 30)
if err != nil {
return PaginationParams{}, err
}
return PaginationParams{
page: page,
perPage: perPage,
}, nil
}