Skip to content

Commit aa4065b

Browse files
committed
Add tools to update and delete project items
1 parent c3cd512 commit aa4065b

File tree

4 files changed

+236
-17
lines changed

4 files changed

+236
-17
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,12 @@ The following sets of tools are available (all are on by default):
665665
- `owner_type`: Owner type (string, required)
666666
- `project_number`: The project's number. (number, required)
667667

668+
- **delete_project_item** - Delete project item
669+
- `item_id`: The numeric ID of the issue or pull request to delete from the project. (number, required)
670+
- `owner`: 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. (string, required)
671+
- `owner_type`: Owner type (string, required)
672+
- `project_number`: The project's number. (number, required)
673+
668674
- **get_project** - Get project
669675
- `owner`: 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. (string, required)
670676
- `owner_type`: Owner type (string, required)
@@ -701,6 +707,13 @@ The following sets of tools are available (all are on by default):
701707
- `per_page`: Number of results per page (max 100, default: 30) (number, optional)
702708
- `query`: Filter projects by a search query (matches title and description) (string, optional)
703709

710+
- **update_project_item** - Update project item
711+
- `fields`: A list of field updates to apply. (array, required)
712+
- `item_id`: The numeric ID of the issue or pull request to update in the project. (number, required)
713+
- `owner`: 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. (string, required)
714+
- `owner_type`: Owner type (string, required)
715+
- `project_number`: The project's number. (number, required)
716+
704717
</details>
705718

706719
<details>

pkg/github/projects.go

Lines changed: 206 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -521,13 +521,12 @@ func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
521521
} else {
522522
projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber)
523523
}
524-
form := url.Values{}
525-
form.Add("type", toNewProjectType(itemType))
526-
form.Add("id", fmt.Sprintf("%d", itemID))
527524

528-
body := strings.NewReader(form.Encode())
529-
530-
httpRequest, err := client.NewFormRequest(projectsURL, body)
525+
newProjectItem := &newProjectItem{
526+
ContentID: int64(itemID),
527+
ContentType: toNewProjectType(itemType),
528+
}
529+
httpRequest, err := client.NewRequest("POST", projectsURL, newProjectItem)
531530
if err != nil {
532531
return nil, fmt.Errorf("failed to create request: %w", err)
533532
}
@@ -543,7 +542,7 @@ func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
543542
}
544543
defer func() { _ = resp.Body.Close() }()
545544

546-
if resp.StatusCode != http.StatusOK {
545+
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
547546
body, err := io.ReadAll(resp.Body)
548547
if err != nil {
549548
return nil, fmt.Errorf("failed to read response body: %w", err)
@@ -559,6 +558,206 @@ func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
559558
}
560559
}
561560

