Skip to content

Commit 9e4ef8f

Browse files
committed
feat: align projects architecture and add iteration field support
- Align project tools with the mcp_holdback_consolidated_projects pattern - Add create_project and create_iteration_field tools - Add comprehensive unit tests for new Project V2 tools - Update documentation
1 parent 13bada0 commit 9e4ef8f

File tree

3 files changed

+257
-17
lines changed

3 files changed

+257
-17
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,23 @@ The following sets of tools are available:
976976

977977
<summary><picture><source media="(prefers-color-scheme: dark)" srcset="pkg/octicons/icons/project-dark.png"><source media="(prefers-color-scheme: light)" srcset="pkg/octicons/icons/project-light.png"><img src="pkg/octicons/icons/project-light.png" width="20" height="20" alt="project"></picture> Projects</summary>
978978

979+
- **create_iteration_field** - Create iteration field
980+
- **Required OAuth Scopes**: `project`
981+
- `duration`: Duration in days for each iteration (typically 7 for weekly) (number, required)
982+
- `field_name`: Field name (e.g., 'Sprint', 'Iteration') (string, required)
983+
- `iterations`: Array of iteration definitions (object[], required)
984+
- `owner`: GitHub username or organization name (string, required)
985+
- `owner_type`: Owner type (string, required)
986+
- `project_number`: The project's number (number, required)
987+
- `start_date`: Start date in YYYY-MM-DD format (string, required)
988+
989+
- **create_project** - Create project
990+
- **Required OAuth Scopes**: `project`
991+
- `description`: Project description (optional) (string, optional)
992+
- `owner`: GitHub username or organization name (string, required)
993+
- `owner_type`: Owner type (string, required)
994+
- `title`: Project title (string, required)
995+
979996
- **projects_get** - Get details of GitHub Projects resources
980997
- **Required OAuth Scopes**: `read:project`
981998
- **Accepted OAuth Scopes**: `project`, `read:project`

pkg/github/projects.go

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ const (
2828
MaxProjectsPerPage = 50
2929
)
3030

31-
// FeatureFlagConsolidatedProjects is the feature flag that disables individual project tools
32-
// in favor of the consolidated project tools.
33-
const FeatureFlagConsolidatedProjects = "remote_mcp_consolidated_projects"
31+
// FeatureFlagHoldbackConsolidatedProjects is the feature flag that, when enabled, reverts to
32+
// individual project tools instead of the consolidated project tools.
33+
const FeatureFlagHoldbackConsolidatedProjects = "mcp_holdback_consolidated_projects"
3434

3535
// Method constants for consolidated project tools
3636
const (
@@ -161,7 +161,7 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool {
161161
return utils.NewToolResultText(string(r)), nil, nil
162162
},
163163
)
164-
tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
164+
tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
165165
return tool
166166
}
167167

@@ -252,7 +252,7 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool {
252252
return utils.NewToolResultText(string(r)), nil, nil
253253
},
254254
)
255-
tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
255+
tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
256256
return tool
257257
}
258258

@@ -361,7 +361,7 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo
361361
return utils.NewToolResultText(string(r)), nil, nil
362362
},
363363
)
364-
tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
364+
tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
365365
return tool
366366
}
367367

@@ -456,7 +456,7 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool
456456
return utils.NewToolResultText(string(r)), nil, nil
457457
},
458458
)
459-
tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
459+
tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
460460
return tool
461461
}
462462

@@ -595,7 +595,7 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool
595595
return utils.NewToolResultText(string(r)), nil, nil
596596
},
597597
)
598-
tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
598+
tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
599599
return tool
600600
}
601601

@@ -704,7 +704,7 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
704704
return utils.NewToolResultText(string(r)), nil, nil
705705
},
706706
)
707-
tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
707+
tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
708708
return tool
709709
}
710710

@@ -818,7 +818,7 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool {
818818
return utils.NewToolResultText(string(r)), nil, nil
819819
},
820820
)
821-
tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
821+
tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
822822
return tool
823823
}
824824

@@ -933,7 +933,7 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo
933933
return utils.NewToolResultText(string(r)), nil, nil
934934
},
935935
)
936-
tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
936+
tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
937937
return tool
938938
}
939939

@@ -1022,7 +1022,7 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo
10221022
return utils.NewToolResultText("project item successfully deleted"), nil, nil
10231023
},
10241024
)
1025-
tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects
1025+
tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects
10261026
return tool
10271027
}
10281028

