package github import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) const ( ProjectUpdateFailedError = "failed to update a project item" ProjectAddFailedError = "failed to add a project item" ProjectDeleteFailedError = "failed to delete a project item" ProjectListFailedError = "failed to list project items" MaxProjectsPerPage = 50 ) // FlexibleString handles JSON unmarshaling of fields that can be either // a plain string or an object with "raw" and "html" fields. // This is needed because the GitHub API returns option names as strings, // while go-github v79 expects them to be ProjectV2TextContent objects. type FlexibleString struct { Raw string `json:"raw,omitempty"` HTML string `json:"html,omitempty"` } // UnmarshalJSON implements custom unmarshaling for FlexibleString func (f *FlexibleString) UnmarshalJSON(data []byte) error { // Try to unmarshal as a plain string first var s string if err := json.Unmarshal(data, &s); err == nil { f.Raw = s f.HTML = s return nil } // If that fails, try to unmarshal as an object type flexibleStringAlias FlexibleString var obj flexibleStringAlias if err := json.Unmarshal(data, &obj); err != nil { return err } *f = FlexibleString(obj) return nil } // ProjectFieldOption represents an option for single_select or iteration fields. // This is a custom type that handles the flexible name format from the GitHub API. type ProjectFieldOption struct { ID string `json:"id,omitempty"` Name *FlexibleString `json:"name,omitempty"` Color string `json:"color,omitempty"` Description *FlexibleString `json:"description,omitempty"` } // ProjectFieldIteration represents an iteration within a project field. type ProjectFieldIteration struct { ID string `json:"id,omitempty"` Title *FlexibleString `json:"title,omitempty"` StartDate string `json:"start_date,omitempty"` Duration int `json:"duration,omitempty"` } // ProjectFieldConfiguration represents the configuration for iteration fields. type ProjectFieldConfiguration struct { Duration int `json:"duration,omitempty"` StartDay int `json:"start_day,omitempty"` Iterations []*ProjectFieldIteration `json:"iterations,omitempty"` } // ProjectField represents a field in a GitHub Project V2. // This is a custom type that properly handles the options array format from the GitHub API. type ProjectField struct { ID int64 `json:"id,omitempty"` NodeID string `json:"node_id,omitempty"` Name string `json:"name,omitempty"` DataType string `json:"data_type,omitempty"` ProjectURL string `json:"project_url,omitempty"` Options []*ProjectFieldOption `json:"options,omitempty"` Configuration *ProjectFieldConfiguration `json:"configuration,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_projects", mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", `List Projects for a user or organization`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"), ), mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), ), mcp.WithString("query", mcp.Description(`Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning".`), ), mcp.WithNumber("per_page", mcp.Description(fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage)), ), mcp.WithString("after", mcp.Description("Forward pagination cursor from previous pageInfo.nextCursor."), ), mcp.WithString("before", mcp.Description("Backward pagination cursor from previous pageInfo.prevCursor (rare)."), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } queryStr, err := OptionalParam[string](req, "query") if err != nil { return mcp.NewToolResultError(err.Error()), nil } pagination, err := extractPaginationOptions(req) if err != nil { return mcp.NewToolResultError(err.Error()), nil } client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil } var resp *github.Response var projects []*github.ProjectV2 var queryPtr *string if queryStr != "" { queryPtr = &queryStr } minimalProjects := []MinimalProject{} opts := &github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, Query: queryPtr, } if ownerType == "org" { projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) } else { projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list projects", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() for _, project := range projects { minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) } response := map[string]any{ "projects": minimalProjects, "pageInfo": buildPageInfo(resp), } r, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return mcp.NewToolResultText(string(r)), nil } } func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_project", mcp.WithDescription(t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number"), ), mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"), ), mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } owner, err := RequiredParam[string](req, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil } var resp *github.Response var project *github.ProjectV2 if ownerType == "org" { project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) } else { project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get project", resp, err, ), nil } 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 project: %s", string(body))), nil } minimalProject := convertToMinimalProject(project) r, err := json.Marshal(minimalProject) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return mcp.NewToolResultText(string(r)), nil } } func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_project_fields", mcp.WithDescription(t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")), mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), ), mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number."), ), mcp.WithNumber("per_page", mcp.Description(fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage)), ), mcp.WithString("after", mcp.Description("Forward pagination cursor from previous pageInfo.nextCursor."), ), mcp.WithString("before", mcp.Description("Backward pagination cursor from previous pageInfo.prevCursor (rare)."), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } pagination, err := extractPaginationOptions(req) if err != nil { return mcp.NewToolResultError(err.Error()), nil } client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil } // Build the URL for the API request var urlPath string if ownerType == "org" { urlPath = fmt.Sprintf("orgs/%s/projectsV2/%d/fields", owner, projectNumber) } else { urlPath = fmt.Sprintf("users/%s/projectsV2/%d/fields", owner, projectNumber) } // Create options for the request opts := &github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, } // Make the raw API request using go-github's client // We use our custom ProjectField type which handles flexible name format projectFields, resp, err := listProjectFieldsRaw(ctx, client, urlPath, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list project fields", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() response := map[string]any{ "fields": projectFields, "pageInfo": buildPageInfo(resp), } r, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return mcp.NewToolResultText(string(r)), nil } } // listProjectFieldsRaw makes a raw API request to list project fields and parses // the response using our custom ProjectField type that handles flexible name formats. func listProjectFieldsRaw(ctx context.Context, client *github.Client, urlPath string, opts *github.ListProjectsOptions) ([]*ProjectField, *github.Response, error) { u, err := addProjectOptions(urlPath, opts) if err != nil { return nil, nil, err } req, err := client.NewRequest("GET", u, nil) if err != nil { return nil, nil, err } var fields []*ProjectField resp, err := client.Do(ctx, req, &fields) if err != nil { return nil, resp, err } return fields, resp, nil } // addProjectOptions adds query parameters to a URL for project API requests. func addProjectOptions(s string, opts *github.ListProjectsOptions) (string, error) { if opts == nil { return s, nil } // Build query parameters manually params := make([]string, 0) if opts.PerPage != nil && *opts.PerPage > 0 { params = append(params, fmt.Sprintf("per_page=%d", *opts.PerPage)) } if opts.After != nil && *opts.After != "" { params = append(params, fmt.Sprintf("after=%s", *opts.After)) } if opts.Before != nil && *opts.Before != "" { params = append(params, fmt.Sprintf("before=%s", *opts.Before)) } if opts.Query != nil && *opts.Query != "" { params = append(params, fmt.Sprintf("q=%s", *opts.Query)) } if len(params) > 0 { s = s + "?" + strings.Join(params, "&") } return s, nil } // getProjectFieldRaw makes a raw API request to get a single project field and parses // the response using our custom ProjectField type that handles flexible name formats. func getProjectFieldRaw(ctx context.Context, client *github.Client, urlPath string) (*ProjectField, *github.Response, error) { req, err := client.NewRequest("GET", urlPath, nil) if err != nil { return nil, nil, err } var field ProjectField resp, err := client.Do(ctx, req, &field) if err != nil { return nil, resp, err } return &field, resp, nil } func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_project_field", mcp.WithDescription(t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")), mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), ), mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")), mcp.WithNumber("field_id", mcp.Required(), mcp.Description("The field's id."), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } fieldID, err := RequiredBigInt(req, "field_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil } // Build the URL for the API request var urlPath string if ownerType == "org" { urlPath = fmt.Sprintf("orgs/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID) } else { urlPath = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID) } // Make the raw API request using go-github's client // We use our custom ProjectField type which handles flexible name format projectField, resp, err := getProjectFieldRaw(ctx, client, urlPath) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get project field", resp, err, ), nil } 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 project field: %s", string(body))), nil } r, err := json.Marshal(projectField) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return mcp.NewToolResultText(string(r)), nil } } func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_project_items", mcp.WithDescription(t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", `Search project items with advanced filtering`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"), ), mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), ), mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number."), ), mcp.WithString("query", mcp.Description(`Query string for advanced filtering of project items using GitHub's project filtering syntax.`), ), mcp.WithNumber("per_page", mcp.Description(fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage)), ), mcp.WithString("after", mcp.Description("Forward pagination cursor from previous pageInfo.nextCursor."), ), mcp.WithString("before", mcp.Description("Backward pagination cursor from previous pageInfo.prevCursor (rare)."), ), mcp.WithArray("fields", mcp.Description("Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned."), mcp.WithStringItems(), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } queryStr, err := OptionalParam[string](req, "query") if err != nil { return mcp.NewToolResultError(err.Error()), nil } fields, err := OptionalBigIntArrayParam(req, "fields") if err != nil { return mcp.NewToolResultError(err.Error()), nil } pagination, err := extractPaginationOptions(req) if err != nil { return mcp.NewToolResultError(err.Error()), nil } client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil } var resp *github.Response var projectItems []*github.ProjectV2Item var queryPtr *string if queryStr != "" { queryPtr = &queryStr } opts := &github.ListProjectItemsOptions{ Fields: fields, ListProjectsOptions: github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, Query: queryPtr, }, } if ownerType == "org" { projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts) } else { projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts) } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, ProjectListFailedError, resp, err, ), nil } defer func() { _ = resp.Body.Close() }() response := map[string]any{ "items": projectItems, "pageInfo": buildPageInfo(resp), } r, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return mcp.NewToolResultText(string(r)), nil } } func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_project_item", mcp.WithDescription(t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"), ), mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), ), mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number."), ), mcp.WithNumber("item_id", mcp.Required(), mcp.Description("The item's ID."), ), mcp.WithArray("fields", mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."), mcp.WithStringItems(), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } itemID, err := RequiredBigInt(req, "item_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } 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 resp *github.Response var projectItem *github.ProjectV2Item var opts *github.GetProjectItemOptions if len(fields) > 0 { opts = &github.GetProjectItemOptions{ Fields: fields, } } if ownerType == "org" { projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts) } else { projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts) } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get project item", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() r, err := json.Marshal(projectItem) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return mcp.NewToolResultText(string(r)), nil } } func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("add_project_item", mcp.WithDescription(t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_ADD_PROJECT_ITEM_USER_TITLE", "Add project item"), ReadOnlyHint: ToBoolPtr(false), }), mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"), ), mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), ), mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number."), ), mcp.WithString("item_type", mcp.Required(), mcp.Description("The item's type, either issue or pull_request."), mcp.Enum("issue", "pull_request"), ), mcp.WithNumber("item_id", mcp.Required(), mcp.Description("The numeric ID of the issue or pull request to add to the project."), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } itemID, err := RequiredBigInt(req, "item_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } itemType, err := RequiredParam[string](req, "item_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } if itemType != "issue" && itemType != "pull_request" { return mcp.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil } client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil } newItem := &github.AddProjectItemOptions{ ID: itemID, Type: toNewProjectType(itemType), } 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, resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { 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("%s: %s", ProjectAddFailedError, string(body))), nil } r, err := json.Marshal(addedItem) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return mcp.NewToolResultText(string(r)), nil } } func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("update_project_item", mcp.WithDescription(t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"), ReadOnlyHint: ToBoolPtr(false), }), mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"), ), mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), ), mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number."), ), mcp.WithNumber("item_id", mcp.Required(), mcp.Description("The unique identifier of the project item. This is not the issue or pull request ID."), ), mcp.WithObject("updated_field", mcp.Required(), mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}"), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } itemID, err := RequiredBigInt(req, "item_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } rawUpdatedField, exists := req.GetArguments()["updated_field"] if !exists { return mcp.NewToolResultError("missing required parameter: updated_field"), nil } fieldValue, ok := rawUpdatedField.(map[string]any) if !ok || fieldValue == nil { return mcp.NewToolResultError("field_value must be an object"), nil } updatePayload, err := buildUpdateProjectItem(fieldValue) if err != nil { return mcp.NewToolResultError(err.Error()), nil } client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil } var resp *github.Response var updatedItem *github.ProjectV2Item if ownerType == "org" { updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload) } else { updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload) } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, ProjectUpdateFailedError, resp, err, ), nil } 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("%s: %s", ProjectUpdateFailedError, string(body))), nil } r, err := json.Marshal(updatedItem) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return mcp.NewToolResultText(string(r)), nil } } func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("delete_project_item", mcp.WithDescription(t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"), ReadOnlyHint: ToBoolPtr(false), }), mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"), ), mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."), ), mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number."), ), mcp.WithNumber("item_id", mcp.Required(), mcp.Description("The internal project item ID to delete from the project (not the issue or pull request ID)."), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } itemID, err := RequiredBigInt(req, "item_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil } var resp *github.Response if ownerType == "org" { resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) } else { resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, ProjectDeleteFailedError, resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent { 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("%s: %s", ProjectDeleteFailedError, string(body))), nil } return mcp.NewToolResultText("project item successfully deleted"), nil } } type pageInfo struct { HasNextPage bool `json:"hasNextPage"` HasPreviousPage bool `json:"hasPreviousPage"` NextCursor string `json:"nextCursor,omitempty"` PrevCursor string `json:"prevCursor,omitempty"` } func toNewProjectType(projType string) string { switch strings.ToLower(projType) { case "issue": return "Issue" case "pull_request": return "PullRequest" default: return "" } } // validateAndConvertToInt64 ensures the value is a number and converts it to int64. func validateAndConvertToInt64(value any) (int64, error) { switch v := value.(type) { case float64: // Validate that the float64 can be safely converted to int64 intVal := int64(v) if float64(intVal) != v { return 0, fmt.Errorf("value must be a valid integer (got %v)", v) } return intVal, nil case int64: return v, nil case int: return int64(v), nil default: return 0, fmt.Errorf("value must be a number (got %T)", v) } } // buildUpdateProjectItem constructs UpdateProjectItemOptions from the input map. func buildUpdateProjectItem(input map[string]any) (*github.UpdateProjectItemOptions, error) { if input == nil { return nil, fmt.Errorf("updated_field must be an object") } idField, ok := input["id"] if !ok { return nil, fmt.Errorf("updated_field.id is required") } fieldID, err := validateAndConvertToInt64(idField) if err != nil { return nil, fmt.Errorf("updated_field.id: %w", err) } valueField, ok := input["value"] if !ok { return nil, fmt.Errorf("updated_field.value is required") } payload := &github.UpdateProjectItemOptions{ Fields: []*github.UpdateProjectV2Field{{ ID: fieldID, Value: valueField, }}, } return payload, nil } func buildPageInfo(resp *github.Response) pageInfo { return pageInfo{ HasNextPage: resp.After != "", HasPreviousPage: resp.Before != "", NextCursor: resp.After, PrevCursor: resp.Before, } } func extractPaginationOptions(request mcp.CallToolRequest) (github.ListProjectsPaginationOptions, error) { perPage, err := OptionalIntParamWithDefault(request, "per_page", MaxProjectsPerPage) if err != nil { return github.ListProjectsPaginationOptions{}, err } if perPage > MaxProjectsPerPage { perPage = MaxProjectsPerPage } after, err := OptionalParam[string](request, "after") if err != nil { return github.ListProjectsPaginationOptions{}, err } before, err := OptionalParam[string](request, "before") if err != nil { return github.ListProjectsPaginationOptions{}, err } opts := github.ListProjectsPaginationOptions{ PerPage: &perPage, } // Only set After/Before if they have non-empty values if after != "" { opts.After = &after } if before != "" { opts.Before = &before } return opts, nil }