@@ -3,6 +3,7 @@ package github
33import (
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.
74392func GetProjectStatuses (getClient GetGQLClientFn , t translations.TranslationHelperFunc ) (mcp.Tool , server.ToolHandlerFunc ) {
0 commit comments