@@ -1148,7 +1148,7 @@ Use this tool to list projects for a user or organization, or list project field
11481148
}
11491149
},
11501150
)
1151-
tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects
1151+
tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects
11521152
return tool
11531153
}
11541154

@@ -1268,7 +1268,7 @@ Use this tool to get details about individual projects, project fields, and proj
12681268
}
12691269
},
12701270
)
1271-
tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects
1271+
tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects
12721272
return tool
12731273
}
12741274

@@ -1439,7 +1439,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
14391439
}
14401440
},
14411441
)
1442-
tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects
1442+
tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects
14431443
return tool
14441444
}
14451445

@@ -2231,7 +2231,7 @@ func CreateProject(t translations.TranslationHelperFunc) inventory.ServerTool {
22312231
return MarshalledTextResult(mutation.CreateProjectV2.ProjectV2), nil, nil
22322232
},
22332233
)
2234-
tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects
2234+
tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects
22352235
return tool
22362236
}
22372237

@@ -2434,7 +2434,7 @@ func CreateIterationField(t translations.TranslationHelperFunc) inventory.Server
24342434
return MarshalledTextResult(updateMutation.UpdateProjectV2Field.ProjectV2Field.ProjectV2IterationField), nil, nil
24352435
},
24362436
)
2437-
tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects
2437+
tool.FeatureFlagDisable = FeatureFlagHoldbackConsolidatedProjects
24382438
return tool
24392439
}
24402440

