Add tools to add, update and delete project items (#1152)
Build and Test Go Project / build (macos-latest) (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
GoReleaser Release / release (push) Has been cancelled
License Check / license-check (push) Has been cancelled
Publish to MCP Registry / publish (push) Has been cancelled

* Add add_project_item tool

* Add tools to update and delete project items

* Update pkg/github/projects.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update pkg/github/projects.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add tests

* Lint the code

* Fix req params

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
JoannaaKL
2025-09-30 10:08:15 +02:00
committed by GitHub
parent 088560113c
commit 6793b9d376
10 changed files with 1077 additions and 9 deletions
+20
View File
@@ -658,6 +658,19 @@ The following sets of tools are available (all are on by default):
<summary>Projects</summary>
- **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)
</details>
<details>
+2 -2
View File
@@ -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),
)
@@ -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"
}
@@ -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"
}
@@ -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"
}
+5 -6
View File
@@ -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 ""
}
}
+1 -1
View File
@@ -163,4 +163,4 @@ func TestGetToolsetInstructions(t *testing.T) {
}
})
}
}
}
+295
View File
@@ -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"`
+618
View File
@@ -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"])
}
})
}
}
+5
View File
@@ -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