Initial commit: Gitea MCP Server
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/gitea-mcp/pkg/errors"
|
||||
"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"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
GetWorkflowFileContentToolName = "get_workflow_file_content"
|
||||
)
|
||||
|
||||
var (
|
||||
GetWorkflowFileContentTool = mcp.NewTool(
|
||||
GetWorkflowFileContentToolName,
|
||||
mcp.WithDescription("Get workflow file content from .gitea/workflows/ or .github/workflows/ directories. Auto-discovers workflow files and returns parsed YAML as JSON."),
|
||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
||||
mcp.WithString("ref", mcp.Description("git ref (branch/tag/commit). Defaults to default branch if not specified")),
|
||||
mcp.WithString("pattern", mcp.Description("file pattern to match (e.g., '*.yml', 'build-*.yml'). Defaults to all workflow files")),
|
||||
mcp.WithString("filename", mcp.Description("specific workflow filename to retrieve. If provided, pattern is ignored")),
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
Tool.RegisterRead(server.ServerTool{
|
||||
Tool: GetWorkflowFileContentTool,
|
||||
Handler: getWorkflowFileContentFn,
|
||||
})
|
||||
}
|
||||
|
||||
type WorkflowFile struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
SHA string `json:"sha"`
|
||||
Size int64 `json:"size"`
|
||||
Content interface{} `json:"content"`
|
||||
RawContent string `json:"raw_content,omitempty"`
|
||||
Encoding string `json:"encoding,omitempty"`
|
||||
}
|
||||
|
||||
type WorkflowFilesResult struct {
|
||||
Directory string `json:"directory"`
|
||||
Files []WorkflowFile `json:"files"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
|
||||
func getWorkflowFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
log.Debugf("Called getWorkflowFileContentFn")
|
||||
|
||||
owner, err := params.GetString(req.GetArguments(), "owner")
|
||||
if err != nil {
|
||||
return to.ErrorResult(errors.TranslateError(err, map[string]string{
|
||||
"operation": "GetWorkflowFileContent",
|
||||
"param": "owner",
|
||||
}))
|
||||
}
|
||||
|
||||
repo, err := params.GetString(req.GetArguments(), "repo")
|
||||
if err != nil {
|
||||
return to.ErrorResult(errors.TranslateError(err, map[string]string{
|
||||
"operation": "GetWorkflowFileContent",
|
||||
"param": "repo",
|
||||
}))
|
||||
}
|
||||
|
||||
ref, _ := req.GetArguments()["ref"].(string)
|
||||
pattern, _ := req.GetArguments()["pattern"].(string)
|
||||
filename, _ := req.GetArguments()["filename"].(string)
|
||||
|
||||
directories := []string{".gitea/workflows", ".github/workflows"}
|
||||
|
||||
var result WorkflowFilesResult
|
||||
var lastErr error
|
||||
|
||||
for _, dir := range directories {
|
||||
if filename != "" {
|
||||
file, err := getWorkflowFile(ctx, owner, repo, ref, filepath.Join(dir, filename))
|
||||
if err == nil {
|
||||
result.Directory = dir
|
||||
result.Files = []WorkflowFile{*file}
|
||||
result.TotalCount = 1
|
||||
return to.TextResult(result)
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
files, err := discoverWorkflowFiles(ctx, owner, repo, ref, dir, pattern)
|
||||
if err == nil && len(files) > 0 {
|
||||
result.Directory = dir
|
||||
result.Files = files
|
||||
result.TotalCount = len(files)
|
||||
return to.TextResult(result)
|
||||
}
|
||||
if err != nil && lastErr == nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
return to.TextResult(WorkflowFilesResult{
|
||||
Directory: "",
|
||||
Files: []WorkflowFile{},
|
||||
TotalCount: 0,
|
||||
})
|
||||
}
|
||||
|
||||
func discoverWorkflowFiles(ctx context.Context, owner, repo, ref, dir, pattern string) ([]WorkflowFile, error) {
|
||||
client, err := gitea.ClientFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.TranslateError(err, map[string]string{
|
||||
"operation": "DiscoverWorkflowFiles",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
})
|
||||
}
|
||||
|
||||
contents, _, err := client.ListContents(owner, repo, ref, dir)
|
||||
if err != nil {
|
||||
return nil, errors.TranslateError(err, map[string]string{
|
||||
"operation": "ListWorkflowDirectory",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"path": dir,
|
||||
})
|
||||
}
|
||||
|
||||
var files []WorkflowFile
|
||||
|
||||
for _, content := range contents {
|
||||
if content.Type != "file" {
|
||||
continue
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(content.Name))
|
||||
if ext != ".yml" && ext != ".yaml" {
|
||||
continue
|
||||
}
|
||||
|
||||
if pattern != "" && !matchPattern(content.Name, pattern) {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(dir, content.Name)
|
||||
file, err := getWorkflowFile(ctx, owner, repo, ref, filePath)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to get workflow file %s: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
files = append(files, *file)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func getWorkflowFile(ctx context.Context, owner, repo, ref, path string) (*WorkflowFile, error) {
|
||||
escapedOwner := url.PathEscape(owner)
|
||||
escapedRepo := url.PathEscape(repo)
|
||||
escapedPath := url.PathEscape(path)
|
||||
|
||||
var result any
|
||||
_, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/contents/%s", escapedOwner, escapedRepo, escapedPath), nil, nil, &result)
|
||||
if err != nil {
|
||||
return nil, errors.TranslateError(err, map[string]string{
|
||||
"operation": "GetWorkflowFile",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"path": path,
|
||||
})
|
||||
}
|
||||
|
||||
contentMap, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response type for workflow file")
|
||||
}
|
||||
|
||||
workflowFile := &WorkflowFile{
|
||||
Path: path,
|
||||
}
|
||||
|
||||
if name, ok := contentMap["name"].(string); ok {
|
||||
workflowFile.Name = name
|
||||
}
|
||||
if sha, ok := contentMap["sha"].(string); ok {
|
||||
workflowFile.SHA = sha
|
||||
}
|
||||
if size, ok := contentMap["size"].(float64); ok {
|
||||
workflowFile.Size = int64(size)
|
||||
}
|
||||
if encoding, ok := contentMap["encoding"].(string); ok {
|
||||
workflowFile.Encoding = encoding
|
||||
}
|
||||
|
||||
if contentStr, ok := contentMap["content"].(string); ok && contentStr != "" {
|
||||
var rawContent []byte
|
||||
|
||||
if workflowFile.Encoding == "base64" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(contentStr)
|
||||
if err != nil {
|
||||
return nil, errors.TranslateError(
|
||||
errors.NewEnhancedError(err, "Failed to decode workflow file content", errors.CategoryActions),
|
||||
map[string]string{
|
||||
"operation": "DecodeWorkflowFile",
|
||||
"path": path,
|
||||
"encoding": workflowFile.Encoding,
|
||||
},
|
||||
)
|
||||
}
|
||||
rawContent = decoded
|
||||
} else {
|
||||
rawContent = []byte(contentStr)
|
||||
}
|
||||
|
||||
workflowFile.RawContent = string(rawContent)
|
||||
|
||||
var yamlContent interface{}
|
||||
if err := yaml.Unmarshal(rawContent, &yamlContent); err != nil {
|
||||
log.Debugf("Failed to parse YAML for %s: %v", path, err)
|
||||
yamlContent = nil
|
||||
} else {
|
||||
workflowFile.Content = convertYamlToInterface(yamlContent)
|
||||
}
|
||||
}
|
||||
|
||||
return workflowFile, nil
|
||||
}
|
||||
|
||||
func matchPattern(filename, pattern string) bool {
|
||||
pattern = strings.ToLower(pattern)
|
||||
filename = strings.ToLower(filename)
|
||||
|
||||
if pattern == "*" || pattern == "*.*" {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(pattern, "*") {
|
||||
suffix := pattern[1:]
|
||||
return strings.HasSuffix(filename, suffix)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(pattern, "*") {
|
||||
prefix := pattern[:len(pattern)-1]
|
||||
return strings.HasPrefix(filename, prefix)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") {
|
||||
mid := pattern[1 : len(pattern)-1]
|
||||
return strings.Contains(filename, mid)
|
||||
}
|
||||
|
||||
return filename == pattern || strings.HasPrefix(filename, pattern)
|
||||
}
|
||||
|
||||
func convertYamlToInterface(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case map[string]interface{}:
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range val {
|
||||
result[k] = convertYamlToInterface(v)
|
||||
}
|
||||
return result
|
||||
case map[interface{}]interface{}:
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range val {
|
||||
key := fmt.Sprintf("%v", k)
|
||||
result[key] = convertYamlToInterface(v)
|
||||
}
|
||||
return result
|
||||
case []interface{}:
|
||||
result := make([]interface{}, len(val))
|
||||
for i, v := range val {
|
||||
result[i] = convertYamlToInterface(v)
|
||||
}
|
||||
return result
|
||||
case []yaml.Node:
|
||||
result := make([]interface{}, len(val))
|
||||
for i, v := range val {
|
||||
result[i] = convertYamlToInterface(&v)
|
||||
}
|
||||
return result
|
||||
case *yaml.Node:
|
||||
switch val.Kind {
|
||||
case yaml.ScalarNode:
|
||||
s := val.Value
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
return s
|
||||
case yaml.SequenceNode:
|
||||
result := make([]interface{}, len(val.Content))
|
||||
for i, n := range val.Content {
|
||||
result[i] = convertYamlToInterface(&n)
|
||||
}
|
||||
return result
|
||||
case yaml.MappingNode:
|
||||
result := make(map[string]interface{})
|
||||
for i := 0; i < len(val.Content); i += 2 {
|
||||
key := val.Content[i].Value
|
||||
result[key] = convertYamlToInterface(&val.Content[i+1])
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return val.Value
|
||||
}
|
||||
default:
|
||||
return val
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user