Files
gitea-mcp/operation/actions/config.go
silverwind bba612d238 Consolidate tools from 110 to 45 using method dispatch (#143)
Consolidate 110 individual MCP tools down to 45 using a method dispatch pattern, aligning tool names with the GitHub MCP server conventions.

**Motivation:** LLMs work better with fewer, well-organized tools. The method dispatch pattern (used by GitHub's MCP server) groups related operations under read/write tools with a `method` parameter.

**Changes:**
- Group related tools into `_read`/`_write` pairs with method dispatch (e.g. `issue_read`, `issue_write`, `pull_request_read`, `pull_request_write`)
- Rename tools to match GitHub MCP naming (`get_file_contents`, `create_or_update_file`, `list_issues`, `list_pull_requests`, etc.)
- Rename `pageSize` to `perPage` for GitHub MCP compat
- Move issue label ops (`add_labels`, `remove_label`, etc.) into `issue_write`
- Merge `create_file`/`update_file` into `create_or_update_file` with optional `sha`
- Make `delete_file` require `sha`
- Add `get_labels` method to `issue_read`
- Add shared helpers: `GetInt64Slice`, `GetStringSlice`, `GetPagination` in params package
- Unexport all dispatch handler functions
- Fix: pass assignees/milestone in `CreateIssue`, bounds check in `GetFileContent`

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/143
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-06 19:12:15 +00:00

556 lines
20 KiB
Go

package actions
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"time"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ActionsConfigReadToolName = "actions_config_read"
ActionsConfigWriteToolName = "actions_config_write"
)
type secretMeta struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at,omitzero"`
}
func toSecretMetas(secrets []*gitea_sdk.Secret) []secretMeta {
metas := make([]secretMeta, 0, len(secrets))
for _, s := range secrets {
if s == nil {
continue
}
metas = append(metas, secretMeta{
Name: s.Name,
Description: s.Description,
CreatedAt: s.Created,
})
}
return metas
}
var (
ActionsConfigReadTool = mcp.NewTool(
ActionsConfigReadToolName,
mcp.WithDescription("Read Actions secrets and variables configuration."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_secrets", "list_org_secrets", "list_repo_variables", "get_repo_variable", "list_org_variables", "get_org_variable")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("variable name (required for get methods)")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ActionsConfigWriteTool = mcp.NewTool(
ActionsConfigWriteToolName,
mcp.WithDescription("Manage Actions secrets and variables: create, update, or delete."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("upsert_repo_secret", "delete_repo_secret", "upsert_org_secret", "delete_org_secret", "create_repo_variable", "update_repo_variable", "delete_repo_variable", "create_org_variable", "update_org_variable", "delete_org_variable")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("secret or variable name (required for most methods)")),
mcp.WithString("data", mcp.Description("secret value (required for upsert secret methods)")),
mcp.WithString("value", mcp.Description("variable value (required for create/update variable methods)")),
mcp.WithString("description", mcp.Description("description for secret or variable")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsConfigReadTool, Handler: configReadFn})
Tool.RegisterWrite(server.ServerTool{Tool: ActionsConfigWriteTool, Handler: configWriteFn})
}
func configReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_repo_secrets":
return listRepoActionSecretsFn(ctx, req)
case "list_org_secrets":
return listOrgActionSecretsFn(ctx, req)
case "list_repo_variables":
return listRepoActionVariablesFn(ctx, req)
case "get_repo_variable":
return getRepoActionVariableFn(ctx, req)
case "list_org_variables":
return listOrgActionVariablesFn(ctx, req)
case "get_org_variable":
return getOrgActionVariableFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func configWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "upsert_repo_secret":
return upsertRepoActionSecretFn(ctx, req)
case "delete_repo_secret":
return deleteRepoActionSecretFn(ctx, req)
case "upsert_org_secret":
return upsertOrgActionSecretFn(ctx, req)
case "delete_org_secret":
return deleteOrgActionSecretFn(ctx, req)
case "create_repo_variable":
return createRepoActionVariableFn(ctx, req)
case "update_repo_variable":
return updateRepoActionVariableFn(ctx, req)
case "delete_repo_variable":
return deleteRepoActionVariableFn(ctx, req)
case "create_org_variable":
return createOrgActionVariableFn(ctx, req)
case "update_org_variable":
return updateOrgActionVariableFn(ctx, req)
case "delete_org_variable":
return deleteOrgActionVariableFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
// Secret functions
func listRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionSecretsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.ListRepoActionSecret(owner, repo, gitea_sdk.ListRepoActionSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action secrets err: %v", err))
}
return to.TextResult(toSecretMetas(secrets))
}
func upsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called upsertRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" {
return to.ErrorResult(errors.New("data is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateRepoActionSecret(owner, repo, gitea_sdk.CreateSecretOption{
Name: name,
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func deleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionSecret(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted", "status": resp.StatusCode})
}
func listOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgActionSecretsFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.ListOrgActionSecret(org, gitea_sdk.ListOrgActionSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action secrets err: %v", err))
}
return to.TextResult(toSecretMetas(secrets))
}
func upsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called upsertOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" {
return to.ErrorResult(errors.New("data is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateOrgActionSecret(org, gitea_sdk.CreateSecretOption{
Name: name,
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func deleteOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
escapedOrg := url.PathEscape(org)
escapedSecret := url.PathEscape(name)
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/secrets/%s", escapedOrg, escapedSecret), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted"})
}
// Variable functions
func listRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionVariablesFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/actions/variables", url.PathEscape(owner), url.PathEscape(repo)), query, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action variables err: %v", err))
}
return to.TextResult(result)
}
func getRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetRepoActionVariable(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repo action variable err: %v", err))
}
return to.TextResult(variable)
}
func createRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateRepoActionVariable(owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("create repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func updateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.UpdateRepoActionVariable(owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("update repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func deleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionVariable(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted", "status": resp.StatusCode})
}
func listOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgActionVariablesFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variables, _, err := client.ListOrgActionVariable(org, gitea_sdk.ListOrgActionVariableOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action variables err: %v", err))
}
return to.TextResult(variables)
}
func getOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetOrgActionVariable(org, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get org action variable err: %v", err))
}
return to.TextResult(variable)
}
func createOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateOrgActionVariable(org, gitea_sdk.CreateOrgActionVariableOption{
Name: name,
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func updateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.UpdateOrgActionVariable(org, name, gitea_sdk.UpdateOrgActionVariableOption{
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("update org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func deleteOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/variables/%s", url.PathEscape(org), url.PathEscape(name)), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted"})
}