Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -658,9 +658,12 @@ The following sets of tools are available (all are on by default):

<summary>Projects</summary>

- **get_project** - Get project
- `owner`: 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. (string, required)
- `owner_type`: Owner type (string, required)
- `project_number`: The project's number (number, required)

- **list_projects** - List projects
- `after`: Cursor for items after (forward pagination) (string, optional)
- `before`: Cursor for items before (backwards pagination) (string, optional)
- `owner`: 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. (string, required)
- `owner_type`: Owner type (string, required)
- `per_page`: Number of results per page (max 100, default: 30) (number, optional)
Expand Down
34 changes: 34 additions & 0 deletions pkg/github/__toolsnaps__/get_project.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"annotations": {
"title": "Get project",
"readOnlyHint": true
},
"description": "Get Project for a user or organization",
"inputSchema": {
"properties": {
"owner": {
"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.",
"type": "string"
},
"owner_type": {
"description": "Owner type",
"enum": [
"user",
"organization"
],
"type": "string"
},
"project_number": {
"description": "The project's number",
"type": "number"
}
},
"required": [
"project_number",
"owner_type",
"owner"
],
"type": "object"
},
"name": "get_project"
}
8 changes: 0 additions & 8 deletions pkg/github/__toolsnaps__/list_projects.snap
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,6 @@
"description": "List Projects for a user or organization",
"inputSchema": {
"properties": {
"after": {
"description": "Cursor for items after (forward pagination)",
"type": "string"
},
"before": {
"description": "Cursor for items before (backwards pagination)",
"type": "string"
},
"owner": {
"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.",
"type": "string"
Expand Down
95 changes: 71 additions & 24 deletions pkg/github/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "organization")),
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.")),
mcp.WithString("query", mcp.Description("Filter projects by a search query (matches title and description)")),
mcp.WithString("before", mcp.Description("Cursor for items before (backwards pagination)")),
mcp.WithString("after", mcp.Description("Cursor for items after (forward pagination)")),
mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](req, "owner")
Expand All @@ -40,15 +38,6 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

beforeCursor, err := OptionalParam[string](req, "before")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
afterCursor, err := OptionalParam[string](req, "after")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
perPage, err := OptionalIntParamWithDefault(req, "per_page", 30)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
Expand All @@ -61,19 +50,14 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (

var url string
if ownerType == "organization" {
url = fmt.Sprintf("/orgs/%s/projectsV2", owner)
url = fmt.Sprintf("orgs/%s/projectsV2", owner)
} else {
url = fmt.Sprintf("/users/%s/projectsV2", owner)
url = fmt.Sprintf("users/%s/projectsV2", owner)
}
projects := []github.ProjectV2{}

opts := ListProjectsOptions{PerPage: perPage}
if afterCursor != "" {
opts.After = afterCursor
}
if beforeCursor != "" {
opts.Before = beforeCursor
}

if queryStr != "" {
opts.Query = queryStr
}
Expand Down Expand Up @@ -113,13 +97,76 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
}
}

type ListProjectsOptions struct {
// A cursor, as given in the Link header. If specified, the query only searches for events before this cursor.
Before string `url:"before,omitempty"`
func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_project",
mcp.WithDescription(t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or organization")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), ReadOnlyHint: ToBoolPtr(true)}),
mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number")),
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "organization")),
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.")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {

// A cursor, as given in the Link header. If specified, the query only searches for events after this cursor.
After string `url:"after,omitempty"`
projectNumber, err := RequiredInt(req, "project_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

owner, err := RequiredParam[string](req, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

ownerType, err := RequiredParam[string](req, "owner_type")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

client, err := getClient(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

var url string
if ownerType == "organization" {
url = fmt.Sprintf("orgs/%s/projectsV2/%d", owner, projectNumber)
} else {
url = fmt.Sprintf("users/%s/projectsV2/%d", owner, projectNumber)
}

projects := []github.ProjectV2{}

httpRequest, err := client.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

resp, err := client.Do(ctx, httpRequest, &projects)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get project",
resp,
err,
), nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to get project: %s", string(body))), nil
}
r, err := json.Marshal(projects)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}

return mcp.NewToolResultText(string(r)), nil
}
}

