Update mcp server with latest google/go-github API (#1358)
Build and Test Go Project / build (ubuntu-latest) (push) Has been cancelled
Build and Test Go Project / build (windows-latest) (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Docker / build (push) Has been cancelled
Build and Test Go Project / build (macos-latest) (push) Has been cancelled
GoReleaser Release / release (push) Has been cancelled
License Check / license-check (push) Has been cancelled
Publish to MCP Registry / publish (push) Has been cancelled

* licences update from required CI build

* fixes licences

* implements new helpers to support google/go-github v77 API

* upgrades toolset to leverage google/go-github v77 with the exception of Update and Delete items

* refactor string conversion helpers

* reverts migration google/go-github GetProjectItem due to bug with the underlying library implementation

* additional refactoring for server helpers based on recent updates to google/go-github

* test updates based on underlying lib requirements

* resolves licences conflicts with script/licenses

* cleanup

* reduce change diff

* updates helper docs to reflect to methods

* upgrades delete projects item to google/go-github

* returns error from parsing string to int64

* improved OptionalBigIntArrayParam doc

* improves implementation for RequiredBigInt

* improves documentation for temporary fieldSelectionOptions struct
This commit is contained in:
Jonathan
2025-11-07 10:31:17 -07:00
committed by GitHub
parent cf0e05e300
commit b68bec003d
3 changed files with 151 additions and 124 deletions
+72 -120
View File
@@ -69,13 +69,13 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
var resp *github.Response
var projects []*github.ProjectV2
minimalProjects := []MinimalProject{}
var queryPtr *string
if queryStr != "" {
queryPtr = &queryStr
}
minimalProjects := []MinimalProject{}
opts := &github.ListProjectsOptions{
ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage},
Query: queryPtr,
@@ -237,27 +237,19 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu
return mcp.NewToolResultError(err.Error()), nil
}
var url string
var resp *github.Response
var projectFields []*github.ProjectV2Field
opts := &github.ListProjectsOptions{
ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage},
}
if ownerType == "org" {
url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields", owner, projectNumber)
projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts)
} else {
url = fmt.Sprintf("users/%s/projectsV2/%d/fields", owner, projectNumber)
}
projectFields := []projectV2Field{}
opts := paginationOptions{PerPage: perPage}
url, err = addOptions(url, opts)
if err != nil {
return nil, fmt.Errorf("failed to add options to request: %w", err)
projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts)
}
httpRequest, err := client.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(ctx, httpRequest, &projectFields)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to list project fields",
@@ -317,7 +309,7 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
fieldID, err := RequiredInt(req, "field_id")
fieldID, err := RequiredBigInt(req, "field_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -326,21 +318,15 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc
return mcp.NewToolResultError(err.Error()), nil
}
var url string
var resp *github.Response
var projectField *github.ProjectV2Field
if ownerType == "org" {
url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID)
} else {
url = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID)
}
projectField := projectV2Field{}
httpRequest, err := client.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(ctx, httpRequest, &projectField)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get project field",
@@ -416,41 +402,37 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
fields, err := OptionalStringArrayParam(req, "fields")
fields, err := OptionalBigIntArrayParam(req, "fields")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
client, err := getClient(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
var url string
var resp *github.Response
var projectItems []*github.ProjectV2Item
var queryPtr *string
if queryStr != "" {
queryPtr = &queryStr
}
opts := &github.ListProjectItemsOptions{
Fields: fields,
ListProjectsOptions: github.ListProjectsOptions{
ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: &perPage},
Query: queryPtr,
},
}
if ownerType == "org" {
url = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber)
projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts)
} else {
url = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber)
}
projectItems := []projectV2Item{}
opts := listProjectItemsOptions{
paginationOptions: paginationOptions{PerPage: perPage},
filterQueryOptions: filterQueryOptions{Query: queryStr},
fieldSelectionOptions: fieldSelectionOptions{Fields: fields},
projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts)
}
url, err = addOptions(url, opts)
if err != nil {
return nil, fmt.Errorf("failed to add options to request: %w", err)
}
httpRequest, err := client.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(ctx, httpRequest, &projectItems)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
ProjectListFailedError,
@@ -518,11 +500,11 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
itemID, err := RequiredInt(req, "item_id")
itemID, err := RequiredBigInt(req, "item_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
fields, err := OptionalStringArrayParam(req, "fields")
fields, err := OptionalBigIntArrayParam(req, "fields")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -624,7 +606,7 @@ func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
itemID, err := RequiredInt(req, "item_id")
itemID, err := RequiredBigInt(req, "item_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -642,24 +624,20 @@ func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
return mcp.NewToolResultError(err.Error()), nil
}
var projectsURL string
if ownerType == "org" {
projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber)
} else {
projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber)
}
newItem := &newProjectItem{
ID: int64(itemID),
newItem := &github.AddProjectItemOptions{
ID: itemID,
Type: toNewProjectType(itemType),
}
httpRequest, err := client.NewRequest("POST", projectsURL, newItem)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
addedItem := projectV2Item{}
resp, err := client.Do(ctx, httpRequest, &addedItem)
var resp *github.Response
var addedItem *github.ProjectV2Item
if ownerType == "org" {
addedItem, resp, err = client.Projects.AddOrganizationProjectItem(ctx, owner, projectNumber, newItem)
} else {
addedItem, resp, err = client.Projects.AddUserProjectItem(ctx, owner, projectNumber, newItem)
}
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
ProjectAddFailedError,
@@ -827,7 +805,7 @@ func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFu
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
itemID, err := RequiredInt(req, "item_id")
itemID, err := RequiredBigInt(req, "item_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -836,19 +814,13 @@ func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFu
return mcp.NewToolResultError(err.Error()), nil
}
var projectsURL string
var resp *github.Response
if ownerType == "org" {
projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID)
} else {
projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID)
}
httpRequest, err := client.NewRequest("DELETE", projectsURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(ctx, httpRequest, nil)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
ProjectDeleteFailedError,
@@ -869,9 +841,10 @@ func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFu
}
}
type newProjectItem struct {
ID int64 `json:"id,omitempty"`
Type string `json:"type,omitempty"`
type fieldSelectionOptions struct {
// Specific list of field IDs to include in the response. If not provided, only the title field is included.
// The comma tag encodes the slice as comma-separated values: fields=102589,985201,169875
Fields []int64 `url:"fields,omitempty,comma"`
}
type updateProjectItemPayload struct {
@@ -883,17 +856,6 @@ type updateProjectItem struct {
Value any `json:"value"`
}
type projectV2Field struct {
ID *int64 `json:"id,omitempty"` // The unique identifier for this field.
NodeID string `json:"node_id,omitempty"` // The GraphQL node ID for this field.
Name string `json:"name,omitempty"` // The display name of the field.
DataType string `json:"data_type,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select").
URL string `json:"url,omitempty"` // The API URL for this field.
Options []*any `json:"options,omitempty"` // Available options for single_select and multi_select fields.
CreatedAt *github.Timestamp `json:"created_at,omitempty"` // The time when this field was created.
UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` // The time when this field was last updated.
}
type projectV2ItemFieldValue struct {
ID *int64 `json:"id,omitempty"` // The unique identifier for this field.
Name string `json:"name,omitempty"` // The display name of the field.
@@ -931,26 +893,6 @@ type projectV2ItemContent struct {
URL *string `json:"url,omitempty"`
}
type paginationOptions struct {
PerPage int `url:"per_page,omitempty"`
}
type filterQueryOptions struct {
Query string `url:"q,omitempty"`
}
type fieldSelectionOptions struct {
// Specific list of field IDs to include in the response. If not provided, only the title field is included.
// Example: fields=102589,985201,169875 or fields[]=102589&fields[]=985201&fields[]=169875
Fields []string `url:"fields,omitempty"`
}
type listProjectItemsOptions struct {
paginationOptions
filterQueryOptions
fieldSelectionOptions
}
func toNewProjectType(projType string) string {
switch strings.ToLower(projType) {
case "issue":
@@ -994,18 +936,28 @@ func addOptions(s string, opts any) (string, error) {
return s, nil
}
u, err := url.Parse(s)
origURL, err := url.Parse(s)
if err != nil {
return s, err
}
qs, err := query.Values(opts)
origValues := origURL.Query()
// Use the github.com/google/go-querystring library to parse the struct
newValues, err := query.Values(opts)
if err != nil {
return s, err
}
u.RawQuery = qs.Encode()
return u.String(), nil
// Merge the values
for key, values := range newValues {
for _, value := range values {
origValues.Add(key, value)
}
}
origURL.RawQuery = origValues.Encode()
return origURL.String(), nil
}
func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) {
+4 -4
View File
@@ -653,8 +653,8 @@ func Test_ListProjectItems(t *testing.T) {
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet},
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
fieldParams := q["fields"]
if len(fieldParams) == 3 && fieldParams[0] == "123" && fieldParams[1] == "456" && fieldParams[2] == "789" {
fieldParams := q.Get("fields")
if fieldParams == "123,456,789" {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(mock.MustMarshal(orgItems))
return
@@ -852,8 +852,8 @@ func Test_GetProjectItem(t *testing.T) {
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet},
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
fieldParams := q["fields"]
if len(fieldParams) == 2 && fieldParams[0] == "123" && fieldParams[1] == "456" {
fieldParams := q.Get("fields")
if fieldParams == "123,456" {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(mock.MustMarshal(orgItem))
return
+75
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"github.com/google/go-github/v77/github"
"github.com/mark3labs/mcp-go/mcp"
@@ -99,6 +100,26 @@ func RequiredInt(r mcp.CallToolRequest, p string) (int, error) {
return int(v), nil
}
// RequiredBigInt 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 (float64).
// 3. Checks if the parameter is not empty, i.e: non-zero value.
// 4. Validates that the float64 value can be safely converted to int64 without truncation.
func RequiredBigInt(r mcp.CallToolRequest, p string) (int64, error) {
v, err := RequiredParam[float64](r, p)
if err != nil {
return 0, err
}
result := int64(v)
// Check if converting back produces the same value to avoid silent truncation
if float64(result) != v {
return 0, fmt.Errorf("parameter %s value %f is too large to fit in int64", p, v)
}
return result, 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
@@ -189,6 +210,60 @@ func OptionalStringArrayParam(r mcp.CallToolRequest, p string) ([]string, error)
}
}
func convertStringSliceToBigIntSlice(s []string) ([]int64, error) {
int64Slice := make([]int64, len(s))
for i, str := range s {
val, err := convertStringToBigInt(str, 0)
if err != nil {
return nil, fmt.Errorf("failed to convert element %d (%s) to int64: %w", i, str, err)
}
int64Slice[i] = val
}
return int64Slice, nil
}
func convertStringToBigInt(s string, def int64) (int64, error) {
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return def, fmt.Errorf("failed to convert string %s to int64: %w", s, err)
}
return v, nil
}
// OptionalBigIntArrayParam 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 an empty slice
// 2. If it is present, iterates the elements, checks each is a string, and converts them to int64 values
func OptionalBigIntArrayParam(r mcp.CallToolRequest, p string) ([]int64, error) {
// Check if the parameter is present in the request
if _, ok := r.GetArguments()[p]; !ok {
return []int64{}, nil
}
switch v := r.GetArguments()[p].(type) {
case nil:
return []int64{}, nil
case []string:
return convertStringSliceToBigIntSlice(v)
case []any:
int64Slice := make([]int64, len(v))
for i, v := range v {
s, ok := v.(string)
if !ok {
return []int64{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v)
}
val, err := convertStringToBigInt(s, 0)
if err != nil {
return []int64{}, fmt.Errorf("parameter %s: failed to convert element %d (%s) to int64: %w", p, i, s, err)
}
int64Slice[i] = val
}
return int64Slice, nil
default:
return []int64{}, fmt.Errorf("parameter %s could not be coerced to []int64, is %T", p, r.GetArguments()[p])
}
}
// WithPagination adds REST API pagination parameters to a tool.
// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
func WithPagination() mcp.ToolOption {