diff --git a/README.md b/README.md index 14596650..cd71c16f 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,7 @@ The following sets of tools are available (all are on by default): | `issues` | Issue-related tools (create, read, update, comment) | | `notifications` | GitHub Notifications related tools | | `pull_requests` | Pull request operations (create, merge, review) | +| `projects` | Manage GitHub Projects V2 | | `repos` | Repository-related tools (file operations, branches, commits) | | `secret_protection` | Secret protection related tools, such as GitHub Secret Scanning | | `users` | Anything relating to GitHub Users | @@ -640,6 +641,47 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `pullNumber`: Pull request number (number, required) - _Note_: Currently, this tool will only work for github.com +### Projects + +- **list_projects** - List projects for a user or organization + - `owner`: Owner login (string, required) + - `owner_type`: Owner type ('user' or 'organization', optional) + +- **get_project_fields** - Get fields for a project + - `owner`: Owner login (string, required) + - `owner_type`: Owner type ('user' or 'organization', optional) + - `number`: Project number (number, required) + +- **get_project_items** - Get items for a project + - `owner`: Owner login (string, required) + - `owner_type`: Owner type ('user' or 'organization', optional) + - `number`: Project number (number, required) + +- **create_project_issue** - Create a new issue in a repository + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `title`: Issue title (string, required) + - `body`: Issue body (string, optional) + +- **add_issue_to_project** - Add an issue to a project + - `project_id`: Project node ID (string, required) + - `issue_id`: Issue node ID (string, required) + +- **update_project_item_field** - Update a project item field value + - `project_id`: Project node ID (string, required) + - `item_id`: Item node ID (string, required) + - `field_id`: Field node ID (string, required) + - `text_value`: New text value (string, optional) + +- **create_draft_issue** - Create a draft issue in a project + - `project_id`: Project node ID (string, required) + - `title`: Draft issue title (string, required) + - `body`: Draft issue body (string, optional) + +- **delete_project_item** - Delete an item from a project + - `project_id`: Project node ID (string, required) + - `item_id`: Item node ID (string, required) + ### Repositories - **create_or_update_file** - Create or update a single file in a repository diff --git a/docs/remote-server.md b/docs/remote-server.md index 888caef4..5753eb18 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -22,6 +22,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | | notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D)| | pull_requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D)| +| projects | Manage GitHub Projects V2 | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D)| | repos | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | | secret_protection | Secret protection related tools, e.g. Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D)| | users | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | diff --git a/pkg/github/__toolsnaps__/add_issue_to_project.snap b/pkg/github/__toolsnaps__/add_issue_to_project.snap new file mode 100644 index 00000000..6d0ae86c --- /dev/null +++ b/pkg/github/__toolsnaps__/add_issue_to_project.snap @@ -0,0 +1,25 @@ +{ + "annotations": { + "title": "Add issue to project", + "readOnlyHint": false + }, + "description": "Add an issue to a project", + "inputSchema": { + "properties": { + "issue_id": { + "description": "Issue node ID", + "type": "string" + }, + "project_id": { + "description": "Project ID", + "type": "string" + } + }, + "required": [ + "project_id", + "issue_id" + ], + "type": "object" + }, + "name": "add_issue_to_project" +} diff --git a/pkg/github/__toolsnaps__/create_draft_issue.snap b/pkg/github/__toolsnaps__/create_draft_issue.snap new file mode 100644 index 00000000..157d6a3e --- /dev/null +++ b/pkg/github/__toolsnaps__/create_draft_issue.snap @@ -0,0 +1,29 @@ +{ + "annotations": { + "title": "Create draft issue", + "readOnlyHint": false + }, + "description": "Create a draft issue in a project", + "inputSchema": { + "properties": { + "body": { + "description": "Issue body", + "type": "string" + }, + "project_id": { + "description": "Project ID", + "type": "string" + }, + "title": { + "description": "Issue title", + "type": "string" + } + }, + "required": [ + "project_id", + "title" + ], + "type": "object" + }, + "name": "create_draft_issue" +} diff --git a/pkg/github/__toolsnaps__/create_project_issue.snap b/pkg/github/__toolsnaps__/create_project_issue.snap new file mode 100644 index 00000000..b9f9071e --- /dev/null +++ b/pkg/github/__toolsnaps__/create_project_issue.snap @@ -0,0 +1,34 @@ +{ + "annotations": { + "title": "Create issue", + "readOnlyHint": false + }, + "description": "Create a new issue", + "inputSchema": { + "properties": { + "body": { + "description": "Issue body", + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "title": { + "description": "Issue title", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "title" + ], + "type": "object" + }, + "name": "create_project_issue" +} diff --git a/pkg/github/__toolsnaps__/delete_project_item.snap b/pkg/github/__toolsnaps__/delete_project_item.snap new file mode 100644 index 00000000..9edd632a --- /dev/null +++ b/pkg/github/__toolsnaps__/delete_project_item.snap @@ -0,0 +1,25 @@ +{ + "annotations": { + "title": "Delete project item", + "readOnlyHint": false + }, + "description": "Delete a project item", + "inputSchema": { + "properties": { + "item_id": { + "description": "Item ID", + "type": "string" + }, + "project_id": { + "description": "Project ID", + "type": "string" + } + }, + "required": [ + "project_id", + "item_id" + ], + "type": "object" + }, + "name": "delete_project_item" +} diff --git a/pkg/github/__toolsnaps__/get_project_fields.snap b/pkg/github/__toolsnaps__/get_project_fields.snap new file mode 100644 index 00000000..d43c60e9 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_project_fields.snap @@ -0,0 +1,33 @@ +{ + "annotations": { + "title": "Get project fields", + "readOnlyHint": true + }, + "description": "Get fields for a project", + "inputSchema": { + "properties": { + "number": { + "description": "Project number", + "type": "number" + }, + "owner": { + "description": "Owner login", + "type": "string" + }, + "owner_type": { + "description": "Owner type", + "enum": [ + "user", + "organization" + ], + "type": "string" + } + }, + "required": [ + "owner", + "number" + ], + "type": "object" + }, + "name": "get_project_fields" +} diff --git a/pkg/github/__toolsnaps__/get_project_items.snap b/pkg/github/__toolsnaps__/get_project_items.snap new file mode 100644 index 00000000..577dfbcc --- /dev/null +++ b/pkg/github/__toolsnaps__/get_project_items.snap @@ -0,0 +1,33 @@ +{ + "annotations": { + "title": "Get project items", + "readOnlyHint": true + }, + "description": "Get items for a project", + "inputSchema": { + "properties": { + "number": { + "description": "Project number", + "type": "number" + }, + "owner": { + "description": "Owner login", + "type": "string" + }, + "owner_type": { + "description": "Owner type", + "enum": [ + "user", + "organization" + ], + "type": "string" + } + }, + "required": [ + "owner", + "number" + ], + "type": "object" + }, + "name": "get_project_items" +} diff --git a/pkg/github/__toolsnaps__/list_projects.snap b/pkg/github/__toolsnaps__/list_projects.snap new file mode 100644 index 00000000..1ce9ccd9 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_projects.snap @@ -0,0 +1,28 @@ +{ + "annotations": { + "title": "List projects", + "readOnlyHint": true + }, + "description": "List Projects for a user or organization", + "inputSchema": { + "properties": { + "owner": { + "description": "Owner login (user or organization)", + "type": "string" + }, + "owner_type": { + "description": "Owner type", + "enum": [ + "user", + "organization" + ], + "type": "string" + } + }, + "required": [ + "owner" + ], + "type": "object" + }, + "name": "list_projects" +} diff --git a/pkg/github/__toolsnaps__/update_project_item_field.snap b/pkg/github/__toolsnaps__/update_project_item_field.snap new file mode 100644 index 00000000..da201a73 --- /dev/null +++ b/pkg/github/__toolsnaps__/update_project_item_field.snap @@ -0,0 +1,34 @@ +{ + "annotations": { + "title": "Update project item field", + "readOnlyHint": false + }, + "description": "Update a project item field", + "inputSchema": { + "properties": { + "field_id": { + "description": "Field ID", + "type": "string" + }, + "item_id": { + "description": "Item ID", + "type": "string" + }, + "project_id": { + "description": "Project ID", + "type": "string" + }, + "text_value": { + "description": "Text value", + "type": "string" + } + }, + "required": [ + "project_id", + "item_id", + "field_id" + ], + "type": "object" + }, + "name": "update_project_item_field" +} diff --git a/pkg/github/projects.go b/pkg/github/projects.go new file mode 100644 index 00000000..eeef4ea3 --- /dev/null +++ b/pkg/github/projects.go @@ -0,0 +1,382 @@ +package github + +import ( + "context" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/go-viper/mapstructure/v2" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" +) + +// ListProjects lists projects for a given user or organization. +func ListProjects(getClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("list_projects", + mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or organization")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), ReadOnlyHint: ToBoolPtr(true)}), + mcp.WithString("owner", mcp.Required(), mcp.Description("Owner login (user or organization)")), + mcp.WithString("owner_type", mcp.Description("Owner type"), mcp.Enum("user", "organization")), + ), 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 := OptionalParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if ownerType == "" { + ownerType = "organization" + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if ownerType == "user" { + var q struct { + User struct { + Projects struct { + Nodes []struct { + ID githubv4.ID + Title githubv4.String + Number githubv4.Int + } + } `graphql:"projectsV2(first: 100)"` + } `graphql:"user(login: $login)"` + } + if err := client.Query(ctx, &q, map[string]any{"login": githubv4.String(owner)}); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return MarshalledTextResult(q), nil + } + var q struct { + Organization struct { + Projects struct { + Nodes []struct { + ID githubv4.ID + Title githubv4.String + Number githubv4.Int + } + } `graphql:"projectsV2(first: 100)"` + } `graphql:"organization(login: $login)"` + } + if err := client.Query(ctx, &q, map[string]any{"login": githubv4.String(owner)}); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return MarshalledTextResult(q), nil + } +} + +// GetProjectFields lists fields for a project. +func GetProjectFields(getClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("get_project_fields", + mcp.WithDescription(t("TOOL_GET_PROJECT_FIELDS_DESCRIPTION", "Get fields for a project")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_FIELDS_USER_TITLE", "Get project fields"), ReadOnlyHint: ToBoolPtr(true)}), + mcp.WithString("owner", mcp.Required(), mcp.Description("Owner login")), + mcp.WithString("owner_type", mcp.Description("Owner type"), mcp.Enum("user", "organization")), + mcp.WithNumber("number", mcp.Required(), mcp.Description("Project number")), + ), 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 + } + number, err := RequiredInt(req, "number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ownerType, err := OptionalParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if ownerType == "" { + ownerType = "organization" + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if ownerType == "user" { + var q struct { + User struct { + Project struct { + Fields struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + DataType githubv4.String + } + } `graphql:"fields(first: 100)"` + } `graphql:"projectV2(number: $number)"` + } `graphql:"user(login: $login)"` + } + if err := client.Query(ctx, &q, map[string]any{"login": githubv4.String(owner), "number": githubv4.Int(number)}); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return MarshalledTextResult(q), nil + } + var q struct { + Organization struct { + Project struct { + Fields struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + DataType githubv4.String + } + } `graphql:"fields(first: 100)"` + } `graphql:"projectV2(number: $number)"` + } `graphql:"organization(login: $login)"` + } + if err := client.Query(ctx, &q, map[string]any{"login": githubv4.String(owner), "number": githubv4.Int(number)}); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return MarshalledTextResult(q), nil + } +} + +// GetProjectItems lists items for a project. +func GetProjectItems(getClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("get_project_items", + mcp.WithDescription(t("TOOL_GET_PROJECT_ITEMS_DESCRIPTION", "Get items for a project")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_ITEMS_USER_TITLE", "Get project items"), ReadOnlyHint: ToBoolPtr(true)}), + mcp.WithString("owner", mcp.Required(), mcp.Description("Owner login")), + mcp.WithString("owner_type", mcp.Description("Owner type"), mcp.Enum("user", "organization")), + mcp.WithNumber("number", mcp.Required(), mcp.Description("Project number")), + ), 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 + } + number, err := RequiredInt(req, "number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ownerType, err := OptionalParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if ownerType == "" { + ownerType = "organization" + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if ownerType == "user" { + var q struct { + User struct { + Project struct { + Items struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"items(first: 100)"` + } `graphql:"projectV2(number: $number)"` + } `graphql:"user(login: $login)"` + } + if err := client.Query(ctx, &q, map[string]any{"login": githubv4.String(owner), "number": githubv4.Int(number)}); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return MarshalledTextResult(q), nil + } + var q struct { + Organization struct { + Project struct { + Items struct { + Nodes []struct{ ID githubv4.ID } + } `graphql:"items(first: 100)"` + } `graphql:"projectV2(number: $number)"` + } `graphql:"organization(login: $login)"` + } + if err := client.Query(ctx, &q, map[string]any{"login": githubv4.String(owner), "number": githubv4.Int(number)}); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return MarshalledTextResult(q), nil + } +} + +// CreateIssue creates an issue in a repository. +func CreateProjectIssue(getClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("create_project_issue", + mcp.WithDescription(t("TOOL_CREATE_PROJECT_ISSUE_DESCRIPTION", "Create a new issue")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_CREATE_PROJECT_ISSUE_USER_TITLE", "Create issue"), ReadOnlyHint: ToBoolPtr(false)}), + mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), + mcp.WithString("title", mcp.Required(), mcp.Description("Issue title")), + mcp.WithString("body", mcp.Description("Issue body")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var params struct{ Owner, Repo, Title, Body string } + if err := mapstructure.Decode(req.Params.Arguments, ¶ms); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + var repoQ struct { + Repository struct{ ID githubv4.ID } `graphql:"repository(owner: $owner, name: $name)"` + } + if err := client.Query(ctx, &repoQ, map[string]any{"owner": githubv4.String(params.Owner), "name": githubv4.String(params.Repo)}); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + input := githubv4.CreateIssueInput{RepositoryID: repoQ.Repository.ID, Title: githubv4.String(params.Title)} + if params.Body != "" { + input.Body = githubv4.NewString(githubv4.String(params.Body)) + } + var mut struct { + CreateIssue struct{ Issue struct{ ID githubv4.ID } } `graphql:"createIssue(input: $input)"` + } + if err := client.Mutate(ctx, &mut, input, nil); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return MarshalledTextResult(mut), nil + } +} + +// AddIssueToProject adds an issue to a project by ID. +func AddIssueToProject(getClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("add_issue_to_project", + mcp.WithDescription(t("TOOL_ADD_ISSUE_TO_PROJECT_DESCRIPTION", "Add an issue to a project")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_ADD_ISSUE_TO_PROJECT_USER_TITLE", "Add issue to project"), ReadOnlyHint: ToBoolPtr(false)}), + mcp.WithString("project_id", mcp.Required(), mcp.Description("Project ID")), + mcp.WithString("issue_id", mcp.Required(), mcp.Description("Issue node ID")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + projectID, err := RequiredParam[string](req, "project_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issueID, err := RequiredParam[string](req, "issue_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + var mut struct { + AddProjectV2ItemById struct { + Item struct{ ID githubv4.ID } + } `graphql:"addProjectV2ItemById(input: $input)"` + } + input := githubv4.AddProjectV2ItemByIdInput{ProjectID: githubv4.ID(projectID), ContentID: githubv4.ID(issueID)} + if err := client.Mutate(ctx, &mut, input, nil); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return MarshalledTextResult(mut), nil + } +} + +// UpdateProjectItemField updates a field value on a project item. +func UpdateProjectItemField(getClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("update_project_item_field", + mcp.WithDescription(t("TOOL_UPDATE_PROJECT_ITEM_FIELD_DESCRIPTION", "Update a project item field")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_UPDATE_PROJECT_ITEM_FIELD_USER_TITLE", "Update project item field"), ReadOnlyHint: ToBoolPtr(false)}), + mcp.WithString("project_id", mcp.Required(), mcp.Description("Project ID")), + mcp.WithString("item_id", mcp.Required(), mcp.Description("Item ID")), + mcp.WithString("field_id", mcp.Required(), mcp.Description("Field ID")), + mcp.WithString("text_value", mcp.Description("Text value")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + projectID, err := RequiredParam[string](req, "project_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + itemID, err := RequiredParam[string](req, "item_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + fieldID, err := RequiredParam[string](req, "field_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + textValue, err := OptionalParam[string](req, "text_value") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + val := githubv4.ProjectV2FieldValue{} + if textValue != "" { + val.Text = githubv4.NewString(githubv4.String(textValue)) + } + var mut struct { + UpdateProjectV2ItemFieldValue struct{ Typename githubv4.String } `graphql:"updateProjectV2ItemFieldValue(input: $input)"` + } + input := githubv4.UpdateProjectV2ItemFieldValueInput{ProjectID: githubv4.ID(projectID), ItemID: githubv4.ID(itemID), FieldID: githubv4.ID(fieldID), Value: val} + if err := client.Mutate(ctx, &mut, input, nil); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return MarshalledTextResult(mut), nil + } +} + +// CreateDraftIssue creates a draft issue in a project. +func CreateDraftIssue(getClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("create_draft_issue", + mcp.WithDescription(t("TOOL_CREATE_DRAFT_ISSUE_DESCRIPTION", "Create a draft issue in a project")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_CREATE_DRAFT_ISSUE_USER_TITLE", "Create draft issue"), ReadOnlyHint: ToBoolPtr(false)}), + mcp.WithString("project_id", mcp.Required(), mcp.Description("Project ID")), + mcp.WithString("title", mcp.Required(), mcp.Description("Issue title")), + mcp.WithString("body", mcp.Description("Issue body")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + projectID, err := RequiredParam[string](req, "project_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + title, err := RequiredParam[string](req, "title") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + body, err := OptionalParam[string](req, "body") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + input := githubv4.AddProjectV2DraftIssueInput{ProjectID: githubv4.ID(projectID), Title: githubv4.String(title)} + if body != "" { + input.Body = githubv4.NewString(githubv4.String(body)) + } + var mut struct { + AddProjectV2DraftIssue struct{ Item struct{ ID githubv4.ID } } `graphql:"addProjectV2DraftIssue(input: $input)"` + } + if err := client.Mutate(ctx, &mut, input, nil); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return MarshalledTextResult(mut), nil + } +} + +// DeleteProjectItem removes an item from a project. +func DeleteProjectItem(getClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("delete_project_item", + mcp.WithDescription(t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a project item")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"), ReadOnlyHint: ToBoolPtr(false)}), + mcp.WithString("project_id", mcp.Required(), mcp.Description("Project ID")), + mcp.WithString("item_id", mcp.Required(), mcp.Description("Item ID")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + projectID, err := RequiredParam[string](req, "project_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + itemID, err := RequiredParam[string](req, "item_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + var mut struct { + DeleteProjectV2Item struct{ Typename githubv4.String } `graphql:"deleteProjectV2Item(input: $input)"` + } + input := githubv4.DeleteProjectV2ItemInput{ProjectID: githubv4.ID(projectID), ItemID: githubv4.ID(itemID)} + if err := client.Mutate(ctx, &mut, input, nil); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return MarshalledTextResult(mut), nil + } +} diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go new file mode 100644 index 00000000..5212e0e6 --- /dev/null +++ b/pkg/github/projects_test.go @@ -0,0 +1,105 @@ +package github + +import ( + "context" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/mark3labs/mcp-go/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListProjects(t *testing.T) { + mockClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Organization struct { + Projects struct { + Nodes []struct{ ID githubv4.ID } + } `graphql:"projectsV2(first: 100)"` + } `graphql:"organization(login: $login)"` + }{}, + map[string]any{"login": githubv4.String("acme")}, + githubv4mock.DataResponse(map[string]any{"organization": map[string]any{"projectsV2": map[string]any{"nodes": []any{}}}}), + ), + ) + tool, handler := ListProjects(stubGetGQLClientFn(githubv4.NewClient(mockClient)), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + res, err := handler(context.Background(), createMCPRequest(map[string]any{"owner": "acme"})) + require.NoError(t, err) + assert.NotNil(t, res) +} + +func Test_AddIssueToProject(t *testing.T) { + mockClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + AddProjectV2ItemById struct{ Item struct{ ID githubv4.ID } } `graphql:"addProjectV2ItemById(input: $input)"` + }{}, + githubv4.AddProjectV2ItemByIdInput{ProjectID: "proj", ContentID: "issue"}, + nil, + githubv4mock.DataResponse(map[string]any{"addProjectV2ItemById": map[string]any{"item": map[string]any{"id": "1"}}}), + ), + ) + tool, handler := AddIssueToProject(stubGetGQLClientFn(githubv4.NewClient(mockClient)), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + res, err := handler(context.Background(), createMCPRequest(map[string]any{"project_id": "proj", "issue_id": "issue"})) + require.NoError(t, err) + assert.NotNil(t, res) +} + +func Test_CreateProjectIssue(t *testing.T) { + mockClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct{ ID githubv4.ID } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{"owner": githubv4.String("acme"), "name": githubv4.String("demo")}, + githubv4mock.DataResponse(map[string]any{"repository": map[string]any{"id": "123"}}), + ), + githubv4mock.NewMutationMatcher( + struct { + CreateIssue struct{ Issue struct{ ID githubv4.ID } } `graphql:"createIssue(input: $input)"` + }{}, + githubv4.CreateIssueInput{RepositoryID: "123", Title: githubv4.String("hello")}, + nil, + githubv4mock.DataResponse(map[string]any{"createIssue": map[string]any{"issue": map[string]any{"id": "456"}}}), + ), + ) + tool, handler := CreateProjectIssue(stubGetGQLClientFn(githubv4.NewClient(mockClient)), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + res, err := handler(context.Background(), createMCPRequest(map[string]any{"owner": "acme", "repo": "demo", "title": "hello"})) + require.NoError(t, err) + assert.NotNil(t, res) +} + +func Test_ProjectToolSchemas(t *testing.T) { + client := githubv4.NewClient(nil) + tools := []mcp.Tool{} + t1, _ := ListProjects(stubGetGQLClientFn(client), translations.NullTranslationHelper) + tools = append(tools, t1) + t2, _ := GetProjectFields(stubGetGQLClientFn(client), translations.NullTranslationHelper) + tools = append(tools, t2) + t3, _ := GetProjectItems(stubGetGQLClientFn(client), translations.NullTranslationHelper) + tools = append(tools, t3) + t4, _ := CreateProjectIssue(stubGetGQLClientFn(client), translations.NullTranslationHelper) + tools = append(tools, t4) + t5, _ := AddIssueToProject(stubGetGQLClientFn(client), translations.NullTranslationHelper) + tools = append(tools, t5) + t6, _ := UpdateProjectItemField(stubGetGQLClientFn(client), translations.NullTranslationHelper) + tools = append(tools, t6) + t7, _ := CreateDraftIssue(stubGetGQLClientFn(client), translations.NullTranslationHelper) + tools = append(tools, t7) + t8, _ := DeleteProjectItem(stubGetGQLClientFn(client), translations.NullTranslationHelper) + tools = append(tools, t8) + for _, tool := range tools { + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index ba540d22..0f3248ae 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -88,6 +88,19 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(SubmitPendingPullRequestReview(getGQLClient, t)), toolsets.NewServerTool(DeletePendingPullRequestReview(getGQLClient, t)), ) + projects := toolsets.NewToolset("projects", "GitHub Projects V2 management tools"). + AddReadTools( + toolsets.NewServerTool(ListProjects(getGQLClient, t)), + toolsets.NewServerTool(GetProjectFields(getGQLClient, t)), + toolsets.NewServerTool(GetProjectItems(getGQLClient, t)), + ). + AddWriteTools( + toolsets.NewServerTool(CreateProjectIssue(getGQLClient, t)), + toolsets.NewServerTool(AddIssueToProject(getGQLClient, t)), + toolsets.NewServerTool(UpdateProjectItemField(getGQLClient, t)), + toolsets.NewServerTool(CreateDraftIssue(getGQLClient, t)), + toolsets.NewServerTool(DeleteProjectItem(getGQLClient, t)), + ) codeSecurity := toolsets.NewToolset("code_security", "Code security related tools, such as GitHub Code Scanning"). AddReadTools( toolsets.NewServerTool(GetCodeScanningAlert(getClient, t)), @@ -145,6 +158,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(issues) tsg.AddToolset(users) tsg.AddToolset(pullRequests) + tsg.AddToolset(projects) tsg.AddToolset(actions) tsg.AddToolset(codeSecurity) tsg.AddToolset(secretProtection)