Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit252c38a

Browse files
authored
Add tool to list projects
1 parent2d3db3a commit252c38a

File tree

4 files changed

+369
-0
lines changed

4 files changed

+369
-0
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"annotations": {
3+
"title":"List projects",
4+
"readOnlyHint":true
5+
},
6+
"description":"List Projects for a user or organization",
7+
"inputSchema": {
8+
"properties": {
9+
"after": {
10+
"description":"Cursor for items after (forward pagination)",
11+
"type":"string"
12+
},
13+
"before": {
14+
"description":"Cursor for items before (backwards pagination)",
15+
"type":"string"
16+
},
17+
"owner": {
18+
"description":"Owner",
19+
"type":"string"
20+
},
21+
"owner_type": {
22+
"description":"Owner type",
23+
"enum": [
24+
"user",
25+
"organization"
26+
],
27+
"type":"string"
28+
},
29+
"per_page": {
30+
"description":"Number of results per page (max 100, default: 30)",
31+
"type":"number"
32+
},
33+
"query": {
34+
"description":"Filter projects by a search query (matches title and description)",
35+
"type":"string"
36+
}
37+
},
38+
"required": [
39+
"owner",
40+
"owner_type"
41+
],
42+
"type":"object"
43+
},
44+
"name":"list_projects"
45+
}

‎pkg/github/projects.go‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"reflect"
11+
12+
ghErrors"github.com/github/github-mcp-server/pkg/errors"
13+
"github.com/github/github-mcp-server/pkg/translations"
14+
"github.com/google/go-github/v74/github"
15+
"github.com/google/go-querystring/query"
16+
"github.com/mark3labs/mcp-go/mcp"
17+
"github.com/mark3labs/mcp-go/server"
18+
)
19+
20+
funcListProjects(getClientGetClientFn,t translations.TranslationHelperFunc) (tool mcp.Tool,handler server.ToolHandlerFunc) {
21+
returnmcp.NewTool("list_projects",
22+
mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION","List Projects for a user or organization")),
23+
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title:t("TOOL_LIST_PROJECTS_USER_TITLE","List projects"),ReadOnlyHint:ToBoolPtr(true)}),
24+
mcp.WithString("owner_type",mcp.Required(),mcp.Description("Owner type"),mcp.Enum("user","organization")),
25+
mcp.WithString("owner",mcp.Required(),mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == organization it is the name of the organization. The name is not case sensitive.")),
26+
mcp.WithString("query",mcp.Description("Filter projects by a search query (matches title and description)")),
27+
mcp.WithString("before",mcp.Description("Cursor for items before (backwards pagination)")),
28+
mcp.WithString("after",mcp.Description("Cursor for items after (forward pagination)")),
29+
mcp.WithNumber("per_page",mcp.Description("Number of results per page (max 100, default: 30)")),
30+
),func(ctx context.Context,req mcp.CallToolRequest) (*mcp.CallToolResult,error) {
31+
owner,err:=RequiredParam[string](req,"owner")
32+
iferr!=nil {
33+
returnmcp.NewToolResultError(err.Error()),nil
34+
}
35+
ownerType,err:=RequiredParam[string](req,"owner_type")
36+
iferr!=nil {
37+
returnmcp.NewToolResultError(err.Error()),nil
38+
}
39+
queryStr,err:=OptionalParam[string](req,"query")
40+
iferr!=nil {
41+
returnmcp.NewToolResultError(err.Error()),nil
42+
}
43+
44+
beforeCursor,err:=OptionalParam[string](req,"before")
45+
iferr!=nil {
46+
returnmcp.NewToolResultError(err.Error()),nil
47+
}
48+
afterCursor,err:=OptionalParam[string](req,"after")
49+
iferr!=nil {
50+
returnmcp.NewToolResultError(err.Error()),nil
51+
}
52+
perPage,err:=OptionalIntParamWithDefault(req,"per_page",30)
53+
iferr!=nil {
54+
returnmcp.NewToolResultError(err.Error()),nil
55+
}
56+
57+
client,err:=getClient(ctx)
58+
iferr!=nil {
59+
returnmcp.NewToolResultError(err.Error()),nil
60+
}
61+
62+
varurlstring=""
63+
ifownerType=="organization" {
64+
url=fmt.Sprintf("/orgs/%s/projectsV2",owner)
65+
}else {
66+
url=fmt.Sprintf("/users/%s/projectsV2",owner)
67+
}
68+
projects:= []github.ProjectV2{}
69+
70+
opts:=ListProjectsOptions{PerPage:perPage}
71+
ifafterCursor!="" {
72+
opts.After=afterCursor
73+
}
74+
ifbeforeCursor!="" {
75+
opts.Before=beforeCursor
76+
}
77+
ifqueryStr!="" {
78+
opts.Query=queryStr
79+
}
80+
url,err=addOptions(url,opts)
81+
iferr!=nil {
82+
returnnil,fmt.Errorf("failed to add options to request: %w",err)
83+
}
84+
85+
httpRequest,err:=client.NewRequest("GET",url,nil)
86+
iferr!=nil {
87+
returnnil,fmt.Errorf("failed to create request: %w",err)
88+
}
89+
90+
resp,err:=client.Do(ctx,httpRequest,&projects)
91+
iferr!=nil {
92+
returnghErrors.NewGitHubAPIErrorResponse(ctx,
93+
"failed to list projects",
94+
resp,
95+
err,
96+
),nil
97+
}
98+
deferfunc() {_=resp.Body.Close() }()
99+
100+
ifresp.StatusCode!=http.StatusOK {
101+
body,err:=io.ReadAll(resp.Body)
102+
iferr!=nil {
103+
returnnil,fmt.Errorf("failed to read response body: %w",err)
104+
}
105+
returnmcp.NewToolResultError(fmt.Sprintf("failed to list projects: %s",string(body))),nil
106+
}
107+
r,err:=json.Marshal(projects)
108+
iferr!=nil {
109+
returnnil,fmt.Errorf("failed to marshal response: %w",err)
110+
}
111+
112+
returnmcp.NewToolResultText(string(r)),nil
113+
}
114+
}
115+
116+
typeListProjectsOptionsstruct {
117+
// A cursor, as given in the Link header. If specified, the query only searches for events after this cursor.
118+
Afterstring`url:"after,omitempty"`
119+
120+
// A cursor, as given in the Link header. If specified, the query only searches for events before this cursor.
121+
Beforestring`url:"before,omitempty"`
122+
123+
// For paginated result sets, the number of results to include per page.
124+
PerPageint`url:"per_page,omitempty"`
125+
126+
// Q is an optional query string to filter/search projects (when supported).
127+
Querystring`url:"q,omitempty"`
128+
}
129+
130+
// addOptions adds the parameters in opts as URL query parameters to s. opts
131+
// must be a struct whose fields may contain "url" tags.
132+
funcaddOptions(sstring,optsany) (string,error) {
133+
v:=reflect.ValueOf(opts)
134+
ifv.Kind()==reflect.Ptr&&v.IsNil() {
135+
returns,nil
136+
}
137+
138+
u,err:=url.Parse(s)
139+
iferr!=nil {
140+
returns,err
141+
}
142+
143+
qs,err:=query.Values(opts)
144+
iferr!=nil {
145+
returns,err
146+
}
147+
148+
u.RawQuery=qs.Encode()
149+
returnu.String(),nil
150+
}

