Skip to content

Commit f133c95

Browse files
committed
add get project tool to get project by name or number
1 parent 781c3c4 commit f133c95

File tree

2 files changed

+319
-0
lines changed

2 files changed

+319
-0
lines changed

pkg/github/projects.go

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package github
33
import (
44
"context"
55
"fmt"
6+
"strings"
67

78
"github.com/github/github-mcp-server/pkg/translations"
89
"github.com/go-viper/mapstructure/v2"
@@ -69,6 +70,323 @@ func ListProjects(getClient GetGQLClientFn, t translations.TranslationHelperFunc
6970
}
7071
}
7172

73+
// GetProject defines a tool that retrieves detailed information about a specific GitHub ProjectV2.
74+
// It takes a project number or name and owner as input and works for both organizations and users.
75+
func GetProject(getClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
76+
return mcp.NewTool("get_project",
77+
mcp.WithDescription(t("TOOL_GET_PROJECT_DESCRIPTION", "Get details for a specific project using its number or name")),
78+
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_TITLE", "Get project details"), ReadOnlyHint: ToBoolPtr(true)}),
79+
mcp.WithString("owner", mcp.Required(), mcp.Description("Owner login (user or organization)")),
80+
mcp.WithNumber("number", mcp.Description("Project number (either number or name must be provided)")),
81+
mcp.WithString("name", mcp.Description("Project name (either number or name must be provided)")),
82+
mcp.WithString("owner_type", mcp.Description("Owner type"), mcp.Enum("user", "organization")),
83+
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
84+
owner, err := RequiredParam[string](req, "owner")
85+
if err != nil {
86+
return mcp.NewToolResultError(err.Error()), nil
87+
}
88+
89+
// Get optional parameters
90+
number, numberErr := OptionalParam[float64](req, "number")
91+
name, nameErr := OptionalParam[string](req, "name")
92+
93+
// Check if parameters were actually provided (not just no error)
94+
nameProvided := nameErr == nil && name != ""
95+
numberProvided := numberErr == nil && number != 0
96+
97+
// CORRECTED VALIDATION:
98+
// 1. Check if both were provided
99+
if nameProvided && numberProvided {
100+
return mcp.NewToolResultError("Cannot provide both 'number' and 'name' parameters. Please use only one."), nil
101+
}
102+
// 2. Check if neither was provided
103+
if !nameProvided && !numberProvided {
104+
return mcp.NewToolResultError("Either the 'number' or 'name' parameter must be provided."), nil
105+
}
106+
107+
ownerType, err := OptionalParam[string](req, "owner_type")
108+
if err != nil {
109+
return mcp.NewToolResultError(err.Error()), nil
110+
}
111+
if ownerType == "" {
112+
ownerType = "organization"
113+
}
114+
115+
client, err := getClient(ctx)
116+
if err != nil {
117+
return mcp.NewToolResultError(err.Error()), nil
118+
}
119+
120+
// Route to the correct helper function based on which parameter was provided
121+
if nameProvided {
122+
return getProjectByName(ctx, client, owner, name, ownerType)
123+
}
124+
125+
// If it wasn't name, it must be number
126+
projectNumber := int(number)
127+
return getProjectByNumber(ctx, client, owner, projectNumber, ownerType)
128+
}
129+
}
130+
131+
// Helper function to get project by number
132+
func getProjectByNumber(ctx context.Context, client interface{}, owner string, number int, ownerType string) (*mcp.CallToolResult, error) {
133+
type GraphQLClient interface {
134+
Query(ctx context.Context, q interface{}, variables map[string]interface{}) error
135+
}
136+
137+
gqlClient := client.(GraphQLClient)
138+
139+
if ownerType == "user" {
140+
var q struct {
141+
User struct {
142+
ProjectV2 struct {
143+
ID githubv4.ID
144+
Title githubv4.String
145+
Number githubv4.Int
146+
Readme githubv4.String
147+
URL githubv4.URI
148+
} `graphql:"projectV2(number: $projectNumber)"`
149+
} `graphql:"user(login: $owner)"`
150+
}
151+
152+
variables := map[string]any{
153+
"owner": githubv4.String(owner),
154+
"projectNumber": githubv4.Int(number),
155+
}
156+
157+
if err := gqlClient.Query(ctx, &q, variables); err != nil {
158+
return mcp.NewToolResultError(err.Error()), nil
159+
}
160+
161+
// Check if the project was found
162+
if q.User.ProjectV2.Title == "" {
163+
return mcp.NewToolResultError(fmt.Sprintf("Could not find project number %d for user '%s'.", number, owner)), nil
164+
}
165+
166+
return MarshalledTextResult(q.User.ProjectV2), nil
167+
} else {
168+
var q struct {
169+
Organization struct {
170+
ProjectV2 struct {
171+
ID githubv4.ID
172+
Title githubv4.String
173+
Number githubv4.Int
174+
Readme githubv4.String
175+
URL githubv4.URI
176+
} `graphql:"projectV2(number: $projectNumber)"`
177+
} `graphql:"organization(login: $owner)"`
178+
}
179+
180+
variables := map[string]any{
181+
"owner": githubv4.String(owner),
182+
"projectNumber": githubv4.Int(number),
183+
}
184+
185+
if err := gqlClient.Query(ctx, &q, variables); err != nil {
186+
return mcp.NewToolResultError(err.Error()), nil
187+
}
188+
189+
// Check if the project was found
190+
if q.Organization.ProjectV2.Title == "" {
191+
return mcp.NewToolResultError(fmt.Sprintf("Could not find project number %d for organization '%s'.", number, owner)), nil
192+
}
193+
194+
return MarshalledTextResult(q.Organization.ProjectV2), nil
195+
}
196+
}
197+
198+
// Helper function to get project by name with pagination support
199+
func getProjectByName(ctx context.Context, client interface{}, owner string, name string, ownerType string) (*mcp.CallToolResult, error) {
200+
type GraphQLClient interface {
201+
Query(ctx context.Context, q interface{}, variables map[string]interface{}) error
202+
}
203+
204+
gqlClient := client.(GraphQLClient)
205+
206+
if ownerType == "user" {
207+
var cursor *githubv4.String
208+
209+
for {
210+
var q struct {
211+
User struct {
212+
Projects struct {
213+
Nodes []struct {
214+
ID githubv4.ID
215+
Title githubv4.String
216+
Number githubv4.Int
217+
Readme githubv4.String
218+
URL githubv4.URI
219+
}
220+
PageInfo struct {
221+
HasNextPage bool
222+
EndCursor githubv4.String
223+
}
224+
} `graphql:"projectsV2(first: 100, after: $cursor)"`
225+
} `graphql:"user(login: $login)"`
226+
}
227+
228+
variables := map[string]any{
229+
"login": githubv4.String(owner),
230+
"cursor": cursor,
231+
}
232+
233+
if err := gqlClient.Query(ctx, &q, variables); err != nil {
234+
return mcp.NewToolResultError(err.Error()), nil
235+
}
236+
237+
// Search for project by name (case-insensitive exact match first)
238+
for _, project := range q.User.Projects.Nodes {
239+
if strings.EqualFold(string(project.Title), name) {
240+
return MarshalledTextResult(project), nil
241+
}
242+
}
243+
244+
// Check if we should continue to next page
245+
if !q.User.Projects.PageInfo.HasNextPage {
246+
break
247+
}
248+
cursor = &q.User.Projects.PageInfo.EndCursor
249+
}
250+
251+
// If exact match not found, do a second pass with partial matching
252+
cursor = nil
253+
for {
254+
var q struct {
255+
User struct {
256+
Projects struct {
257+
Nodes []struct {
258+
ID githubv4.ID
259+
Title githubv4.String
260+
Number githubv4.Int
261+
Readme githubv4.String
262+
URL githubv4.URI
263+
}
264+
PageInfo struct {
265+
HasNextPage bool
266+
EndCursor githubv4.String
267+
}
268+
} `graphql:"projectsV2(first: 100, after: $cursor)"`
269+
} `graphql:"user(login: $login)"`
270+
}
271+
272+
variables := map[string]any{
273+
"login": githubv4.String(owner),
274+
"cursor": cursor,
275+
}
276+
277+
if err := gqlClient.Query(ctx, &q, variables); err != nil {
278+
return mcp.NewToolResultError(err.Error()), nil
279+
}
280+
281+
// Search for project by partial name match
282+
for _, project := range q.User.Projects.Nodes {
283+
if strings.Contains(strings.ToLower(string(project.Title)), strings.ToLower(name)) {
284+
return MarshalledTextResult(project), nil
285+
}
286+
}
287+
288+
// Check if we should continue to next page
289+
if !q.User.Projects.PageInfo.HasNextPage {
290+
break
291+
}
292+
cursor = &q.User.Projects.PageInfo.EndCursor
293+
}
294+
295+
return mcp.NewToolResultError(fmt.Sprintf("Could not find project with name '%s' for user '%s'.", name, owner)), nil
296+
} else {
297+
var cursor *githubv4.String
298+
299+
// First pass: exact match
300+
for {
301+
var q struct {
302+
Organization struct {
303+
Projects struct {
304+
Nodes []struct {
305+
ID githubv4.ID
306+
Title githubv4.String
307+
Number githubv4.Int
308+
Readme githubv4.String
309+
URL githubv4.URI
310+
}
311+
PageInfo struct {
312+
HasNextPage bool
313+
EndCursor githubv4.String
314+
}
315+
} `graphql:"projectsV2(first: 100, after: $cursor)"`
316+
} `graphql:"organization(login: $login)"`
317+
}
318+
319+
variables := map[string]any{
320+
"login": githubv4.String(owner),
321+
"cursor": cursor,
322+
}
323+
324+
if err := gqlClient.Query(ctx, &q, variables); err != nil {
325+
return mcp.NewToolResultError(err.Error()), nil
326+
}
327+
328+
// Search for project by name (case-insensitive exact match first)
329+
for _, project := range q.Organization.Projects.Nodes {
330+
if strings.EqualFold(string(project.Title), name) {
331+
return MarshalledTextResult(project), nil
332+
}
333+
}
334+
335+
// Check if we should continue to next page
336+
if !q.Organization.Projects.PageInfo.HasNextPage {
337+
break
338+
}
339+
cursor = &q.Organization.Projects.PageInfo.EndCursor
340+
}
341+
342+
// Second pass: partial match
343+
cursor = nil
344+
for {
345+
var q struct {
346+
Organization struct {
347+
Projects struct {
348+
Nodes []struct {
349+
ID githubv4.ID
350+
Title githubv4.String
351+
Number githubv4.Int
352+
Readme githubv4.String
353+
URL githubv4.URI
354+
}
355+
PageInfo struct {
356+
HasNextPage bool
357+
EndCursor githubv4.String
358+
}
359+
} `graphql:"projectsV2(first: 100, after: $cursor)"`
360+
} `graphql:"organization(login: $login)"`
361+
}
362+
363+
variables := map[string]any{
364+
"login": githubv4.String(owner),
365+
"cursor": cursor,
366+
}
367+
368+
if err := gqlClient.Query(ctx, &q, variables); err != nil {
369+
return mcp.NewToolResultError(err.Error()), nil
370+
}
371+
372+
// Search for project by partial name match
373+
for _, project := range q.Organization.Projects.Nodes {
374+
if strings.Contains(strings.ToLower(string(project.Title)), strings.ToLower(name)) {
375+
return MarshalledTextResult(project), nil
376+
}
377+
}
378+
379+
// Check if we should continue to next page
380+
if !q.Organization.Projects.PageInfo.HasNextPage {
381+
break
382+
}
383+
cursor = &q.Organization.Projects.PageInfo.EndCursor
384+
}
385+
386+
return mcp.NewToolResultError(fmt.Sprintf("Could not find project with name '%s' for organization '%s'.", name, owner)), nil
387+
}
388+
}
389+
72390
// GetProjectStatuses retrieves the Status field options for a specific GitHub ProjectV2.
73391
// It returns the status options with their IDs, names, and descriptions.
74392
func GetProjectStatuses(getClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
106106
projects := toolsets.NewToolset("projects", "GitHub Projects V2 management tools").
107107
AddReadTools(
108108
toolsets.NewServerTool(ListProjects(getGQLClient, t)),
109+
toolsets.NewServerTool(GetProject(getGQLClient, t)),
109110
toolsets.NewServerTool(GetProjectFields(getGQLClient, t)),
110111
toolsets.NewServerTool(GetProjectStatuses(getGQLClient, t)),
111112
toolsets.NewServerTool(GetProjectItems(getGQLClient, t)),

0 commit comments

Comments
 (0)