From fa338aaea16e1062496a997458e5c9a21b84a410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Doria=20de=20Souza?= Date: Thu, 22 Jan 2026 14:56:18 +0100 Subject: [PATCH 1/4] feat: Add support for GitHub Projects v2 Iteration Fields - Add 'create_project' tool to create Projects V2 - Add 'create_iteration_field' tool to create and configure iteration fields (sprints) - Add 'getOwnerNodeID' and 'getProjectNodeID' helper functions --- pkg/github/projects.go | 332 +++++++++++++++++++++++++++++++++++++++++ pkg/github/tools.go | 2 + 2 files changed, 334 insertions(+) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 8af181a72..7523b835a 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "strings" + "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" @@ -16,6 +17,7 @@ import ( "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" ) const ( @@ -1868,3 +1870,333 @@ func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsP return opts, nil } + +func CreateProject(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "create_project", + Description: t("TOOL_CREATE_PROJECT_DESCRIPTION", "Create a new GitHub Project (ProjectsV2). Returns the project ID and number."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_PROJECT_USER_TITLE", "Create project"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "GitHub username or organization name", + }, + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "title": { + Type: "string", + Description: "Project title", + }, + "description": { + Type: "string", + Description: "Project description (optional)", + }, + }, + Required: []string{"owner", "owner_type", "title"}, + }, + }, + []scopes.Scope{scopes.Project}, + 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 + } + title, err := RequiredParam[string](args, "title") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerId, err := getOwnerNodeID(ctx, gqlClient, owner, ownerType) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get owner ID: %v", err)), nil, nil + } + + var mutation struct { + CreateProjectV2 struct { + ProjectV2 struct { + ID string + Number int + Title string + URL string + } + } `graphql:"createProjectV2(input: $input)"` + } + + input := githubv4.CreateProjectV2Input{ + OwnerID: githubv4.ID(ownerId), + Title: githubv4.String(title), + } + + err = gqlClient.Mutate(ctx, &mutation, input, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create project: %v", err)), nil, nil + } + + return MarshalledTextResult(mutation.CreateProjectV2.ProjectV2), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects + return tool +} + +func CreateIterationField(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "create_iteration_field", + Description: t("TOOL_CREATE_ITERATION_FIELD_DESCRIPTION", "Create an iteration field on a ProjectsV2 with weekly sprints. Returns field ID and iteration IDs."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_ITERATION_FIELD_USER_TITLE", "Create iteration field"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "GitHub username or organization name", + }, + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "project_number": { + Type: "number", + Description: "The project's number", + }, + "field_name": { + Type: "string", + Description: "Field name (e.g., 'Sprint', 'Iteration')", + }, + "duration": { + Type: "number", + Description: "Duration in days for each iteration (typically 7 for weekly)", + }, + "start_date": { + Type: "string", + Description: "Start date in YYYY-MM-DD format", + }, + "iterations": { + Type: "array", + Description: "Array of iteration definitions", + Items: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "title": { + Type: "string", + }, + "startDate": { + Type: "string", + }, + "duration": { + Type: "number", + }, + }, + Required: []string{"title", "startDate", "duration"}, + }, + }, + }, + Required: []string{"owner", "owner_type", "project_number", "field_name", "duration", "start_date", "iterations"}, + }, + }, + []scopes.Scope{scopes.Project}, + 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 + } + fieldName, err := RequiredParam[string](args, "field_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + duration, err := RequiredInt(args, "duration") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + startDateStr, err := RequiredParam[string](args, "start_date") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // Handle iterations array + rawIterations, ok := args["iterations"].([]any) + if !ok { + return utils.NewToolResultError("iterations must be an array"), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectId, err := getProjectNodeID(ctx, client, owner, ownerType, projectNumber) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil + } + + // Step 1: Create Field + var createMutation struct { + CreateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"createProjectV2Field(input: $input)"` + } + + createInput := githubv4.CreateProjectV2FieldInput{ + ProjectID: githubv4.ID(projectId), + DataType: githubv4.ProjectV2CustomFieldTypeIteration, + Name: githubv4.String(fieldName), + } + + err = gqlClient.Mutate(ctx, &createMutation, createInput, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create iteration field: %v", err)), nil, nil + } + + fieldId := createMutation.CreateProjectV2Field.ProjectV2Field.ProjectV2IterationField.ID + + // Step 2: Update Field Configuration + var updateMutation struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + } + + var iterationsInput []githubv4.ProjectV2IterationFieldIterationInput + for _, item := range rawIterations { + iterMap, ok := item.(map[string]any) + if !ok { continue } + title, _ := iterMap["title"].(string) + sDate, _ := iterMap["startDate"].(string) + dur, _ := iterMap["duration"].(float64) + + parsedSDate, err := time.Parse("2006-01-02", sDate) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to parse iteration startDate %s: %v", sDate, err)), nil, nil + } + + iterationsInput = append(iterationsInput, githubv4.ProjectV2IterationFieldIterationInput{ + Title: githubv4.String(title), + StartDate: githubv4.Date{Time: parsedSDate}, + Duration: githubv4.Int(dur), + }) + } + + parsedStartDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to parse start_date %s: %v", startDateStr, err)), nil, nil + } + + configInput := githubv4.ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(duration), + StartDate: githubv4.Date{Time: parsedStartDate}, + Iterations: &iterationsInput, + } + + updateInput := githubv4.UpdateProjectV2FieldInput{ + ProjectID: githubv4.ID(projectId), + FieldID: githubv4.ID(fieldId), + IterationConfiguration: &configInput, + } + + err = gqlClient.Mutate(ctx, &updateMutation, updateInput, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to update iteration configuration: %v", err)), nil, nil + } + + return MarshalledTextResult(updateMutation.UpdateProjectV2Field.ProjectV2Field.ProjectV2IterationField), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects + return tool +} + +func getOwnerNodeID(ctx context.Context, client *githubv4.Client, owner string, ownerType string) (string, error) { + if ownerType == "org" { + var query struct { + Organization struct { + ID string + } `graphql:"organization(login: $login)"` + } + variables := map[string]interface{}{ + "login": githubv4.String(owner), + } + err := client.Query(ctx, &query, variables) + return query.Organization.ID, err + } else { + var query struct { + User struct { + ID string + } `graphql:"user(login: $login)"` + } + variables := map[string]interface{}{ + "login": githubv4.String(owner), + } + err := client.Query(ctx, &query, variables) + return query.User.ID, err + } +} + +func getProjectNodeID(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (string, error) { + if ownerType == "org" { + project, _, err := client.Projects.GetOrganizationProject(ctx, owner, projectNumber) + if err != nil { + return "", err + } + return project.GetNodeID(), nil + } else { + project, _, err := client.Projects.GetUserProject(ctx, owner, projectNumber) + if err != nil { + return "", err + } + return project.GetNodeID(), nil + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 4384b730d..e32b088b1 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -283,6 +283,8 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ProjectsList(t), ProjectsGet(t), ProjectsWrite(t), + CreateProject(t), + CreateIterationField(t), // Label tools GetLabel(t), From 6c7daa5220f41aabc82b6650e84e186f0541ecb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Doria=20de=20Souza?= Date: Thu, 22 Jan 2026 14:56:18 +0100 Subject: [PATCH 2/4] feat: Add support for GitHub Projects v2 Iteration Fields - Add 'create_project' tool to create Projects V2 - Add 'create_iteration_field' tool to create and configure iteration fields (sprints) - Add 'getOwnerNodeID' and 'getProjectNodeID' helper functions --- go.mod | 3 - go.sum | 11 +- pkg/github/projects.go | 416 +++++++++++++++++++++++++++++++++++++++-- pkg/github/tools.go | 2 + 4 files changed, 408 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index e50f52c72..937a9f2ed 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/github/github-mcp-server go 1.24.0 require ( - github.com/fatih/color v1.18.0 github.com/google/go-github/v79 v79.0.0 github.com/google/jsonschema-go v0.4.2 github.com/josephburnett/jd v1.9.2 @@ -21,8 +20,6 @@ require ( github.com/gorilla/css v1.0.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index 89c8b1dda..4bb758cdb 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -53,11 +51,6 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= @@ -75,6 +68,8 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/shurcooL/githubv4 v0.0.0-20230704064427-599ae7bbf278 h1:kdEGVAV4sO46DPtb8k793jiecUEhaX9ixoIBt41HEGU= +github.com/shurcooL/githubv4 v0.0.0-20230704064427-599ae7bbf278/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M= github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= @@ -130,9 +125,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 2ca441f7b..c4255a034 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "strings" + "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" @@ -1480,7 +1481,8 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an "failed to list projects", resp, err, - ), nil, nil + ), + nil, nil } default: projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) @@ -1489,7 +1491,8 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an "failed to list projects", resp, err, - ), nil, nil + ), + nil, nil } } @@ -1552,7 +1555,7 @@ func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, // If both failed, return error if (userErr != nil || userResp == nil || userResp.StatusCode != http.StatusOK) && (orgErr != nil || orgResp == nil || orgResp.StatusCode != http.StatusOK) { - return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil + return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': owner is neither a user nor an org with this project", owner)), nil, nil } response := map[string]any{ @@ -1600,7 +1603,8 @@ func listProjectFields(ctx context.Context, client *github.Client, args map[stri "failed to list project fields", resp, err, - ), nil, nil + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1665,7 +1669,8 @@ func listProjectItems(ctx context.Context, client *github.Client, args map[strin ProjectListFailedError, resp, err, - ), nil, nil + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1697,7 +1702,8 @@ func getProject(ctx context.Context, client *github.Client, owner, ownerType str "failed to get project", resp, err, - ), nil, nil + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1734,7 +1740,8 @@ func getProjectField(ctx context.Context, client *github.Client, owner, ownerTyp "failed to get project field", resp, err, - ), nil, nil + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1776,7 +1783,8 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType "failed to get project item", resp, err, - ), nil, nil + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1816,7 +1824,8 @@ func updateProjectItem(ctx context.Context, client *github.Client, owner, ownerT ProjectUpdateFailedError, resp, err, - ), nil, nil + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1850,7 +1859,8 @@ func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerT ProjectDeleteFailedError, resp, err, - ), nil, nil + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1936,7 +1946,7 @@ func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, owne err = gqlClient.Mutate(ctx, &mutation, input, nil) if err != nil { - return utils.NewToolResultError(fmt.Sprintf(ProjectAddFailedError+": %v", err)), nil, nil + return utils.NewToolResultError(fmt.Sprintf(ProjectAddFailedError+`: %v`, err)), nil, nil } result := map[string]any{ @@ -2011,7 +2021,7 @@ func buildUpdateProjectItem(input map[string]any) (*github.UpdateProjectItemOpti } payload := &github.UpdateProjectItemOptions{ - Fields: []*github.UpdateProjectV2Field{{ + Fields: []*github.UpdateProjectV2Field{{ ID: fieldID, Value: valueField, }}, @@ -2137,3 +2147,385 @@ func detectOwnerType(ctx context.Context, client *github.Client, owner string, p return "", fmt.Errorf("could not determine owner type for %s with project %d: owner is neither a user nor an org with this project", owner, projectNumber) } + +func CreateProject(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "create_project", + Description: t("TOOL_CREATE_PROJECT_DESCRIPTION", "Create a new GitHub Project (ProjectsV2). Returns the project ID and number."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_PROJECT_USER_TITLE", "Create project"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "GitHub username or organization name", + }, + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "title": { + Type: "string", + Description: "Project title", + }, + "description": { + Type: "string", + Description: "Project description (optional)", + }, + }, + Required: []string{"owner", "owner_type", "title"}, + }, + }, + []scopes.Scope{scopes.Project}, + 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 + } + title, err := RequiredParam[string](args, "title") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerId, err := getOwnerNodeID(ctx, gqlClient, owner, ownerType) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get owner ID: %v", err)), nil, nil + } + + var mutation struct { + CreateProjectV2 struct { + ProjectV2 struct { + ID string + Number int + Title string + URL string + } + } `graphql:"createProjectV2(input: $input)"` + } + + input := githubv4.CreateProjectV2Input{ + OwnerID: githubv4.ID(ownerId), + Title: githubv4.String(title), + } + + err = gqlClient.Mutate(ctx, &mutation, input, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create project: %v", err)), nil, nil + } + + return MarshalledTextResult(mutation.CreateProjectV2.ProjectV2), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects + return tool +} + +func CreateIterationField(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "create_iteration_field", + Description: t("TOOL_CREATE_ITERATION_FIELD_DESCRIPTION", "Create an iteration field on a ProjectsV2 with weekly sprints. Returns field ID and iteration IDs."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_ITERATION_FIELD_USER_TITLE", "Create iteration field"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "GitHub username or organization name", + }, + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "project_number": { + Type: "number", + Description: "The project's number", + }, + "field_name": { + Type: "string", + Description: "Field name (e.g., 'Sprint', 'Iteration')", + }, + "duration": { + Type: "number", + Description: "Duration in days for each iteration (typically 7 for weekly)", + }, + "start_date": { + Type: "string", + Description: "Start date in YYYY-MM-DD format", + }, + "iterations": { + Type: "array", + Description: "Array of iteration definitions", + Items: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "title": { + Type: "string", + }, + "startDate": { + Type: "string", + }, + "duration": { + Type: "number", + }, + }, + Required: []string{"title", "startDate", "duration"}, + }, + }, + }, + Required: []string{"owner", "owner_type", "project_number", "field_name", "duration", "start_date", "iterations"}, + }, + }, + []scopes.Scope{scopes.Project}, + 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 + } + fieldName, err := RequiredParam[string](args, "field_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + duration, err := RequiredInt(args, "duration") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + startDateStr, err := RequiredParam[string](args, "start_date") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // Handle iterations array + rawIterations, ok := args["iterations"].([]any) + if !ok { + return utils.NewToolResultError("iterations must be an array"), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectId, err := getProjectNodeID(ctx, client, owner, ownerType, projectNumber) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil + } + + // Step 1: Create Field + var createMutation struct { + CreateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"createProjectV2Field(input: $input)"` + } + + createInput := githubv4.CreateProjectV2FieldInput{ + ProjectID: githubv4.ID(projectId), + DataType: githubv4.ProjectV2CustomFieldType("ITERATION"), + Name: githubv4.String(fieldName), + } + + err = gqlClient.Mutate(ctx, &createMutation, createInput, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create iteration field: %v", err)), nil, nil + } + + fieldId := createMutation.CreateProjectV2Field.ProjectV2Field.ProjectV2IterationField.ID + + // Step 2: Update Field Configuration + var updateMutation struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + } + + var iterationsInput []ProjectV2IterationFieldIterationInput + for _, item := range rawIterations { + iterMap, ok := item.(map[string]any) + if !ok { + continue + } + title, _ := iterMap["title"].(string) + sDate, _ := iterMap["startDate"].(string) + dur, _ := iterMap["duration"].(float64) + + parsedSDate, err := time.Parse("2006-01-02", sDate) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to parse iteration startDate %s: %v", sDate, err)), nil, nil + } + + iterationsInput = append(iterationsInput, ProjectV2IterationFieldIterationInput{ + Title: githubv4.String(title), + StartDate: githubv4.Date{Time: parsedSDate}, + Duration: githubv4.Int(dur), + }) + } + + parsedStartDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to parse start_date %s: %v", startDateStr, err)), nil, nil + } + + configInput := ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(duration), + StartDate: githubv4.Date{Time: parsedStartDate}, + Iterations: &iterationsInput, + } + + updateInput := UpdateProjectV2FieldInput{ + ProjectID: githubv4.ID(projectId), + FieldID: githubv4.ID(fieldId), + IterationConfiguration: &configInput, + } + + err = gqlClient.Mutate(ctx, &updateMutation, updateInput, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to update iteration configuration: %v", err)), nil, nil + } + + return MarshalledTextResult(updateMutation.UpdateProjectV2Field.ProjectV2Field.ProjectV2IterationField), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects + return tool +} + +func getOwnerNodeID(ctx context.Context, client *githubv4.Client, owner string, ownerType string) (string, error) { + if ownerType == "org" { + var query struct { + Organization struct { + ID string + } `graphql:"organization(login: $login)"` + } + variables := map[string]interface{}{ + "login": githubv4.String(owner), + } + err := client.Query(ctx, &query, variables) + return query.Organization.ID, err + } else { + var query struct { + User struct { + ID string + } `graphql:"user(login: $login)"` + } + variables := map[string]interface{}{ + "login": githubv4.String(owner), + } + err := client.Query(ctx, &query, variables) + return query.User.ID, err + } +} + +func getProjectNodeID(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (string, error) { + + if ownerType == "org" { + + project, _, err := client.Projects.GetOrganizationProject(ctx, owner, projectNumber) + + if err != nil { + + return "", err + + } + + return project.GetNodeID(), nil + + } else { + + project, _, err := client.Projects.GetUserProject(ctx, owner, projectNumber) + + if err != nil { + + return "", err + + } + + return project.GetNodeID(), nil + + } + +} + + + +type UpdateProjectV2FieldInput struct { + + ProjectID githubv4.ID `json:"projectId"` + + FieldID githubv4.ID `json:"fieldId"` + + IterationConfiguration *ProjectV2IterationFieldConfigurationInput `json:"iterationConfiguration,omitempty"` + +} + + + +type ProjectV2IterationFieldConfigurationInput struct { + + Duration githubv4.Int `json:"duration"` + + StartDate githubv4.Date `json:"startDate"` + + Iterations *[]ProjectV2IterationFieldIterationInput `json:"iterations"` + +} + + + +type ProjectV2IterationFieldIterationInput struct { + + StartDate githubv4.Date `json:"startDate"` + + Duration githubv4.Int `json:"duration"` + + Title githubv4.String `json:"title"` + +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index a169ff591..dcca1f82e 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -288,6 +288,8 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ProjectsList(t), ProjectsGet(t), ProjectsWrite(t), + CreateProject(t), + CreateIterationField(t), // Label tools GetLabel(t), From 641d8d6e4feaff19dd8039341ef82b3a342d243c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Doria=20de=20Souza?= Date: Tue, 27 Jan 2026 13:59:34 +0100 Subject: [PATCH 3/4] Resolve conflicts and fix compilation by defining missing GraphQL types locally --- pkg/github/__toolsnaps__/projects_write.snap | 2 +- pkg/github/projects.go | 92 ++++++++++++++++---- 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index d2d871bcd..c9d42d585 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -11,7 +11,7 @@ "type": "number" }, "item_id": { - "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.", + "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add.", "type": "number" }, "item_owner": { diff --git a/pkg/github/projects.go b/pkg/github/projects.go index fe68feb4c..cb7025375 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -2220,31 +2220,43 @@ func CreateIterationField(t translations.TranslationHelperFunc) inventory.Server } createInput := githubv4.CreateProjectV2FieldInput{ + ProjectID: githubv4.ID(projectId), - DataType: githubv4.ProjectV2CustomFieldTypeIteration, - Name: githubv4.String(fieldName), + + DataType: ProjectV2CustomFieldTypeIteration, + + Name: githubv4.String(fieldName), } err = gqlClient.Mutate(ctx, &createMutation, createInput, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create iteration field: %v", err)), nil, nil + } fieldId := createMutation.CreateProjectV2Field.ProjectV2Field.ProjectV2IterationField.ID // Step 2: Update Field Configuration + var updateMutation struct { UpdateProjectV2Field struct { ProjectV2Field struct { ProjectV2IterationField struct { - ID string - Name string + ID string + + Name string + Configuration struct { Iterations []struct { - ID string - Title string + ID string + + Title string + StartDate string - Duration int + + Duration int } } } `graphql:"... on ProjectV2IterationField"` @@ -2252,44 +2264,69 @@ func CreateIterationField(t translations.TranslationHelperFunc) inventory.Server } `graphql:"updateProjectV2Field(input: $input)"` } - var iterationsInput []githubv4.ProjectV2IterationFieldIterationInput + var iterationsInput []ProjectV2IterationFieldIterationInput + for _, item := range rawIterations { + iterMap, ok := item.(map[string]any) - if !ok { continue } + + if !ok { + continue + } + title, _ := iterMap["title"].(string) + sDate, _ := iterMap["startDate"].(string) + dur, _ := iterMap["duration"].(float64) - + parsedSDate, err := time.Parse("2006-01-02", sDate) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to parse iteration startDate %s: %v", sDate, err)), nil, nil + } - iterationsInput = append(iterationsInput, githubv4.ProjectV2IterationFieldIterationInput{ - Title: githubv4.String(title), + iterationsInput = append(iterationsInput, ProjectV2IterationFieldIterationInput{ + + Title: githubv4.String(title), + StartDate: githubv4.Date{Time: parsedSDate}, - Duration: githubv4.Int(dur), + + Duration: githubv4.Int(dur), }) + } parsedStartDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to parse start_date %s: %v", startDateStr, err)), nil, nil + } - configInput := githubv4.ProjectV2IterationFieldConfigurationInput{ - Duration: githubv4.Int(duration), + configInput := ProjectV2IterationFieldConfigurationInput{ + + Duration: githubv4.Int(duration), + StartDate: githubv4.Date{Time: parsedStartDate}, + Iterations: &iterationsInput, } - updateInput := githubv4.UpdateProjectV2FieldInput{ + updateInput := UpdateProjectV2FieldInput{ + ProjectID: githubv4.ID(projectId), - FieldID: githubv4.ID(fieldId), + + FieldID: githubv4.ID(fieldId), + IterationConfiguration: &configInput, } err = gqlClient.Mutate(ctx, &updateMutation, updateInput, nil) + if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to update iteration configuration: %v", err)), nil, nil } @@ -2443,6 +2480,27 @@ func resolvePullRequestNodeID(ctx context.Context, gqlClient *githubv4.Client, o return query.Repository.PullRequest.ID, nil } +// local GraphQL types for ProjectV2 Iterations, as they are missing in shurcooL/githubv4 +const ProjectV2CustomFieldTypeIteration githubv4.ProjectV2CustomFieldType = "ITERATION" + +type ProjectV2IterationFieldIterationInput struct { + Title githubv4.String `json:"title"` + StartDate githubv4.Date `json:"startDate"` + Duration githubv4.Int `json:"duration"` +} + +type ProjectV2IterationFieldConfigurationInput struct { + Duration githubv4.Int `json:"duration"` + StartDate githubv4.Date `json:"startDate"` + Iterations *[]ProjectV2IterationFieldIterationInput `json:"iterations,omitempty"` +} + +type UpdateProjectV2FieldInput struct { + ProjectID githubv4.ID `json:"projectId"` + FieldID githubv4.ID `json:"fieldId"` + IterationConfiguration *ProjectV2IterationFieldConfigurationInput `json:"iterationConfiguration,omitempty"` +} + // detectOwnerType attempts to detect the owner type by trying both user and org // Returns the detected type ("user" or "org") and any error encountered func detectOwnerType(ctx context.Context, client *github.Client, owner string, projectNumber int) (string, error) { From cff67b04660a0b23ba231d5749c743236c05d40c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Doria=20de=20Souza?= Date: Tue, 27 Jan 2026 14:13:43 +0100 Subject: [PATCH 4/4] Merge remote origin changes and resolve project tool conflicts --- go.mod | 2 +- pkg/github/__toolsnaps__/projects_write.snap | 2 +- pkg/github/projects.go | 118 +++++++++---------- 3 files changed, 55 insertions(+), 67 deletions(-) diff --git a/go.mod b/go.mod index 7b466e210..10bbde9d1 100644 --- a/go.mod +++ b/go.mod @@ -50,4 +50,4 @@ require ( gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect -) \ No newline at end of file +) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index c9d42d585..d2d871bcd 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -11,7 +11,7 @@ "type": "number" }, "item_id": { - "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add.", + "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.", "type": "number" }, "item_owner": { diff --git a/pkg/github/projects.go b/pkg/github/projects.go index c4255a034..af884355f 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -1478,21 +1478,21 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list projects", - resp, - err, - ), - nil, nil + "failed to list projects", + resp, + err, + ), + nil, nil } default: projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list projects", - resp, - err, - ), - nil, nil + "failed to list projects", + resp, + err, + ), + nil, nil } } @@ -1600,11 +1600,11 @@ func listProjectFields(ctx context.Context, client *github.Client, args map[stri if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list project fields", - resp, - err, - ), - nil, nil + "failed to list project fields", + resp, + err, + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1666,11 +1666,11 @@ func listProjectItems(ctx context.Context, client *github.Client, args map[strin if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectListFailedError, - resp, - err, - ), - nil, nil + ProjectListFailedError, + resp, + err, + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1699,11 +1699,11 @@ func getProject(ctx context.Context, client *github.Client, owner, ownerType str } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project", - resp, - err, - ), - nil, nil + "failed to get project", + resp, + err, + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1737,11 +1737,11 @@ func getProjectField(ctx context.Context, client *github.Client, owner, ownerTyp if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project field", - resp, - err, - ), - nil, nil + "failed to get project field", + resp, + err, + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1780,11 +1780,11 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project item", - resp, - err, - ), - nil, nil + "failed to get project item", + resp, + err, + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1821,11 +1821,11 @@ func updateProjectItem(ctx context.Context, client *github.Client, owner, ownerT if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectUpdateFailedError, - resp, - err, - ), - nil, nil + ProjectUpdateFailedError, + resp, + err, + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1856,11 +1856,11 @@ func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerT if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectDeleteFailedError, - resp, - err, - ), - nil, nil + ProjectDeleteFailedError, + resp, + err, + ), + nil, nil } defer func() { _ = resp.Body.Close() }() @@ -2021,7 +2021,7 @@ func buildUpdateProjectItem(input map[string]any) (*github.UpdateProjectItemOpti } payload := &github.UpdateProjectItemOptions{ - Fields: []*github.UpdateProjectV2Field{{ + Fields: []*github.UpdateProjectV2Field{{ ID: fieldID, Value: valueField, }}, @@ -2494,38 +2494,26 @@ func getProjectNodeID(ctx context.Context, client *github.Client, owner, ownerTy } - - type UpdateProjectV2FieldInput struct { + ProjectID githubv4.ID `json:"projectId"` - ProjectID githubv4.ID `json:"projectId"` - - FieldID githubv4.ID `json:"fieldId"` + FieldID githubv4.ID `json:"fieldId"` IterationConfiguration *ProjectV2IterationFieldConfigurationInput `json:"iterationConfiguration,omitempty"` - } - - type ProjectV2IterationFieldConfigurationInput struct { + Duration githubv4.Int `json:"duration"` - Duration githubv4.Int `json:"duration"` - - StartDate githubv4.Date `json:"startDate"` + StartDate githubv4.Date `json:"startDate"` Iterations *[]ProjectV2IterationFieldIterationInput `json:"iterations"` - } - - type ProjectV2IterationFieldIterationInput struct { + StartDate githubv4.Date `json:"startDate"` - StartDate githubv4.Date `json:"startDate"` - - Duration githubv4.Int `json:"duration"` - - Title githubv4.String `json:"title"` + Duration githubv4.Int `json:"duration"` + Title githubv4.String `json:"title"` }