‎pkg/github/projects_test.go‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/github/github-mcp-server/internal/toolsnaps"
10+
"github.com/github/github-mcp-server/pkg/translations"
11+
gh"github.com/google/go-github/v74/github"
12+
"github.com/migueleliasweb/go-github-mock/src/mock"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
funcTest_ListProjects(t*testing.T) {
18+
// Verify tool definition and schema once
19+
mockClient:=gh.NewClient(nil)
20+
tool,_:=ListProjects(stubGetClientFn(mockClient),translations.NullTranslationHelper)
21+
require.NoError(t,toolsnaps.Test(tool.Name,tool))
22+
23+
assert.Equal(t,"list_projects",tool.Name)
24+
assert.NotEmpty(t,tool.Description)
25+
assert.Contains(t,tool.InputSchema.Properties,"owner")
26+
assert.Contains(t,tool.InputSchema.Properties,"owner_type")
27+
assert.Contains(t,tool.InputSchema.Properties,"query")
28+
assert.Contains(t,tool.InputSchema.Properties,"before")
29+
assert.Contains(t,tool.InputSchema.Properties,"after")
30+
assert.Contains(t,tool.InputSchema.Properties,"per_page")
31+
assert.ElementsMatch(t,tool.InputSchema.Required, []string{"owner","owner_type"})
32+
33+
// Minimal project objects (fields chosen to likely exist on ProjectV2; test only asserts round-trip JSON array length)
34+
orgProjects:= []map[string]any{{"id":1,"title":"Org Project"}}
35+
userProjects:= []map[string]any{{"id":2,"title":"User Project"}}
36+
37+
tests:= []struct {
38+
namestring
39+
mockedClient*http.Client
40+
requestArgsmap[string]interface{}
41+
expectErrorbool
42+
expectedLengthint
43+
expectedErrMsgstring
44+
}{
45+
{
46+
name:"success organization",
47+
mockedClient:mock.NewMockedHTTPClient(
48+
mock.WithRequestMatchHandler(
49+
mock.EndpointPattern{Pattern:"/orgs/{org}/projectsV2",Method:http.MethodGet},
50+
mockResponse(t,http.StatusOK,orgProjects),
51+
),
52+
),
53+
requestArgs:map[string]interface{}{
54+
"owner":"octo-org",
55+
"owner_type":"organization",
56+
},
57+
expectError:false,
58+
expectedLength:1,
59+
},
60+
{
61+
name:"success user",
62+
mockedClient:mock.NewMockedHTTPClient(
63+
mock.WithRequestMatchHandler(
64+
mock.EndpointPattern{Pattern:"/users/{username}/projectsV2",Method:http.MethodGet},
65+
mockResponse(t,http.StatusOK,userProjects),
66+
),
67+
),
68+
requestArgs:map[string]interface{}{
69+
"owner":"octocat",
70+
"owner_type":"user",
71+
},
72+
expectError:false,
73+
expectedLength:1,
74+
},
75+
{
76+
name:"success organization with pagination & query",
77+
mockedClient:mock.NewMockedHTTPClient(
78+
mock.WithRequestMatchHandler(
79+
mock.EndpointPattern{Pattern:"/orgs/{org}/projectsV2",Method:http.MethodGet},
80+
http.HandlerFunc(func(w http.ResponseWriter,r*http.Request) {
81+
q:=r.URL.Query()
82+
// Assert query params present
83+
ifq.Get("after")=="cursor123"&&q.Get("per_page")=="50"&&q.Get("q")=="roadmap" {
84+
w.WriteHeader(http.StatusOK)
85+
_,_=w.Write(mock.MustMarshal(orgProjects))
86+
return
87+
}
88+
w.WriteHeader(http.StatusBadRequest)
89+
_,_=w.Write([]byte(`{"message":"unexpected query params"}`))
90+
}),
91+
),
92+
),
93+
requestArgs:map[string]interface{}{
94+
"owner":"octo-org",
95+
"owner_type":"organization",
96+
"after":"cursor123",
97+
"per_page":float64(50),
98+
"query":"roadmap",
99+
},
100+
expectError:false,
101+
expectedLength:1,
102+
},
103+
{
104+
name:"api error",
105+
mockedClient:mock.NewMockedHTTPClient(
106+
mock.WithRequestMatchHandler(
107+
mock.EndpointPattern{Pattern:"/orgs/{org}/projectsV2",Method:http.MethodGet},
108+
mockResponse(t,http.StatusInternalServerError,map[string]string{"message":"boom"}),
109+
),
110+
),
111+
requestArgs:map[string]interface{}{
112+
"owner":"octo-org",
113+
"owner_type":"organization",
114+
},
115+
expectError:true,
116+
expectedErrMsg:"failed to list projects",
117+
},
118+
{
119+
name:"missing owner",
120+
mockedClient:mock.NewMockedHTTPClient(),
121+
requestArgs:map[string]interface{}{
122+
"owner_type":"organization",
123+
},
124+
expectError:true,
125+
},
126+
{
127+
name:"missing owner_type",
128+
mockedClient:mock.NewMockedHTTPClient(),
129+
requestArgs:map[string]interface{}{
130+
"owner":"octo-org",
131+
},
132+
expectError:true,
133+
},
134+
}
135+
136+
for_,tc:=rangetests {
137+
t.Run(tc.name,func(t*testing.T) {
138+
client:=gh.NewClient(tc.mockedClient)
139+
_,handler:=ListProjects(stubGetClientFn(client),translations.NullTranslationHelper)
140+
request:=createMCPRequest(tc.requestArgs)
141+
result,err:=handler(context.Background(),request)
142+
143+
require.NoError(t,err)
144+
iftc.expectError {
145+
require.True(t,result.IsError)
146+
text:=getTextResult(t,result).Text
147+
iftc.expectedErrMsg!="" {
148+
assert.Contains(t,text,tc.expectedErrMsg)
149+
}
150+
// Parameter missing cases
151+
iftc.name=="missing owner" {
152+
assert.Contains(t,text,"missing required parameter: owner")
153+
}
154+
iftc.name=="missing owner_type" {
155+
assert.Contains(t,text,"missing required parameter: owner_type")
156+
}
157+
return
158+
}
159+
160+
require.False(t,result.IsError)
161+
textContent:=getTextResult(t,result)
162+
vararr []map[string]any
163+
err=json.Unmarshal([]byte(textContent.Text),&arr)
164+
require.NoError(t,err)
165+
assert.Equal(t,tc.expectedLength,len(arr))
166+
})
167+
}
168+
}

‎pkg/github/tools.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,11 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
190190
toolsets.NewServerTool(UpdateGist(getClient,t)),
191191
)
192192

193+
projects:=toolsets.NewToolset("projects","GitHub Projects related tools").
194+
AddReadTools(
195+
toolsets.NewServerTool(ListProjects(getClient,t)),
196+
)
197+
193198
// Add toolsets to the group
194199
tsg.AddToolset(contextTools)
195200
tsg.AddToolset(repos)
@@ -206,6 +211,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
206211
tsg.AddToolset(discussions)
207212
tsg.AddToolset(gists)
208213
tsg.AddToolset(securityAdvisories)
214+
tsg.AddToolset(projects)
209215

210216
returntsg
211217
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp