From 394dca290c5b0361d3842f6d25561db8a105c33d Mon Sep 17 00:00:00 2001 From: logikonline Date: Sat, 24 Jan 2026 15:01:58 -0500 Subject: [PATCH] feat(mcp): add list_secrets tool for MCP API Implements list_secrets MCP tool to query available secrets across global, organization, and repository scopes. Returns secret names, descriptions, and metadata without exposing values. Supports optional owner and repo parameters to filter by scope. --- routers/api/v2/mcp.go | 112 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/routers/api/v2/mcp.go b/routers/api/v2/mcp.go index d916e99b3f..9e0042f731 100644 --- a/routers/api/v2/mcp.go +++ b/routers/api/v2/mcp.go @@ -14,6 +14,8 @@ import ( actions_model "code.gitcaddy.com/server/v3/models/actions" "code.gitcaddy.com/server/v3/models/db" repo_model "code.gitcaddy.com/server/v3/models/repo" + secret_model "code.gitcaddy.com/server/v3/models/secret" + user_model "code.gitcaddy.com/server/v3/models/user" "code.gitcaddy.com/server/v3/modules/actions" "code.gitcaddy.com/server/v3/modules/json" "code.gitcaddy.com/server/v3/modules/log" @@ -235,6 +237,23 @@ var mcpTools = []MCPTool{ "required": []string{"owner", "repo", "tag"}, }, }, + { + Name: "list_secrets", + Description: "List available secrets (names and descriptions only, not values) for workflows. Shows global, organization, and repository secrets.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "owner": map[string]any{ + "type": "string", + "description": "Repository or organization owner (optional, shows global secrets if omitted)", + }, + "repo": map[string]any{ + "type": "string", + "description": "Repository name (optional, shows org secrets if omitted)", + }, + }, + }, + }, } // MCPHandler handles MCP protocol requests @@ -326,6 +345,8 @@ func handleToolsCall(ctx *context.APIContext, req *MCPRequest) { result, err = toolListReleases(ctx, params.Arguments) case "get_release": result, err = toolGetRelease(ctx, params.Arguments) + case "list_secrets": + result, err = toolListSecrets(ctx, params.Arguments) case "get_error_patterns": result, err = toolGetErrorPatterns(ctx, params.Arguments) case "report_error_solution": @@ -789,3 +810,94 @@ func toolGetRelease(ctx *context.APIContext, args map[string]any) (any, error) { "asset_count": len(assets), }, nil } + +func toolListSecrets(ctx *context.APIContext, args map[string]any) (any, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + + result := map[string]any{ + "global_secrets": []map[string]any{}, + "owner_secrets": []map[string]any{}, + "repo_secrets": []map[string]any{}, + } + + // Always include global secrets (available to all workflows) + globalSecrets, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{Global: true}) + if err != nil { + log.Error("Failed to fetch global secrets: %v", err) + } else { + globalList := make([]map[string]any, 0, len(globalSecrets)) + for _, s := range globalSecrets { + globalList = append(globalList, map[string]any{ + "name": s.Name, + "description": s.Description, + "created_at": s.CreatedUnix.AsTime().Format(time.RFC3339), + "scope": "global", + }) + } + result["global_secrets"] = globalList + } + + // If owner is specified, get org/user secrets + if owner != "" { + ownerUser, err := user_model.GetUserByName(ctx, owner) + if err != nil { + return nil, fmt.Errorf("owner not found: %s", owner) + } + + ownerSecrets, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{OwnerID: ownerUser.ID}) + if err != nil { + log.Error("Failed to fetch owner secrets: %v", err) + } else { + ownerList := make([]map[string]any, 0, len(ownerSecrets)) + for _, s := range ownerSecrets { + scope := "user" + if ownerUser.IsOrganization() { + scope = "organization" + } + ownerList = append(ownerList, map[string]any{ + "name": s.Name, + "description": s.Description, + "created_at": s.CreatedUnix.AsTime().Format(time.RFC3339), + "scope": scope, + "owner": owner, + }) + } + result["owner_secrets"] = ownerList + } + + // If repo is also specified, get repo secrets + if repo != "" { + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + repoSecrets, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{RepoID: repository.ID}) + if err != nil { + log.Error("Failed to fetch repo secrets: %v", err) + } else { + repoList := make([]map[string]any, 0, len(repoSecrets)) + for _, s := range repoSecrets { + repoList = append(repoList, map[string]any{ + "name": s.Name, + "description": s.Description, + "created_at": s.CreatedUnix.AsTime().Format(time.RFC3339), + "scope": "repository", + "repo": fmt.Sprintf("%s/%s", owner, repo), + }) + } + result["repo_secrets"] = repoList + } + } + } + + // Add summary counts + result["total_count"] = len(result["global_secrets"].([]map[string]any)) + + len(result["owner_secrets"].([]map[string]any)) + + len(result["repo_secrets"].([]map[string]any)) + + result["note"] = "Secret values are not shown for security. Only names and descriptions are available." + + return result, nil +}