Files
Adam Holt a26079a4b1
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
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
Add open world hints for tools.
Tools that return user generated content should have open world hints,
to allow clients to inform users that the content may be untrusted and
prompt them to approve the request before executing it.
2025-12-30 13:47:36 +01:00

1099 lines
34 KiB
Go

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/inventory"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v79/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
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
)
func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "list_projects",
Description: t("TOOL_LIST_PROJECTS_DESCRIPTION", `List Projects for a user or organization`),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"),
ReadOnlyHint: true,
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner_type": {
Type: "string",
Description: "Owner type",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
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.",
},
"query": {
Type: "string",
Description: `Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning".`,
},
"per_page": {
Type: "number",
Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage),
},
"after": {
Type: "string",
Description: "Forward pagination cursor from previous pageInfo.nextCursor.",
},
"before": {
Type: "string",
Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).",
},
},
Required: []string{"owner_type", "owner"},
},
},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerType, err := RequiredParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
queryStr, err := OptionalParam[string](args, "query")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
pagination, err := extractPaginationOptionsFromArgs(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, 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, 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, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
}
func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "get_project",
Description: t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"),
ReadOnlyHint: true,
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"project_number": {
Type: "number",
Description: "The project's number",
},
"owner_type": {
Type: "string",
Description: "Owner type",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
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.",
},
},
Required: []string{"project_number", "owner_type", "owner"},
},
},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerType, err := RequiredParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, 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, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), nil, nil
}
minimalProject := convertToMinimalProject(project)
r, err := json.Marshal(minimalProject)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
}
func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "list_project_fields",
Description: t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"),
ReadOnlyHint: true,
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner_type": {
Type: "string",
Description: "Owner type",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
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.",
},
"project_number": {
Type: "number",
Description: "The project's number.",
},
"per_page": {
Type: "number",
Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage),
},
"after": {
Type: "string",
Description: "Forward pagination cursor from previous pageInfo.nextCursor.",
},
"before": {
Type: "string",
Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).",
},
},
Required: []string{"owner_type", "owner", "project_number"},
},
},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerType, err := RequiredParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
pagination, err := extractPaginationOptionsFromArgs(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
var resp *github.Response
var projectFields []*github.ProjectV2Field
opts := &github.ListProjectsOptions{
ListProjectsPaginationOptions: pagination,
}
if ownerType == "org" {
projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts)
} else {
projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts)
}
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to list project fields",
resp,
err,
), nil, 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, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
}
func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "get_project_field",
Description: t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"),
ReadOnlyHint: true,
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner_type": {
Type: "string",
Description: "Owner type",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
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.",
},
"project_number": {
Type: "number",
Description: "The project's number.",
},
"field_id": {
Type: "number",
Description: "The field's id.",
},
},
Required: []string{"owner_type", "owner", "project_number", "field_id"},
},
},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerType, err := RequiredParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
fieldID, err := RequiredBigInt(args, "field_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
var resp *github.Response
var projectField *github.ProjectV2Field
if ownerType == "org" {
projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID)
} else {
projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID)
}
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get project field",
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project field", resp, body), nil, nil
}
r, err := json.Marshal(projectField)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
}
func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "list_project_items",
Description: t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", `Search project items with advanced filtering`),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"),
ReadOnlyHint: true,
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner_type": {
Type: "string",
Description: "Owner type",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
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.",
},
"project_number": {
Type: "number",
Description: "The project's number.",
},
"query": {
Type: "string",
Description: `Query string for advanced filtering of project items using GitHub's project filtering syntax.`,
},
"per_page": {
Type: "number",
Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage),
},
"after": {
Type: "string",
Description: "Forward pagination cursor from previous pageInfo.nextCursor.",
},
"before": {
Type: "string",
Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).",
},
"fields": {
Type: "array",
Description: "Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned.",
Items: &jsonschema.Schema{
Type: "string",
},
},
},
Required: []string{"owner_type", "owner", "project_number"},
},
},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerType, err := RequiredParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
queryStr, err := OptionalParam[string](args, "query")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
fields, err := OptionalBigIntArrayParam(args, "fields")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
pagination, err := extractPaginationOptionsFromArgs(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, 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, 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, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
}
func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "get_project_item",
Description: t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"),
ReadOnlyHint: true,
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner_type": {
Type: "string",
Description: "Owner type",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
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.",
},
"project_number": {
Type: "number",
Description: "The project's number.",
},
"item_id": {
Type: "number",
Description: "The item's ID.",
},
"fields": {
Type: "array",
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.",
Items: &jsonschema.Schema{
Type: "string",
},
},
},
Required: []string{"owner_type", "owner", "project_number", "item_id"},
},
},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerType, err := RequiredParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
itemID, err := RequiredBigInt(args, "item_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
fields, err := OptionalBigIntArrayParam(args, "fields")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, 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, nil
}
defer func() { _ = resp.Body.Close() }()
r, err := json.Marshal(projectItem)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
}
func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "add_project_item",
Description: t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_ADD_PROJECT_ITEM_USER_TITLE", "Add project item"),
ReadOnlyHint: false,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner_type": {
Type: "string",
Description: "Owner type",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
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.",
},
"project_number": {
Type: "number",
Description: "The project's number.",
},
"item_type": {
Type: "string",
Description: "The item's type, either issue or pull_request.",
Enum: []any{"issue", "pull_request"},
},
"item_id": {
Type: "number",
Description: "The numeric ID of the issue or pull request to add to the project.",
},
},
Required: []string{"owner_type", "owner", "project_number", "item_type", "item_id"},
},
},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerType, err := RequiredParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
itemID, err := RequiredBigInt(args, "item_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
itemType, err := RequiredParam[string](args, "item_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if itemType != "issue" && itemType != "pull_request" {
return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, 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, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectAddFailedError, resp, body), nil, nil
}
r, err := json.Marshal(addedItem)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
}
func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "update_project_item",
Description: t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"),
ReadOnlyHint: false,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner_type": {
Type: "string",
Description: "Owner type",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
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.",
},
"project_number": {
Type: "number",
Description: "The project's number.",
},
"item_id": {
Type: "number",
Description: "The unique identifier of the project item. This is not the issue or pull request ID.",
},
"updated_field": {
Type: "object",
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\"}",
},
},
Required: []string{"owner_type", "owner", "project_number", "item_id", "updated_field"},
},
},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerType, err := RequiredParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
itemID, err := RequiredBigInt(args, "item_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
rawUpdatedField, exists := args["updated_field"]
if !exists {
return utils.NewToolResultError("missing required parameter: updated_field"), nil, nil
}
fieldValue, ok := rawUpdatedField.(map[string]any)
if !ok || fieldValue == nil {
return utils.NewToolResultError("field_value must be an object"), nil, nil
}
updatePayload, err := buildUpdateProjectItem(fieldValue)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, 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, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectUpdateFailedError, resp, body), nil, nil
}
r, err := json.Marshal(updatedItem)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
}
func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "delete_project_item",
Description: t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner_type": {
Type: "string",
Description: "Owner type",
Enum: []any{"user", "org"},
},
"owner": {
Type: "string",
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.",
},
"project_number": {
Type: "number",
Description: "The project's number.",
},
"item_id": {
Type: "number",
Description: "The internal project item ID to delete from the project (not the issue or pull request ID).",
},
},
Required: []string{"owner_type", "owner", "project_number", "item_id"},
},
},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerType, err := RequiredParam[string](args, "owner_type")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
itemID, err := RequiredBigInt(args, "item_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, 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, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNoContent {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectDeleteFailedError, resp, body), nil, nil
}
return utils.NewToolResultText("project item successfully deleted"), nil, 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 extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsPaginationOptions, error) {
perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage)
if err != nil {
return github.ListProjectsPaginationOptions{}, err
}
if perPage > MaxProjectsPerPage {
perPage = MaxProjectsPerPage
}
after, err := OptionalParam[string](args, "after")
if err != nil {
return github.ListProjectsPaginationOptions{}, err
}
before, err := OptionalParam[string](args, "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
}