Skip to content
Draft
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,7 @@ The following sets of tools are available:
- `item_owner`: The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number. (string, optional)
- `item_repo`: The name of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number. (string, optional)
- `item_type`: The item's type, either issue or pull_request. Required for 'add_project_item' method. (string, optional)
- `items`: The items to update with the top-level 'updated_field'. Required for 'update_project_items'; prefer it over calling 'update_project_item' in a loop. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Limit: 100 items per call. (object[], optional)
- `iteration_duration`: Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method. (number, optional)
- `iterations`: Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases. (object[], optional)
- `method`: The method to execute (string, required)
Expand All @@ -1130,7 +1131,7 @@ The following sets of tools are available:
- `status`: The status of the project. Used for 'create_project_status_update' method. (string, optional)
- `target_date`: The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional)
- `title`: The project title. Required for 'create_project' method. (string, optional)
- `updated_field`: Object describing the field to update and its new value. Required for 'update_project_item'. Two shapes are accepted: (1) by ID — {"id": 123456, "value": "..."}; (2) by name — {"name": "Status", "value": "In Progress"}. For single-select fields, option-name resolution requires the by-name shape; on the by-ID shape, pass the option ID. Set value to null to clear the field. (object, optional)
- `updated_field`: The field/value to apply, using {"id": 123, "value": ...} or {"name": "Status", "value": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item' SINGLE_SELECT fields, the name form accepts option names; the ID form expects an option ID. (object, optional)

</details>

Expand Down
99 changes: 97 additions & 2 deletions pkg/github/__toolsnaps__/projects_write.snap
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"readOnlyHint": false,
"title": "Manage GitHub Projects"
},
"description": "Create and manage GitHub Projects: create projects, add/update/delete items, create status updates, and add iteration fields.",
"description": "Create and manage GitHub Projects: create projects, add/update/delete items, bulk-update many items at once, create status updates, and add iteration fields.",
"inputSchema": {
"properties": {
"body": {
Expand Down Expand Up @@ -40,6 +40,64 @@
],
"type": "string"
},
"items": {
"description": "The items to update with the top-level 'updated_field'. Required for 'update_project_items'; prefer it over calling 'update_project_item' in a loop. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Limit: 100 items per call.",
"items": {
"oneOf": [
{
"additionalProperties": false,
"properties": {
"node_id": {
"description": "The project item's GraphQL node ID, as returned by 'list_project_items' or 'add_project_item'.",
"type": "string"
}
},
"required": [
"node_id"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"item_id": {
"description": "The numeric project item ID.",
"type": "integer"
}
},
"required": [
"item_id"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"issue_number": {
"description": "Issue number used to resolve the project item.",
"type": "integer"
},
"item_owner": {
"description": "Owner of the repository containing the issue.",
"type": "string"
},
"item_repo": {
"description": "Repository containing the issue.",
"type": "string"
}
},
"required": [
"item_owner",
"item_repo",
"issue_number"
],
"type": "object"
}
],
"type": "object"
},
"type": "array"
},
"iteration_duration": {
"description": "Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method.",
"type": "number"
Expand Down Expand Up @@ -76,6 +134,7 @@
"enum": [
"add_project_item",
"update_project_item",
"update_project_items",
"delete_project_item",
"create_project_status_update",
"create_project",
Expand Down Expand Up @@ -127,7 +186,43 @@
"type": "string"
},
"updated_field": {
"description": "Object describing the field to update and its new value. Required for 'update_project_item'. Two shapes are accepted: (1) by ID — {\"id\": 123456, \"value\": \"...\"}; (2) by name — {\"name\": \"Status\", \"value\": \"In Progress\"}. For single-select fields, option-name resolution requires the by-name shape; on the by-ID shape, pass the option ID. Set value to null to clear the field.",
"description": "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item' SINGLE_SELECT fields, the name form accepts option names; the ID form expects an option ID.",
"oneOf": [
{
"additionalProperties": false,
"properties": {
"id": {
"description": "The numeric project field ID.",
"type": "integer"
},
"value": {
"description": "The value to apply. Any JSON value is accepted; use null to clear the field."
}
},
"required": [
"id",
"value"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"name": {
"description": "The project field name. Matching is case-insensitive.",
"type": "string"
},
"value": {
"description": "The value to apply. Any JSON value is accepted; use null to clear the field."
}
},
"required": [
"name",
"value"
],
"type": "object"
}
],
"type": "object"
}
},
Expand Down
92 changes: 88 additions & 4 deletions pkg/github/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const (
ProjectStatusUpdateCreateFailedError = "failed to create project status update"
ProjectResolveIDFailedError = "failed to resolve project ID"
MaxProjectsPerPage = 50
maxProjectItemsPerBatch = 100
)

// Method constants for consolidated project tools
Expand All @@ -44,6 +45,7 @@ const (
projectsMethodGetProjectItem = "get_project_item"
projectsMethodAddProjectItem = "add_project_item"
projectsMethodUpdateProjectItem = "update_project_item"
projectsMethodUpdateProjectItems = "update_project_items"
projectsMethodDeleteProjectItem = "delete_project_item"
projectsMethodListProjectStatusUpdates = "list_project_status_updates"
projectsMethodGetProjectStatusUpdate = "get_project_status_update"
Expand Down Expand Up @@ -490,13 +492,90 @@ Use this tool to get details about individual projects, project fields, and proj
return tool
}

func updateProjectItemsItemSchema() *jsonschema.Schema {
variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema {
return &jsonschema.Schema{
Type: "object",
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
Properties: properties,
Required: required,
}
}

return &jsonschema.Schema{
Type: "object",
OneOf: []*jsonschema.Schema{
variant([]string{"node_id"}, map[string]*jsonschema.Schema{
"node_id": {
Type: "string",
Description: "The project item's GraphQL node ID, as returned by 'list_project_items' or 'add_project_item'.",
},
}),
variant([]string{"item_id"}, map[string]*jsonschema.Schema{
"item_id": {
Type: "integer",
Description: "The numeric project item ID.",
},
}),
variant([]string{"item_owner", "item_repo", "issue_number"}, map[string]*jsonschema.Schema{
"item_owner": {
Type: "string",
Description: "Owner of the repository containing the issue.",
},
"item_repo": {
Type: "string",
Description: "Repository containing the issue.",
},
"issue_number": {
Type: "integer",
Description: "Issue number used to resolve the project item.",
},
}),
},
}
}

func projectUpdatedFieldSchema() *jsonschema.Schema {
value := &jsonschema.Schema{
Description: "The value to apply. Any JSON value is accepted; use null to clear the field.",
}
variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema {
properties["value"] = value
return &jsonschema.Schema{
Type: "object",
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
Properties: properties,
Required: required,
}
}

return &jsonschema.Schema{
Type: "object",
Description: "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item' SINGLE_SELECT fields, the name form accepts option names; the ID form expects an option ID.",
OneOf: []*jsonschema.Schema{
variant([]string{"id", "value"}, map[string]*jsonschema.Schema{
"id": {
Type: "integer",
Description: "The numeric project field ID.",
},
}),
variant([]string{"name", "value"}, map[string]*jsonschema.Schema{
"name": {
Type: "string",
Description: "The project field name. Matching is case-insensitive.",
},
}),
},
}
}

// ProjectsWrite returns the tool and handler for modifying GitHub Projects resources.
func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
tool := NewTool(
ToolsetMetadataProjects,
mcp.Tool{
Name: "projects_write",
Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Create and manage GitHub Projects: create projects, add/update/delete items, create status updates, and add iteration fields."),
Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Create and manage GitHub Projects: create projects, add/update/delete items, bulk-update many items at once, create status updates, and add iteration fields."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Manage GitHub Projects"),
ReadOnlyHint: false,
Expand All @@ -511,6 +590,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
Enum: []any{
projectsMethodAddProjectItem,
projectsMethodUpdateProjectItem,
projectsMethodUpdateProjectItems,
projectsMethodDeleteProjectItem,
projectsMethodCreateProjectStatusUpdate,
projectsMethodCreateProject,
Expand Down Expand Up @@ -559,9 +639,11 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
Type: "number",
Description: "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.",
},
"updated_field": {
Type: "object",
Description: "Object describing the field to update and its new value. Required for 'update_project_item'. Two shapes are accepted: (1) by ID — {\"id\": 123456, \"value\": \"...\"}; (2) by name — {\"name\": \"Status\", \"value\": \"In Progress\"}. For single-select fields, option-name resolution requires the by-name shape; on the by-ID shape, pass the option ID. Set value to null to clear the field.",
"updated_field": projectUpdatedFieldSchema(),
"items": {
Type: "array",
Description: "The items to update with the top-level 'updated_field'. Required for 'update_project_items'; prefer it over calling 'update_project_item' in a loop. Each entry must match exactly one reference variant: 'node_id', numeric 'item_id', or 'item_owner' + 'item_repo' + 'issue_number'. Limit: " + strconv.Itoa(maxProjectItemsPerBatch) + " items per call.",
Items: updateProjectItemsItemSchema(),
},
"body": {
Type: "string",
Expand Down Expand Up @@ -722,6 +804,8 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultError("updated_field must be an object"), nil, nil
}
return updateProjectItem(ctx, client, gqlClient, owner, ownerType, projectNumber, itemID, fieldValue)
case projectsMethodUpdateProjectItems:
return updateProjectItemsBatch(ctx, client, gqlClient, owner, ownerType, projectNumber, args)
case projectsMethodDeleteProjectItem:
itemID, err := RequiredBigInt(args, "item_id")
if err != nil {
Expand Down
Loading