type ListProjectsOptions struct {
// For paginated result sets, the number of results to include per page.
PerPage int `url:"per_page,omitempty"`

Expand Down
141 changes: 137 additions & 4 deletions pkg/github/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ func Test_ListProjects(t *testing.T) {
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "owner_type")
assert.Contains(t, tool.InputSchema.Properties, "query")
assert.Contains(t, tool.InputSchema.Properties, "before")
assert.Contains(t, tool.InputSchema.Properties, "after")
assert.Contains(t, tool.InputSchema.Properties, "per_page")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "owner_type"})

Expand Down Expand Up @@ -80,7 +78,7 @@ func Test_ListProjects(t *testing.T) {
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
// Assert query params present
if q.Get("after") == "cursor123" && q.Get("per_page") == "50" && q.Get("q") == "roadmap" {
if q.Get("per_page") == "50" && q.Get("q") == "roadmap" {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(mock.MustMarshal(orgProjects))
return
Expand All @@ -93,7 +91,6 @@ func Test_ListProjects(t *testing.T) {
requestArgs: map[string]interface{}{
"owner": "octo-org",
"owner_type": "organization",
"after": "cursor123",
"per_page": float64(50),
"query": "roadmap",
},
Expand Down Expand Up @@ -166,3 +163,139 @@ func Test_ListProjects(t *testing.T) {
})
}
}

func Test_GetProject(t *testing.T) {
mockClient := gh.NewClient(nil)
tool, _ := GetProject(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))

assert.Equal(t, "get_project", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "project_number")
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "owner_type")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"project_number", "owner", "owner_type"})

// Minimal project object for response array
project := []map[string]any{{"id": 123, "title": "Project Title"}}

tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedLength int
expectedErrMsg string
}{
{
name: "success organization project fetch",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/123", Method: http.MethodGet},
mockResponse(t, http.StatusOK, project),
),
),
requestArgs: map[string]interface{}{
"project_number": float64(123),
"owner": "octo-org",
"owner_type": "organization",
},
expectError: false,
expectedLength: 1,
},
{
name: "success user project fetch",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.EndpointPattern{Pattern: "/users/{username}/projectsV2/456", Method: http.MethodGet},
mockResponse(t, http.StatusOK, project),
),
),
requestArgs: map[string]interface{}{
"project_number": float64(456),
"owner": "octocat",
"owner_type": "user",
},
expectError: false,
expectedLength: 1,
},
{
name: "api error",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/999", Method: http.MethodGet},
mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
),
),
requestArgs: map[string]interface{}{
"project_number": float64(999),
"owner": "octo-org",
"owner_type": "organization",
},
expectError: true,
expectedErrMsg: "failed to get project", // updated to match implementation
},
{
name: "missing project_number",
mockedClient: mock.NewMockedHTTPClient(),
requestArgs: map[string]interface{}{
"owner": "octo-org",
"owner_type": "organization",
},
expectError: true,
},
{
name: "missing owner",
mockedClient: mock.NewMockedHTTPClient(),
requestArgs: map[string]interface{}{
"project_number": float64(123),
"owner_type": "organization",
},
expectError: true,
},
{
name: "missing owner_type",
mockedClient: mock.NewMockedHTTPClient(),
requestArgs: map[string]interface{}{
"project_number": float64(123),
"owner": "octo-org",
},
expectError: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := gh.NewClient(tc.mockedClient)
_, handler := GetProject(stubGetClientFn(client), translations.NullTranslationHelper)
request := createMCPRequest(tc.requestArgs)
result, err := handler(context.Background(), request)

require.NoError(t, err)
if tc.expectError {
require.True(t, result.IsError)
text := getTextResult(t, result).Text
if tc.expectedErrMsg != "" {
assert.Contains(t, text, tc.expectedErrMsg)
}
if tc.name == "missing project_number" {
assert.Contains(t, text, "missing required parameter: project_number")
}
if tc.name == "missing owner" {
assert.Contains(t, text, "missing required parameter: owner")
}
if tc.name == "missing owner_type" {
assert.Contains(t, text, "missing required parameter: owner_type")
}
return
}

require.False(t, result.IsError)
textContent := getTextResult(t, result)
var arr []map[string]any
err = json.Unmarshal([]byte(textContent.Text), &arr)
require.NoError(t, err)
assert.Equal(t, tc.expectedLength, len(arr))
})
}
}
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
projects := toolsets.NewToolset("projects", "GitHub Projects related tools").
AddReadTools(
toolsets.NewServerTool(ListProjects(getClient, t)),
toolsets.NewServerTool(GetProject(getClient, t)),
)

// Add toolsets to the group
Expand Down
Loading