diff --git a/README.md b/README.md
index ff3e44ba9..bd0963abf 100644
--- a/README.md
+++ b/README.md
@@ -658,6 +658,19 @@ The following sets of tools are available (all are on by default):
Projects
+- **add_project_item** - Add project item
+ - `item_id`: The numeric ID of the issue or pull request to add to the project. (number, required)
+ - `item_type`: The item's type, either issue or pull_request. (string, required)
+ - `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)
+ - `owner_type`: Owner type (string, required)
+ - `project_number`: The project's number. (number, required)
+
+- **delete_project_item** - Delete project item
+ - `item_id`: The internal project item ID to delete from the project (not the issue or pull request ID). (number, required)
+ - `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)
+ - `owner_type`: Owner type (string, required)
+ - `project_number`: The project's number. (number, required)
+
- **get_project** - Get project
- `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)
- `owner_type`: Owner type (string, required)
@@ -694,6 +707,13 @@ The following sets of tools are available (all are on by default):
- `per_page`: Number of results per page (max 100, default: 30) (number, optional)
- `query`: Filter projects by a search query (matches title and description) (string, optional)
+- **update_project_item** - Update project item
+ - `fields`: A list of field updates to apply. (array, required)
+ - `item_id`: The numeric ID of the project item to update (not the issue or pull request ID). (number, required)
+ - `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)
+ - `owner_type`: Owner type (string, required)
+ - `project_number`: The project's number. (number, required)
+
diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go
index 9de0682f3..34e374a24 100644
--- a/internal/ghmcp/server.go
+++ b/internal/ghmcp/server.go
@@ -118,8 +118,8 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
// Generate instructions based on enabled toolsets
instructions := github.GenerateInstructions(enabledToolsets)
-
- ghServer := github.NewServer(cfg.Version,
+
+ ghServer := github.NewServer(cfg.Version,
server.WithInstructions(instructions),
server.WithHooks(hooks),
)
diff --git a/pkg/github/__toolsnaps__/add_project_item.snap b/pkg/github/__toolsnaps__/add_project_item.snap
new file mode 100644
index 000000000..143c04eb9
--- /dev/null
+++ b/pkg/github/__toolsnaps__/add_project_item.snap
@@ -0,0 +1,48 @@
+{
+ "annotations": {
+ "title": "Add project item",
+ "readOnlyHint": false
+ },
+ "description": "Add a specific Project item for a user or org",
+ "inputSchema": {
+ "properties": {
+ "item_id": {
+ "description": "The numeric ID of the issue or pull request to add to the project.",
+ "type": "number"
+ },
+ "item_type": {
+ "description": "The item's type, either issue or pull_request.",
+ "enum": [
+ "issue",
+ "pull_request"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "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.",
+ "type": "string"
+ },
+ "owner_type": {
+ "description": "Owner type",
+ "enum": [
+ "user",
+ "org"
+ ],
+ "type": "string"
+ },
+ "project_number": {
+ "description": "The project's number.",
+ "type": "number"
+ }
+ },
+ "required": [
+ "owner_type",
+ "owner",
+ "project_number",
+ "item_type",
+ "item_id"
+ ],
+ "type": "object"
+ },
+ "name": "add_project_item"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/delete_project_item.snap b/pkg/github/__toolsnaps__/delete_project_item.snap
new file mode 100644
index 000000000..0de1336a0
--- /dev/null
+++ b/pkg/github/__toolsnaps__/delete_project_item.snap
@@ -0,0 +1,39 @@
+{
+ "annotations": {
+ "title": "Delete project item",
+ "readOnlyHint": false
+ },
+ "description": "Delete a specific Project item for a user or org",
+ "inputSchema": {
+ "properties": {
+ "item_id": {
+ "description": "The internal project item ID to delete from the project (not the issue or pull request ID).",
+ "type": "number"
+ },
+ "owner": {
+ "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.",
+ "type": "string"
+ },
+ "owner_type": {
+ "description": "Owner type",
+ "enum": [
+ "user",
+ "org"
+ ],
+ "type": "string"
+ },
+ "project_number": {
+ "description": "The project's number.",
+ "type": "number"
+ }
+ },
+ "required": [
+ "owner_type",
+ "owner",
+ "project_number",
+ "item_id"
+ ],
+ "type": "object"
+ },
+ "name": "delete_project_item"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/update_project_item.snap b/pkg/github/__toolsnaps__/update_project_item.snap
new file mode 100644
index 000000000..ff2905282
--- /dev/null
+++ b/pkg/github/__toolsnaps__/update_project_item.snap
@@ -0,0 +1,44 @@
+{
+ "annotations": {
+ "title": "Update project item",
+ "readOnlyHint": false
+ },
+ "description": "Update a specific Project item for a user or org",
+ "inputSchema": {
+ "properties": {
+ "fields": {
+ "description": "A list of field updates to apply.",
+ "type": "array"
+ },
+ "item_id": {
+ "description": "The numeric ID of the project item to update (not the issue or pull request ID).",
+ "type": "number"
+ },
+ "owner": {
+ "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.",
+ "type": "string"
+ },
+ "owner_type": {
+ "description": "Owner type",
+ "enum": [
+ "user",
+ "org"
+ ],
+ "type": "string"
+ },
+ "project_number": {
+ "description": "The project's number.",
+ "type": "number"
+ }
+ },
+ "required": [
+ "owner_type",
+ "owner",
+ "project_number",
+ "item_id",
+ "fields"
+ ],
+ "type": "object"
+ },
+ "name": "update_project_item"
+}
\ No newline at end of file
diff --git a/pkg/github/instructions.go b/pkg/github/instructions.go
index 3f72e707b..7eefe53f0 100644
--- a/pkg/github/instructions.go
+++ b/pkg/github/instructions.go
@@ -12,21 +12,21 @@ func GenerateInstructions(enabledToolsets []string) string {
if os.Getenv("DISABLE_INSTRUCTIONS") == "true" {
return "" // Baseline mode
}
-
+
var instructions []string
-
+
// Core instruction - always included if context toolset enabled
if slices.Contains(enabledToolsets, "context") {
instructions = append(instructions, "Always call 'get_me' first to understand current user permissions and context.")
}
-
+
// Individual toolset instructions
for _, toolset := range enabledToolsets {
if inst := getToolsetInstructions(toolset); inst != "" {
instructions = append(instructions, inst)
}
}
-
+
// Base instruction with context management
baseInstruction := `The GitHub MCP Server provides tools to interact with GitHub platform.
@@ -40,7 +40,7 @@ Context management:
allInstructions := []string{baseInstruction}
allInstructions = append(allInstructions, instructions...)
-
+
return strings.Join(allInstructions, " ")
}
@@ -57,4 +57,3 @@ func getToolsetInstructions(toolset string) string {
return ""
}
}
-
diff --git a/pkg/github/instructions_test.go b/pkg/github/instructions_test.go
index 8450dc1a1..f00e0ac74 100644
--- a/pkg/github/instructions_test.go
+++ b/pkg/github/instructions_test.go
@@ -163,4 +163,4 @@ func TestGetToolsetInstructions(t *testing.T) {
}
})
}
-}
\ No newline at end of file
+}
diff --git a/pkg/github/projects.go b/pkg/github/projects.go
index f3ea0f7e1..09bcbd5ed 100644
--- a/pkg/github/projects.go
+++ b/pkg/github/projects.go
@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"reflect"
+ "strings"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
@@ -474,6 +475,289 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc)
}
}
+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 := RequiredInt(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
+ }
+
+ 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)
+ }
+
+ newProjectItem := &newProjectItem{
+ ID: int64(itemID),
+ Type: toNewProjectType(itemType),
+ }
+ httpRequest, err := client.NewRequest("POST", projectsURL, newProjectItem)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ addedItem := projectV2Item{}
+
+ resp, err := client.Do(ctx, httpRequest, &addedItem)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to add a project item",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusCreated && 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 add a project item: %s", string(body))), nil
+ }
+ r, err := json.Marshal(convertToMinimalProjectItem(&addedItem))
+ 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 := RequiredInt(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 projectsURL string
+ if ownerType == "org" {
+ projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
+ } else {
+ projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", 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,
+ "failed to delete a project item",
+ 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("failed to delete a project item: %s", string(body))), nil
+ }
+ return mcp.NewToolResultText("project item successfully deleted"), 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 numeric ID of the project item to update (not the issue or pull request ID).")),
+ mcp.WithArray("fields", mcp.Required(), mcp.Description("A list of field updates to apply.")),
+ ), 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 := RequiredInt(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
+ }
+ fieldsParam, ok := req.GetArguments()["fields"]
+ if !ok {
+ return mcp.NewToolResultError("missing required parameter: fields"), nil
+ }
+
+ rawFields, ok := fieldsParam.([]any)
+ if !ok {
+ return mcp.NewToolResultError("parameter fields must be an array of objects"), nil
+ }
+ if len(rawFields) == 0 {
+ return mcp.NewToolResultError("fields must contain at least one field update"), nil
+ }
+
+ var projectsURL string
+ if ownerType == "org" {
+ projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
+ } else {
+ projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
+ }
+
+ updateFields := make([]*newProjectV2Field, 0, len(rawFields))
+ for idx, rawField := range rawFields {
+ fieldMap, ok := rawField.(map[string]any)
+ if !ok {
+ return mcp.NewToolResultError(fmt.Sprintf("fields[%d] must be an object", idx)), nil
+ }
+
+ rawID, ok := fieldMap["id"]
+ if !ok {
+ return mcp.NewToolResultError(fmt.Sprintf("fields[%d] is missing 'id'", idx)), nil
+ }
+
+ var fieldID int64
+ switch v := rawID.(type) {
+ case float64:
+ fieldID = int64(v)
+ case int64:
+ fieldID = v
+ case json.Number:
+ n, convErr := v.Int64()
+ if convErr != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("fields[%d].id must be a numeric value", idx)), nil
+ }
+ fieldID = n
+ default:
+ return mcp.NewToolResultError(fmt.Sprintf("fields[%d].id must be a numeric value", idx)), nil
+ }
+
+ value, ok := fieldMap["value"]
+ if !ok {
+ return mcp.NewToolResultError(fmt.Sprintf("fields[%d] is missing 'value'", idx)), nil
+ }
+
+ updateFields = append(updateFields, &newProjectV2Field{
+ ID: github.Ptr(fieldID),
+ Value: value,
+ })
+ }
+
+ updateProjectItemOptions := &updateProjectItemOptions{Fields: updateFields}
+
+ httpRequest, err := client.NewRequest("PATCH", projectsURL, updateProjectItemOptions)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ updatedItem := projectV2Item{}
+ resp, err := client.Do(ctx, httpRequest, &updatedItem)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to update a project item",
+ 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 update a project item: %s", string(body))), nil
+ }
+ r, err := json.Marshal(convertToMinimalProjectItem(&updatedItem))
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+type updateProjectItemOptions struct {
+ Fields []*newProjectV2Field `json:"fields,omitempty"`
+}
+
+type newProjectV2Field struct {
+ ID *int64 `json:"id,omitempty"`
+ Value any `json:"value,omitempty"`
+}
+
+type newProjectItem struct {
+ ID int64 `json:"id,omitempty"` // Issue or Pull Request ID to add to the project.
+ Type string `json:"type,omitempty"`
+}
+
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.
@@ -500,6 +784,17 @@ type projectV2Item struct {
Fields []*projectV2Field `json:"fields,omitempty"`
}
+func toNewProjectType(projType string) string {
+ switch strings.ToLower(projType) {
+ case "issue":
+ return "Issue"
+ case "pull_request":
+ return "PullRequest"
+ default:
+ return ""
+ }
+}
+
type listProjectsOptions struct {
// For paginated result sets, the number of results to include per page.
PerPage int `url:"per_page,omitempty"`
diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go
index a6225ec86..628bad8fb 100644
--- a/pkg/github/projects_test.go
+++ b/pkg/github/projects_test.go
@@ -3,6 +3,7 @@ package github
import (
"context"
"encoding/json"
+ "io"
"net/http"
"testing"
@@ -928,3 +929,620 @@ func Test_GetProjectItem(t *testing.T) {
})
}
}
+
+func Test_AddProjectItem(t *testing.T) {
+ mockClient := gh.NewClient(nil)
+ tool, _ := AddProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "add_project_item", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner_type")
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "project_number")
+ assert.Contains(t, tool.InputSchema.Properties, "item_type")
+ assert.Contains(t, tool.InputSchema.Properties, "item_id")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_type", "item_id"})
+
+ orgItem := map[string]any{
+ "id": 601,
+ "content_type": "Issue",
+ "creator": map[string]any{
+ "login": "octocat",
+ "id": 1,
+ "html_url": "https://github.com/octocat",
+ "avatar_url": "https://avatars.githubusercontent.com/u/1?v=4",
+ },
+ }
+
+ userItem := map[string]any{
+ "id": 701,
+ "content_type": "PullRequest",
+ "creator": map[string]any{
+ "login": "hubot",
+ "id": 2,
+ "html_url": "https://github.com/hubot",
+ "avatar_url": "https://avatars.githubusercontent.com/u/2?v=4",
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ expectedID int
+ expectedContentType string
+ expectedCreatorLogin string
+ }{
+ {
+ name: "success organization issue",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost},
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ assert.NoError(t, err)
+ var payload struct {
+ Type string `json:"type"`
+ ID int `json:"id"`
+ }
+ assert.NoError(t, json.Unmarshal(body, &payload))
+ assert.Equal(t, "Issue", payload.Type)
+ assert.Equal(t, 9876, payload.ID)
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write(mock.MustMarshal(orgItem))
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(321),
+ "item_type": "issue",
+ "item_id": float64(9876),
+ },
+ expectedID: 601,
+ expectedContentType: "Issue",
+ expectedCreatorLogin: "octocat",
+ },
+ {
+ name: "success user pull request",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodPost},
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ assert.NoError(t, err)
+ var payload struct {
+ Type string `json:"type"`
+ ID int `json:"id"`
+ }
+ assert.NoError(t, json.Unmarshal(body, &payload))
+ assert.Equal(t, "PullRequest", payload.Type)
+ assert.Equal(t, 7654, payload.ID)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(mock.MustMarshal(userItem))
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "octocat",
+ "owner_type": "user",
+ "project_number": float64(222),
+ "item_type": "pull_request",
+ "item_id": float64(7654),
+ },
+ expectedID: 701,
+ expectedContentType: "PullRequest",
+ expectedCreatorLogin: "hubot",
+ },
+ {
+ name: "api error",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost},
+ mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(999),
+ "item_type": "issue",
+ "item_id": float64(8888),
+ },
+ expectError: true,
+ expectedErrMsg: "failed to add a project item",
+ },
+ {
+ name: "missing owner",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner_type": "org",
+ "project_number": float64(1),
+ "item_type": "Issue",
+ "item_id": float64(10),
+ },
+ expectError: true,
+ },
+ {
+ name: "missing owner_type",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "project_number": float64(1),
+ "item_type": "Issue",
+ "item_id": float64(10),
+ },
+ expectError: true,
+ },
+ {
+ name: "missing project_number",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "item_type": "Issue",
+ "item_id": float64(10),
+ },
+ expectError: true,
+ },
+ {
+ name: "missing item_type",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
+ "item_id": float64(10),
+ },
+ expectError: true,
+ },
+ {
+ name: "missing item_id",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
+ "item_type": "Issue",
+ },
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := gh.NewClient(tc.mockedClient)
+ _, handler := AddProjectItem(stubGetClientFn(client), translations.NullTranslationHelper)
+ request := createMCPRequest(tc.requestArgs)
+
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+
+ if tc.expectError {
+ require.True(t, result.IsError)
+ text := getTextResult(t, result).Text
+ if tc.expectedErrMsg != "" {
+ assert.Contains(t, text, tc.expectedErrMsg)
+ }
+ switch tc.name {
+ case "missing owner":
+ assert.Contains(t, text, "missing required parameter: owner")
+ case "missing owner_type":
+ assert.Contains(t, text, "missing required parameter: owner_type")
+ case "missing project_number":
+ assert.Contains(t, text, "missing required parameter: project_number")
+ case "missing item_type":
+ assert.Contains(t, text, "missing required parameter: item_type")
+ case "missing item_id":
+ assert.Contains(t, text, "missing required parameter: item_id")
+ }
+ return
+ }
+
+ require.False(t, result.IsError)
+ textContent := getTextResult(t, result)
+ var item map[string]any
+ require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item))
+ if tc.expectedID != 0 {
+ assert.Equal(t, float64(tc.expectedID), item["id"])
+ }
+ if tc.expectedContentType != "" {
+ assert.Equal(t, tc.expectedContentType, item["content_type"])
+ }
+ if tc.expectedCreatorLogin != "" {
+ creator, ok := item["creator"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, tc.expectedCreatorLogin, creator["login"])
+ }
+ })
+ }
+}
+
+func Test_DeleteProjectItem(t *testing.T) {
+ mockClient := gh.NewClient(nil)
+ tool, _ := DeleteProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "delete_project_item", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner_type")
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "project_number")
+ assert.Contains(t, tool.InputSchema.Properties, "item_id")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ expectedText string
+ }{
+ {
+ name: "success organization delete",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete},
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(123),
+ "item_id": float64(555),
+ },
+ expectedText: "project item successfully deleted",
+ },
+ {
+ name: "success user delete",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete},
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "octocat",
+ "owner_type": "user",
+ "project_number": float64(456),
+ "item_id": float64(777),
+ },
+ expectedText: "project item successfully deleted",
+ },
+ {
+ name: "api error",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete},
+ mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(321),
+ "item_id": float64(999),
+ },
+ expectError: true,
+ expectedErrMsg: "failed to delete a project item",
+ },
+ {
+ name: "missing owner",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner_type": "org",
+ "project_number": float64(1),
+ "item_id": float64(10),
+ },
+ expectError: true,
+ },
+ {
+ name: "missing owner_type",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "project_number": float64(1),
+ "item_id": float64(10),
+ },
+ expectError: true,
+ },
+ {
+ name: "missing project_number",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "item_id": float64(10),
+ },
+ expectError: true,
+ },
+ {
+ name: "missing item_id",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
+ },
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := gh.NewClient(tc.mockedClient)
+ _, handler := DeleteProjectItem(stubGetClientFn(client), translations.NullTranslationHelper)
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(context.Background(), request)
+
+ require.NoError(t, err)
+ if tc.expectError {
+ require.True(t, result.IsError)
+ text := getTextResult(t, result).Text
+ if tc.expectedErrMsg != "" {
+ assert.Contains(t, text, tc.expectedErrMsg)
+ }
+ switch tc.name {
+ case "missing owner":
+ assert.Contains(t, text, "missing required parameter: owner")
+ case "missing owner_type":
+ assert.Contains(t, text, "missing required parameter: owner_type")
+ case "missing project_number":
+ assert.Contains(t, text, "missing required parameter: project_number")
+ case "missing item_id":
+ assert.Contains(t, text, "missing required parameter: item_id")
+ }
+ return
+ }
+
+ require.False(t, result.IsError)
+ text := getTextResult(t, result).Text
+ assert.Contains(t, text, tc.expectedText)
+ })
+ }
+}
+
+func Test_UpdateProjectItem(t *testing.T) {
+ mockClient := gh.NewClient(nil)
+ tool, _ := UpdateProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "update_project_item", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner_type")
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "project_number")
+ assert.Contains(t, tool.InputSchema.Properties, "item_id")
+ assert.Contains(t, tool.InputSchema.Properties, "fields")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id", "fields"})
+
+ orgUpdated := map[string]any{
+ "id": 801,
+ "content_type": "Issue",
+ "creator": map[string]any{"login": "octocat"},
+ }
+ userUpdated := map[string]any{
+ "id": 901,
+ "content_type": "PullRequest",
+ "creator": map[string]any{"login": "hubot"},
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ expectedID int
+ expectedCreatorLogin string
+ }{
+ {
+ name: "success organization update",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch},
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ assert.NoError(t, err)
+ var payload struct {
+ Fields []struct {
+ ID int `json:"id"`
+ Value interface{} `json:"value"`
+ } `json:"fields"`
+ }
+ assert.NoError(t, json.Unmarshal(body, &payload))
+ assert.Len(t, payload.Fields, 1)
+ if len(payload.Fields) == 1 {
+ assert.Equal(t, 123, payload.Fields[0].ID)
+ assert.Equal(t, "In Progress", payload.Fields[0].Value)
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(mock.MustMarshal(orgUpdated))
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(111),
+ "item_id": float64(2222),
+ "fields": []any{
+ map[string]any{"id": float64(123), "value": "In Progress"},
+ },
+ },
+ expectedID: 801,
+ expectedCreatorLogin: "octocat",
+ },
+ {
+ name: "success user update",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch},
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ assert.NoError(t, err)
+ var payload map[string]any
+ assert.NoError(t, json.Unmarshal(body, &payload))
+ fields, ok := payload["fields"].([]any)
+ assert.True(t, ok)
+ assert.Len(t, fields, 1)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(mock.MustMarshal(userUpdated))
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "octocat",
+ "owner_type": "user",
+ "project_number": float64(222),
+ "item_id": float64(3333),
+ "fields": []any{
+ map[string]any{"id": float64(456), "value": 42},
+ },
+ },
+ expectedID: 901,
+ expectedCreatorLogin: "hubot",
+ },
+ {
+ name: "api error",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch},
+ mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(333),
+ "item_id": float64(4444),
+ "fields": []any{
+ map[string]any{"id": float64(789), "value": "Done"},
+ },
+ },
+ expectError: true,
+ expectedErrMsg: "failed to update a project item",
+ },
+ {
+ name: "missing owner",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner_type": "org",
+ "project_number": float64(1),
+ "item_id": float64(1),
+ "fields": []any{map[string]any{"id": float64(1), "value": "X"}},
+ },
+ expectError: true,
+ },
+ {
+ name: "missing owner_type",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "project_number": float64(1),
+ "item_id": float64(1),
+ "fields": []any{map[string]any{"id": float64(1), "value": "X"}},
+ },
+ expectError: true,
+ },
+ {
+ name: "missing project_number",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "item_id": float64(1),
+ "fields": []any{map[string]any{"id": float64(1), "value": "X"}},
+ },
+ expectError: true,
+ },
+ {
+ name: "missing item_id",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
+ "fields": []any{map[string]any{"id": float64(1), "value": "X"}},
+ },
+ expectError: true,
+ },
+ {
+ name: "missing fields",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
+ "item_id": float64(1),
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: fields",
+ },
+ {
+ name: "empty fields",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
+ "item_id": float64(1),
+ "fields": []any{},
+ },
+ expectError: true,
+ expectedErrMsg: "fields must contain at least one field update",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := gh.NewClient(tc.mockedClient)
+ _, handler := UpdateProjectItem(stubGetClientFn(client), translations.NullTranslationHelper)
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(context.Background(), request)
+
+ require.NoError(t, err)
+ if tc.expectError {
+ require.True(t, result.IsError)
+ text := getTextResult(t, result).Text
+ if tc.expectedErrMsg != "" {
+ assert.Contains(t, text, tc.expectedErrMsg)
+ }
+ switch tc.name {
+ case "missing owner":
+ assert.Contains(t, text, "missing required parameter: owner")
+ case "missing owner_type":
+ assert.Contains(t, text, "missing required parameter: owner_type")
+ case "missing project_number":
+ assert.Contains(t, text, "missing required parameter: project_number")
+ case "missing item_id":
+ assert.Contains(t, text, "missing required parameter: item_id")
+ }
+ return
+ }
+
+ require.False(t, result.IsError)
+ textContent := getTextResult(t, result)
+ var item map[string]any
+ require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item))
+ if tc.expectedID != 0 {
+ assert.Equal(t, float64(tc.expectedID), item["id"])
+ }
+ if tc.expectedCreatorLogin != "" {
+ creator, ok := item["creator"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, tc.expectedCreatorLogin, creator["login"])
+ }
+ })
+ }
+}
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index eb6e657e3..dec0a9e37 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -198,6 +198,11 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(GetProjectField(getClient, t)),
toolsets.NewServerTool(ListProjectItems(getClient, t)),
toolsets.NewServerTool(GetProjectItem(getClient, t)),
+ ).
+ AddWriteTools(
+ toolsets.NewServerTool(AddProjectItem(getClient, t)),
+ toolsets.NewServerTool(DeleteProjectItem(getClient, t)),
+ toolsets.NewServerTool(UpdateProjectItem(getClient, t)),
)
// Add toolsets to the group