pkg/github/projects_v2_test.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"testing"
8+
"time"
9+
10+
"github.com/github/github-mcp-server/internal/githubv4mock"
11+
"github.com/github/github-mcp-server/pkg/translations"
12+
gh "github.com/google/go-github/v79/github"
13+
"github.com/shurcooL/githubv4"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func Test_CreateProject(t *testing.T) {
19+
toolDef := CreateProject(translations.NullTranslationHelper)
20+
21+
t.Run("success user project", func(t *testing.T) {
22+
mockedClient := githubv4mock.NewMockedHTTPClient(
23+
// Mock getOwnerNodeID for user
24+
githubv4mock.NewQueryMatcher(
25+
struct {
26+
User struct {
27+
ID string
28+
} `graphql:"user(login: $login)"`
29+
}{},
30+
map[string]any{
31+
"login": githubv4.String("octocat"),
32+
},
33+
githubv4mock.DataResponse(map[string]any{
34+
"user": map[string]any{
35+
"id": "U_octocat",
36+
},
37+
}),
38+
),
39+
// Mock createProjectV2 mutation
40+
githubv4mock.NewMutationMatcher(
41+
struct {
42+
CreateProjectV2 struct {
43+
ProjectV2 struct {
44+
ID string
45+
Number int
46+
Title string
47+
URL string
48+
}
49+
} `graphql:"createProjectV2(input: $input)"`
50+
}{},
51+
githubv4.CreateProjectV2Input{
52+
OwnerID: githubv4.ID("U_octocat"),
53+
Title: githubv4.String("New Project"),
54+
},
55+
nil,
56+
githubv4mock.DataResponse(map[string]any{
57+
"createProjectV2": map[string]any{
58+
"projectV2": map[string]any{
59+
"id": "PVT_project123",
60+
"number": 1,
61+
"title": "New Project",
62+
"url": "https://github.com/users/octocat/projects/1",
63+
},
64+
},
65+
}),
66+
),
67+
)
68+
69+
client := githubv4.NewClient(mockedClient)
70+
deps := BaseDeps{
71+
GQLClient: client,
72+
}
73+
handler := toolDef.Handler(deps)
74+
request := createMCPRequest(map[string]any{
75+
"owner": "octocat",
76+
"owner_type": "user",
77+
"title": "New Project",
78+
})
79+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
80+
81+
require.NoError(t, err)
82+
require.False(t, result.IsError)
83+
84+
textContent := getTextResult(t, result)
85+
var response map[string]any
86+
err = json.Unmarshal([]byte(textContent.Text), &response)
87+
require.NoError(t, err)
88+
assert.Equal(t, "PVT_project123", response["ID"])
89+
assert.Equal(t, float64(1), response["Number"])
90+
})
91+
}
92+
93+
func Test_CreateIterationField(t *testing.T) {
94+
toolDef := CreateIterationField(translations.NullTranslationHelper)
95+
96+
t.Run("success", func(t *testing.T) {
97+
// REST client for getProjectNodeID
98+
mockRESTClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
99+
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, map[string]any{
100+
"id": 1,
101+
"node_id": "PVT_project1",
102+
"title": "Org Project",
103+
}),
104+
})
105+
106+
// GraphQL client for mutations
107+
mockGQLClient := githubv4mock.NewMockedHTTPClient(
108+
// Step 1: Create Field
109+
githubv4mock.NewMutationMatcher(
110+
struct {
111+
CreateProjectV2Field struct {
112+
ProjectV2Field struct {
113+
ProjectV2IterationField struct {
114+
ID string
115+
Name string
116+
} `graphql:"... on ProjectV2IterationField"`
117+
}
118+
} `graphql:"createProjectV2Field(input: $input)"`
119+
}{},
120+
githubv4.CreateProjectV2FieldInput{
121+
ProjectID: githubv4.ID("PVT_project1"),
122+
DataType: githubv4.ProjectV2CustomFieldType("ITERATION"),
123+
Name: githubv4.String("Sprint"),
124+
},
125+
nil,
126+
githubv4mock.DataResponse(map[string]any{
127+
"createProjectV2Field": map[string]any{
128+
"projectV2Field": map[string]any{
129+
"id": "PVTIF_field1",
130+
"name": "Sprint",
131+
},
132+
},
133+
}),
134+
),
135+
// Step 2: Update Field Configuration
136+
githubv4mock.NewMutationMatcher(
137+
struct {
138+
UpdateProjectV2Field struct {
139+
ProjectV2Field struct {
140+
ProjectV2IterationField struct {
141+
ID string
142+
Name string
143+
Configuration struct {
144+
Iterations []struct {
145+
ID string
146+
Title string
147+
StartDate string
148+
Duration int
149+
}
150+
}
151+
} `graphql:"... on ProjectV2IterationField"`
152+
}
153+
} `graphql:"updateProjectV2Field(input: $input)"`
154+
}{},
155+
UpdateProjectV2FieldInput{
156+
ProjectID: githubv4.ID("PVT_project1"),
157+
FieldID: githubv4.ID("PVTIF_field1"),
158+
IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{
159+
Duration: githubv4.Int(7),
160+
StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)},
161+
Iterations: &[]ProjectV2IterationFieldIterationInput{
162+
{
163+
Title: githubv4.String("Sprint 1"),
164+
StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)},
165+
Duration: githubv4.Int(7),
166+
},
167+
},
168+
},
169+
},
170+
nil,
171+
githubv4mock.DataResponse(map[string]any{
172+
"updateProjectV2Field": map[string]any{
173+
"projectV2Field": map[string]any{
174+
"id": "PVTIF_field1",
175+
"name": "Sprint",
176+
"configuration": map[string]any{
177+
"iterations": []any{
178+
map[string]any{
179+
"id": "PVTI_iter1",
180+
"title": "Sprint 1",
181+
"startDate": "2025-01-20",
182+
"duration": 7,
183+
},
184+
},
185+
},
186+
},
187+
},
188+
}),
189+
),
190+
)
191+
192+
deps := BaseDeps{
193+
Client: gh.NewClient(mockRESTClient),
194+
GQLClient: githubv4.NewClient(mockGQLClient),
195+
}
196+
handler := toolDef.Handler(deps)
197+
request := createMCPRequest(map[string]any{
198+
"owner": "octo-org",
199+
"owner_type": "org",
200+
"project_number": float64(1),
201+
"field_name": "Sprint",
202+
"duration": float64(7),
203+
"start_date": "2025-01-20",
204+
"iterations": []any{
205+
map[string]any{
206+
"title": "Sprint 1",
207+
"startDate": "2025-01-20",
208+
"duration": float64(7),
209+
},
210+
},
211+
})
212+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
213+
214+
require.NoError(t, err)
215+
require.False(t, result.IsError)
216+
217+
textContent := getTextResult(t, result)
218+
var response map[string]any
219+
err = json.Unmarshal([]byte(textContent.Text), &response)
220+
require.NoError(t, err)
221+
assert.Equal(t, "PVTIF_field1", response["ID"])
222+
})
223+
}

0 commit comments

Comments
 (0)