Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -658,10 +658,19 @@ 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 == org 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_project_fields** - List project fields
- `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org 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)
- `projectNumber`: The project's number. (string, 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`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org 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)
- `query`: Filter projects by a search query (matches title and description) (string, 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 org",
"inputSchema": {
"properties": {
"owner": {
"description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
"type": "string"
},
"owner_type": {
"description": "Owner type",
"enum": [
"user",
"org"
],
"type": "string"
},
"project_number": {
"description": "The project's number",
"type": "number"
}
},
"required": [
"project_number",
"owner_type",
"owner"
],
"type": "object"
},
"name": "get_project"
}
38 changes: 38 additions & 0 deletions pkg/github/__toolsnaps__/list_project_fields.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"annotations": {
"title": "List project fields",
"readOnlyHint": true
},
"description": "List Project fields for a user or org",
"inputSchema": {
"properties": {
"owner": {
"description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
"type": "string"
},
"owner_type": {
"description": "Owner type",
"enum": [
"user",
"org"
],
"type": "string"
},
"per_page": {
"description": "Number of results per page (max 100, default: 30)",
"type": "number"
},
"projectNumber": {
"description": "The project's number.",
"type": "string"
}
},
"required": [
"owner_type",
"owner",
"projectNumber"
],
"type": "object"
},
"name": "list_project_fields"
}
14 changes: 3 additions & 11 deletions pkg/github/__toolsnaps__/list_projects.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,18 @@
"title": "List projects",
"readOnlyHint": true
},
"description": "List Projects for a user or organization",
"description": "List Projects for a user or org",
"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.",
"description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
"type": "string"
},
"owner_type": {
"description": "Owner type",
"enum": [
"user",
"organization"
"org"
],
"type": "string"
},
Expand Down
193 changes: 166 additions & 27 deletions pkg/github/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,11 @@ import (

func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_projects",
mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or organization")),
mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or org")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), ReadOnlyHint: ToBoolPtr(true)}),
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("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")),
mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org 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,16 +38,87 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
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
}
client, err := getClient(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

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

opts := listProjectsOptions{PerPage: perPage}

if queryStr != "" {
opts.Query = queryStr
}
if perPage > 0 {
opts.PerPage = perPage
}
url, err = addOptions(url, opts)
if err != nil {
return nil, fmt.Errorf("failed to add options to request: %w", err)
}

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 list projects",
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 list projects: %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
}
}

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 org")),
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", "org")),
mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {

projectNumber, err := RequiredInt(req, "project_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
afterCursor, err := OptionalParam[string](req, "after")

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

ownerType, err := RequiredParam[string](req, "owner_type")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
Expand All @@ -60,22 +129,87 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
}

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

opts := ListProjectsOptions{PerPage: perPage}
if afterCursor != "" {
opts.After = afterCursor
project := github.ProjectV2{}

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

resp, err := client.Do(ctx, httpRequest, &project)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get project",
resp,
err,
), nil
}
if queryStr != "" {
opts.Query = queryStr
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(project)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}

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

func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_project_fields",
mcp.WithDescription(t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), ReadOnlyHint: ToBoolPtr(true)}),
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")),
mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.")),
mcp.WithString("projectNumber", mcp.Required(), mcp.Description("The project's number.")),
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")
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
}
projectNumber, err := RequiredParam[string](req, "projectNumber")
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
}
client, err := getClient(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

var url string
if ownerType == "org" {
url = fmt.Sprintf("orgs/%s/projectsV2/%s/fields", owner, projectNumber)
} else {
url = fmt.Sprintf("users/%s/projectsV2/%s/fields", owner, projectNumber)
}
projectFields := []projectV2Field{}

opts := listProjectsOptions{PerPage: perPage}

if perPage > 0 {
opts.PerPage = perPage
}
url, err = addOptions(url, opts)
if err != nil {
Expand All @@ -87,7 +221,7 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
return nil, fmt.Errorf("failed to create request: %w", err)
}

resp, err := client.Do(ctx, httpRequest, &projects)
resp, err := client.Do(ctx, httpRequest, &projectFields)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to list projects",
Expand All @@ -104,7 +238,7 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
}
return mcp.NewToolResultError(fmt.Sprintf("failed to list projects: %s", string(body))), nil
}
r, err := json.Marshal(projects)
r, err := json.Marshal(projectFields)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
Expand All @@ -113,13 +247,18 @@ 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"`

// A cursor, as given in the Link header. If specified, the query only searches for events after this cursor.
After string `url:"after,omitempty"`
type projectV2Field struct {
ID *int64 `json:"id,omitempty"` // The unique identifier for this field.
NodeID string `json:"node_id,omitempty"` // The GraphQL node ID for this field.
Name string `json:"name,omitempty"` // The display name of the field.
DataType string `json:"dataType,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select").
URL string `json:"url,omitempty"` // The API URL for this field.
Options []*any `json:"options,omitempty"` // Available options for single_select and multi_select fields.
CreatedAt *github.Timestamp `json:"created_at,omitempty"` // The time when this field was created.
UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` // The time when this field was last updated.
}

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

Expand Down
Loading
Loading