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
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:
+72
-120
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user