Initial commit: Gitea MCP Server

This commit is contained in:
2026-04-10 21:57:19 -07:00
commit 1f27844dce
119 changed files with 25001 additions and 0 deletions
+598
View File
@@ -0,0 +1,598 @@
package actions
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"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"
)
const (
MonitorWorkflowDispatchToolName = "monitor_workflow_dispatch"
DefaultPollInterval = 10 * time.Second
DefaultTimeout = 5 * time.Minute
)
var (
MonitorWorkflowDispatchTool = mcp.NewTool(
MonitorWorkflowDispatchToolName,
mcp.WithDescription("Dispatch a workflow and monitor its execution until completion. Returns full execution summary including run ID, status, conclusion, duration, and logs."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Required(), mcp.Description("workflow ID or filename")),
mcp.WithString("ref", mcp.Required(), mcp.Description("git ref (branch/tag) to run workflow on")),
mcp.WithObject("inputs", mcp.Description("workflow inputs object")),
mcp.WithNumber("timeout_seconds", mcp.Description("polling timeout in seconds (default: 300 = 5 minutes)"), mcp.DefaultNumber(300), mcp.Min(10)),
mcp.WithNumber("poll_interval_seconds", mcp.Description("poll interval in seconds (default: 10)"), mcp.DefaultNumber(10), mcp.Min(5)),
)
)
type MonitorResult struct {
RunID int64 `json:"run_id"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
WorkflowID string `json:"workflow_id"`
WorkflowName string `json:"workflow_name,omitempty"`
Branch string `json:"branch"`
CommitSHA string `json:"commit_sha"`
Duration string `json:"duration"`
DurationSec float64 `json:"duration_seconds"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
Jobs []JobSummary `json:"jobs"`
Logs map[string]JobLogs `json:"logs,omitempty"`
Error string `json:"error,omitempty"`
TimedOut bool `json:"timed_out"`
}
type JobSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
Steps []StepInfo `json:"steps,omitempty"`
}
type StepInfo struct {
Name string `json:"name"`
Number int `json:"number"`
Status string `json:"status"`
Conclusion string `json:"conclusion,omitempty"`
}
type JobLogs struct {
JobID int64 `json:"job_id"`
JobName string `json:"job_name"`
Log string `json:"log,omitempty"`
Bytes int `json:"bytes"`
Truncated bool `json:"truncated"`
}
func init() {
Tool.RegisterWrite(server.ServerTool{
Tool: MonitorWorkflowDispatchTool,
Handler: monitorWorkflowDispatchFn,
})
}
func monitorWorkflowDispatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called monitorWorkflowDispatchFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("owner is required"),
map[string]string{"operation": "MonitorWorkflowDispatch"},
))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("repo is required"),
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner},
))
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("workflow_id is required"),
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner, "repo": repo},
))
}
ref, err := params.GetString(req.GetArguments(), "ref")
if err != nil || ref == "" {
return to.ErrorResult(errors.TranslateError(
stderrors.New("ref is required"),
map[string]string{"operation": "MonitorWorkflowDispatch", "owner": owner, "repo": repo, "workflow_id": workflowID},
))
}
timeoutSec := int(params.GetOptionalInt(req.GetArguments(), "timeout_seconds", 300))
pollIntervalSec := int(params.GetOptionalInt(req.GetArguments(), "poll_interval_seconds", 10))
var inputs map[string]any
if raw, exists := req.GetArguments()["inputs"]; exists {
if m, ok := raw.(map[string]any); ok {
inputs = m
}
}
if err := checkActionsAPIAvailable(ctx); err != nil {
return to.ErrorResult(err)
}
log.Infof("Dispatching workflow %s for %s/%s on ref %s", workflowID, owner, repo, ref)
_, err = dispatchWorkflow(ctx, owner, repo, workflowID, ref, inputs)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "DispatchWorkflow",
"owner": owner,
"repo": repo,
"workflow": workflowID,
}))
}
timeout := time.Duration(timeoutSec) * time.Second
pollInterval := time.Duration(pollIntervalSec) * time.Second
log.Infof("Waiting for workflow run to start (timeout: %v)...", timeout)
runID, err := waitForRunToStart(ctx, owner, repo, workflowID, ref, pollInterval, timeout)
if err != nil {
result := MonitorResult{
WorkflowID: workflowID,
Branch: ref,
Error: fmt.Sprintf("Workflow dispatched but run never started: %v", err),
}
return toErrorResultWithJSON(result)
}
log.Infof("Run %d started, monitoring until completion...", runID)
monitorResult, err := monitorRunUntilComplete(ctx, owner, repo, runID, pollInterval, timeout)
if err != nil {
return to.ErrorResult(errors.TranslateError(err, map[string]string{
"operation": "MonitorRun",
"owner": owner,
"repo": repo,
"run_id": fmt.Sprintf("%d", runID),
}))
}
log.Infof("Retrieving logs for run %d...", runID)
logs, err := retrieveJobLogs(ctx, owner, repo, monitorResult.Jobs)
if err != nil {
log.Warnf("Failed to retrieve some job logs: %v", err)
}
monitorResult.Logs = logs
return toTextResultWithJSON(monitorResult)
}
func checkActionsAPIAvailable(ctx context.Context) error {
var versionResp struct {
Version string `json:"version"`
}
status, err := gitea.DoJSON(ctx, "GET", "version", nil, nil, &versionResp)
if err != nil {
return errors.TranslateError(
fmt.Errorf("failed to check Gitea version: status=%d, err=%v", status, err),
map[string]string{"operation": "CheckGiteaVersion"},
)
}
major, minor, patch, err := parseVersionForCheck(versionResp.Version)
if err != nil {
return errors.TranslateError(
fmt.Errorf("failed to parse version '%s': %v", versionResp.Version, err),
map[string]string{"operation": "ParseVersion"},
)
}
if major < 1 || (major == 1 && minor < 23) {
return errors.NewEnhancedError(
stderrors.New("Actions API not available"),
fmt.Sprintf("Actions API requires Gitea 1.23+, found %d.%d.%d", major, minor, patch),
errors.CategoryActions,
).WithOperation("CheckActionsAPIAvailable")
}
return nil
}
func parseVersionForCheck(version string) (int, int, int, error) {
version = trimVersionPrefix(version)
parts := splitVersion(version)
if len(parts) < 2 {
return 0, 0, 0, fmt.Errorf("invalid version format: %s", version)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid major version: %s", parts[0])
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid minor version: %s", parts[1])
}
patch := 0
if len(parts) >= 3 {
patch, err = strconv.Atoi(parts[2])
if err != nil {
patch = 0
}
}
return major, minor, patch, nil
}
func trimVersionPrefix(v string) string {
v = trimOnePrefix(v, "v")
v = trimOnePrefix(v, "V")
return v
}
func trimOnePrefix(s, prefix string) string {
if len(s) > 0 && s[0] == prefix[0] {
return s[1:]
}
return s
}
func splitVersion(v string) []string {
var parts []string
start := 0
for i := 0; i < len(v); i++ {
if v[i] == '.' {
if start < i {
parts = append(parts, v[start:i])
}
start = i + 1
}
}
if start < len(v) {
parts = append(parts, v[start:])
}
return parts
}
func dispatchWorkflow(ctx context.Context, owner, repo, workflowID, ref string, inputs map[string]any) (map[string]any, error) {
body := map[string]any{
"ref": ref,
}
if inputs != nil {
body["inputs"] = inputs
}
err := doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatches", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatch", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
},
nil, body, nil,
)
if err != nil {
var httpErr *gitea.HTTPError
if stderrors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
return nil, errors.NewEnhancedError(
err,
fmt.Sprintf("workflow dispatch not supported on this Gitea version (endpoint returned %d)", httpErr.StatusCode),
errors.CategoryActions,
).WithOperation("DispatchWorkflow")
}
return nil, err
}
return map[string]any{"message": "workflow dispatched"}, nil
}
func waitForRunToStart(ctx context.Context, owner, repo, workflowID, ref string, pollInterval, timeout time.Duration) (int64, error) {
startTime := time.Now()
seenRunIDs := make(map[int64]bool)
for time.Since(startTime) < timeout {
select {
case <-ctx.Done():
return 0, ctx.Err()
default:
}
runs, err := listRecentRuns(ctx, owner, repo, workflowID, ref, 10)
if err != nil {
log.Warnf("Failed to list runs: %v", err)
time.Sleep(pollInterval)
continue
}
for _, run := range runs {
runID := int64(run["id"].(float64))
if seenRunIDs[runID] {
continue
}
seenRunIDs[runID] = true
status := getString(run, "status")
if status == "queued" || status == "in_progress" || status == "waiting" {
return runID, nil
}
createdAt := getString(run, "created_at")
if createdAt != "" {
runTime, err := time.Parse(time.RFC3339, createdAt)
if err == nil && time.Since(runTime) < 2*time.Minute {
return runID, nil
}
}
}
time.Sleep(pollInterval)
}
return 0, fmt.Errorf("timeout waiting for run to start after %v", timeout)
}
func listRecentRuns(ctx context.Context, owner, repo, workflowID, ref string, limit int) ([]map[string]any, error) {
query := url.Values{}
query.Set("limit", strconv.Itoa(limit))
var result struct {
WorkflowRuns []map[string]any `json:"workflow_runs"`
}
err := doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return nil, err
}
var matchingRuns []map[string]any
for _, run := range result.WorkflowRuns {
if workflowID != "" {
runWorkflowID := getString(run, "workflow_id")
runPath := getString(run, "path")
if runWorkflowID != workflowID && runPath != workflowID &&
runWorkflowID != "" && !containsPath(runPath, workflowID) {
continue
}
}
if ref != "" {
headBranch := getString(run, "head_branch")
if headBranch != "" && headBranch != ref {
continue
}
}
matchingRuns = append(matchingRuns, run)
}
return matchingRuns, nil
}
func containsPath(path, substring string) bool {
return len(path) > 0 && len(substring) > 0 &&
(path == substring ||
(len(path) > len(substring) && (path[len(path)-len(substring):] == substring ||
path[:len(substring)] == substring)))
}
func getString(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
if v, ok := m[key].(float64); ok {
return strconv.FormatInt(int64(v), 10)
}
return ""
}
func monitorRunUntilComplete(ctx context.Context, owner, repo string, runID int64, pollInterval, timeout time.Duration) (*MonitorResult, error) {
startTime := time.Now()
var firstSeen time.Time
for time.Since(startTime) < timeout {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
run, err := getRun(ctx, owner, repo, runID)
if err != nil {
return nil, fmt.Errorf("failed to get run %d: %v", runID, err)
}
status := getString(run, "status")
conclusion := getString(run, "conclusion")
if firstSeen.IsZero() {
firstSeen = time.Now()
}
if status == "completed" {
return buildMonitorResult(run, runID, owner, repo, firstSeen, time.Now())
}
log.Debugf("Run %d status: %s (conclusion: %s), waiting...", runID, status, conclusion)
time.Sleep(pollInterval)
}
run, err := getRun(ctx, owner, repo, runID)
if err != nil {
return nil, fmt.Errorf("timeout after %v and failed to get final status: %v", timeout, err)
}
result, _ := buildMonitorResult(run, runID, owner, repo, firstSeen, time.Now())
result.TimedOut = true
result.Error = fmt.Sprintf("Polling timed out after %v", timeout)
return result, nil
}
func getRun(ctx context.Context, owner, repo string, runID int64) (map[string]any, error) {
var result map[string]any
err := doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, &result,
)
return result, err
}
func buildMonitorResult(run map[string]any, runID int64, owner, repo string, started, ended time.Time) (*MonitorResult, error) {
result := &MonitorResult{
RunID: runID,
Status: getString(run, "status"),
Conclusion: getString(run, "conclusion"),
WorkflowID: getString(run, "workflow_id"),
WorkflowName: getString(run, "name"),
Branch: getString(run, "head_branch"),
CommitSHA: getString(run, "head_sha"),
StartedAt: getString(run, "created_at"),
CompletedAt: getString(run, "updated_at"),
}
if createdAt := getString(run, "run_started_at"); createdAt != "" {
result.StartedAt = createdAt
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
started = t
}
}
if updatedAt := getString(run, "updated_at"); updatedAt != "" {
if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
ended = t
}
}
duration := ended.Sub(started)
if duration < 0 {
duration = 0
}
result.Duration = duration.String()
result.DurationSec = duration.Seconds()
jobs, err := listRunJobs(runID, owner, repo, run)
if err == nil {
result.Jobs = jobs
}
return result, nil
}
func listRunJobs(runID int64, owner, repo string, run map[string]any) ([]JobSummary, error) {
if jobsData, ok := run["jobs"].([]any); ok && len(jobsData) > 0 {
return parseJobSummaries(jobsData), nil
}
return nil, nil
}
func parseJobSummaries(jobsData []any) []JobSummary {
var summaries []JobSummary
for _, j := range jobsData {
job, ok := j.(map[string]any)
if !ok {
continue
}
summary := JobSummary{
ID: int64(job["id"].(float64)),
Name: getString(job, "name"),
Status: getString(job, "status"),
Conclusion: getString(job, "conclusion"),
StartedAt: getString(job, "started_at"),
CompletedAt: getString(job, "completed_at"),
}
if stepsData, ok := job["steps"].([]any); ok {
summary.Steps = parseSteps(stepsData)
}
summaries = append(summaries, summary)
}
return summaries
}
func parseSteps(stepsData []any) []StepInfo {
var steps []StepInfo
for _, s := range stepsData {
step, ok := s.(map[string]any)
if !ok {
continue
}
steps = append(steps, StepInfo{
Name: getString(step, "name"),
Number: int(step["number"].(float64)),
Status: getString(step, "status"),
Conclusion: getString(step, "conclusion"),
})
}
return steps
}
func retrieveJobLogs(ctx context.Context, owner, repo string, jobs []JobSummary) (map[string]JobLogs, error) {
logs := make(map[string]JobLogs)
for _, job := range jobs {
if job.ID == 0 {
continue
}
logData, _, err := fetchJobLogBytes(ctx, owner, repo, job.ID)
if err != nil {
log.Warnf("Failed to fetch logs for job %d: %v", job.ID, err)
continue
}
maxLogBytes := 100 * 1024
truncated := false
if len(logData) > maxLogBytes {
logData = logData[len(logData)-maxLogBytes:]
truncated = true
}
logs[job.Name] = JobLogs{
JobID: job.ID,
JobName: job.Name,
Log: string(logData),
Bytes: len(logData),
Truncated: truncated,
}
}
return logs, nil
}
func toTextResultWithJSON(result *MonitorResult) (*mcp.CallToolResult, error) {
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return to.ErrorResult(fmt.Errorf("failed to marshal result: %v", err))
}
return to.TextResult(string(jsonBytes))
}
func toErrorResultWithJSON(result MonitorResult) (*mcp.CallToolResult, error) {
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
return to.ErrorResult(fmt.Errorf("%s (marshal error: %v)", result.Error, err))
}
return to.TextResult(fmt.Sprintf("Error: %s\n\nPartial Result:\n%s", result.Error, string(jsonBytes)))
}