561+
func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
562+
return mcp.NewTool("delete_project_item",
563+
mcp.WithDescription(t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org")),
564+
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"), ReadOnlyHint: ToBoolPtr(false)}),
565+
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")),
566+
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.")),
567+
mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")),
568+
mcp.WithNumber("item_id", mcp.Required(), mcp.Description("The numeric ID of the issue or pull request to delete from the project.")),
569+
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
570+
owner, err := RequiredParam[string](req, "owner")
571+
if err != nil {
572+
return mcp.NewToolResultError(err.Error()), nil
573+
}
574+
ownerType, err := RequiredParam[string](req, "owner_type")
575+
if err != nil {
576+
return mcp.NewToolResultError(err.Error()), nil
577+
}
578+
projectNumber, err := RequiredInt(req, "project_number")
579+
if err != nil {
580+
return mcp.NewToolResultError(err.Error()), nil
581+
}
582+
itemID, err := RequiredInt(req, "item_id")
583+
if err != nil {
584+
return mcp.NewToolResultError(err.Error()), nil
585+
}
586+
client, err := getClient(ctx)
587+
if err != nil {
588+
return mcp.NewToolResultError(err.Error()), nil
589+
}
590+
591+
var projectsURL string
592+
if ownerType == "org" {
593+
projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
594+
} else {
595+
projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
596+
}
597+
598+
httpRequest, err := client.NewRequest("DELETE", projectsURL, nil)
599+
if err != nil {
600+
return nil, fmt.Errorf("failed to create request: %w", err)
601+
}
602+
603+
resp, err := client.Do(ctx, httpRequest, nil)
604+
if err != nil {
605+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
606+
"failed to delete a project item",
607+
resp,
608+
err,
609+
), nil
610+
}
611+
defer func() { _ = resp.Body.Close() }()
612+
613+
if resp.StatusCode != http.StatusNoContent {
614+
body, err := io.ReadAll(resp.Body)
615+
if err != nil {
616+
return nil, fmt.Errorf("failed to read response body: %w", err)
617+
}
618+
return mcp.NewToolResultError(fmt.Sprintf("failed to delete a project item: %s", string(body))), nil
619+
}
620+
return mcp.NewToolResultText("project item successfully deleted"), nil
621+
}
622+
}
623+
624+
func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
625+
return mcp.NewTool("update_project_item",
626+
mcp.WithDescription(t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org")),
627+
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"), ReadOnlyHint: ToBoolPtr(false)}),
628+
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")),
629+
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.")),
630+
mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")),
631+
mcp.WithNumber("item_id", mcp.Required(), mcp.Description("The numeric ID of the issue or pull request to update in the project.")),
632+
mcp.WithArray("fields", mcp.Required(), mcp.Description("A list of field updates to apply.")),
633+
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
634+
owner, err := RequiredParam[string](req, "owner")
635+
if err != nil {
636+
return mcp.NewToolResultError(err.Error()), nil
637+
}
638+
ownerType, err := RequiredParam[string](req, "owner_type")
639+
if err != nil {
640+
return mcp.NewToolResultError(err.Error()), nil
641+
}
642+
projectNumber, err := RequiredInt(req, "project_number")
643+
if err != nil {
644+
return mcp.NewToolResultError(err.Error()), nil
645+
}
646+
itemID, err := RequiredInt(req, "item_id")
647+
if err != nil {
648+
return mcp.NewToolResultError(err.Error()), nil
649+
}
650+
client, err := getClient(ctx)
651+
if err != nil {
652+
return mcp.NewToolResultError(err.Error()), nil
653+
}
654+
fieldsParam, ok := req.GetArguments()["fields"]
655+
if !ok {
656+
return mcp.NewToolResultError("missing required parameter: fields"), nil
657+
}
658+
659+
rawFields, ok := fieldsParam.([]any)
660+
if !ok {
661+
return mcp.NewToolResultError("parameter fields must be an array of objects"), nil
662+
}
663+
if len(rawFields) == 0 {
664+
return mcp.NewToolResultError("fields must contain at least one field update"), nil
665+
}
666+
667+
var projectsURL string
668+
if ownerType == "org" {
669+
projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
670+
} else {
671+
projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
672+
}
673+
674+
updateFields := make([]*newProjectV2Field, 0, len(rawFields))
675+
for idx, rawField := range rawFields {
676+
fieldMap, ok := rawField.(map[string]any)
677+
if !ok {
678+
return mcp.NewToolResultError(fmt.Sprintf("fields[%d] must be an object", idx)), nil
679+
}
680+
681+
rawID, ok := fieldMap["id"]
682+
if !ok {
683+
return mcp.NewToolResultError(fmt.Sprintf("fields[%d] is missing 'id'", idx)), nil
684+
}
685+
686+
var fieldID int64
687+
switch v := rawID.(type) {
688+
case float64:
689+
fieldID = int64(v)
690+
case int64:
691+
fieldID = v
692+
case json.Number:
693+
n, convErr := v.Int64()
694+
if convErr != nil {
695+
return mcp.NewToolResultError(fmt.Sprintf("fields[%d].id must be a numeric value", idx)), nil
696+
}
697+
fieldID = n
698+
default:
699+
return mcp.NewToolResultError(fmt.Sprintf("fields[%d].id must be a numeric value", idx)), nil
700+
}
701+
702+
value, ok := fieldMap["value"]
703+
if !ok {
704+
return mcp.NewToolResultError(fmt.Sprintf("fields[%d] is missing 'value'", idx)), nil
705+
}
706+
707+
updateFields = append(updateFields, &newProjectV2Field{
708+
ID: github.Ptr(fieldID),
709+
Value: value,
710+
})
711+
}
712+
713+
updateProjectItemOptions := &updateProjectItemOptions{Fields: updateFields}
714+
715+
httpRequest, err := client.NewRequest("PATCH", projectsURL, updateProjectItemOptions)
716+
if err != nil {
717+
return nil, fmt.Errorf("failed to create request: %w", err)
718+
}
719+
720+
updatedItem := projectV2Item{}
721+
resp, err := client.Do(ctx, httpRequest, &updatedItem)
722+
if err != nil {
723+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
724+
"failed to update a project item",
725+
resp,
726+
err,
727+
), nil
728+
}
729+
defer func() { _ = resp.Body.Close() }()
730+
731+
if resp.StatusCode != http.StatusOK {
732+
body, err := io.ReadAll(resp.Body)
733+
if err != nil {
734+
return nil, fmt.Errorf("failed to read response body: %w", err)
735+
}
736+
return mcp.NewToolResultError(fmt.Sprintf("failed to update a project item: %s", string(body))), nil
737+
}
738+
r, err := json.Marshal(convertToMinimalProjectItem(&updatedItem))
739+
if err != nil {
740+
return nil, fmt.Errorf("failed to marshal response: %w", err)
741+
}
742+
743+
return mcp.NewToolResultText(string(r)), nil
744+
}
745+
}
746+
747+
type updateProjectItemOptions struct {
748+
Fields []*newProjectV2Field `json:"fields,omitempty"`
749+
}
750+
751+
type newProjectV2Field struct {
752+
ID *int64 `json:"id,omitempty"`
753+
Value any `json:"value,omitempty"`
754+
}
755+
756+
type newProjectItem struct {
757+
ContentID int64 `json:"content_id,omitempty"`
758+
ContentType string `json:"content_type,omitempty"`
759+
}
760+
562761
type projectV2Field struct {
563762
ID *int64 `json:"id,omitempty"` // The unique identifier for this field.
564763
NodeID string `json:"node_id,omitempty"` // The GraphQL node ID for this field.

pkg/github/projects_test.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"encoding/json"
66
"io"
77
"net/http"
8-
"net/url"
98
"testing"
109

1110
"github.com/github/github-mcp-server/internal/toolsnaps"
@@ -985,11 +984,14 @@ func Test_AddProjectItem(t *testing.T) {
985984
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
986985
body, err := io.ReadAll(r.Body)
987986
assert.NoError(t, err)
988-
values, err := url.ParseQuery(string(body))
989-
assert.NoError(t, err)
990-
assert.Equal(t, "Issue", values.Get("type"))
991-
assert.Equal(t, "9876", values.Get("id"))
992-
w.WriteHeader(http.StatusOK)
987+
var payload struct {
988+
ContentType string `json:"content_type"`
989+
ContentID int `json:"content_id"`
990+
}
991+
assert.NoError(t, json.Unmarshal(body, &payload))
992+
assert.Equal(t, "Issue", payload.ContentType)
993+
assert.Equal(t, 9876, payload.ContentID)
994+
w.WriteHeader(http.StatusCreated)
993995
_, _ = w.Write(mock.MustMarshal(orgItem))
994996
}),
995997
),
@@ -1013,10 +1015,13 @@ func Test_AddProjectItem(t *testing.T) {
10131015
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
10141016
body, err := io.ReadAll(r.Body)
10151017
assert.NoError(t, err)
1016-
values, err := url.ParseQuery(string(body))
1017-
assert.NoError(t, err)
1018-
assert.Equal(t, "PullRequest", values.Get("type"))
1019-
assert.Equal(t, "7654", values.Get("id"))
1018+
var payload struct {
1019+
ContentType string `json:"content_type"`
1020+
ContentID int `json:"content_id"`
1021+
}
1022+
assert.NoError(t, json.Unmarshal(body, &payload))
1023+
assert.Equal(t, "PullRequest", payload.ContentType)
1024+
assert.Equal(t, 7654, payload.ContentID)
10201025
w.WriteHeader(http.StatusOK)
10211026
_, _ = w.Write(mock.MustMarshal(userItem))
10221027
}),

pkg/github/tools.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
201201
).
202202
AddWriteTools(
203203
toolsets.NewServerTool(AddProjectItem(getClient, t)),
204+
toolsets.NewServerTool(DeleteProjectItem(getClient, t)),
205+
toolsets.NewServerTool(UpdateProjectItem(getClient, t)),
204206
)
205207

206208
// Add toolsets to the group

0 commit comments

Comments
 (0)