diff --git a/routers/api/v2/api.go b/routers/api/v2/api.go index 3c97867bf0..664f56d9ea 100644 --- a/routers/api/v2/api.go +++ b/routers/api/v2/api.go @@ -84,8 +84,12 @@ func Routes() *web.Router { m.Get("/live", LivenessCheck) m.Get("/ready", ReadinessCheck) m.Get("/component/{component}", ComponentHealthCheck) + }) + // MCP Protocol endpoint for AI tool integration + m.Post("/mcp", MCPHandler) + // Operation progress endpoints (SSE) m.Group("/operations", func() { m.Get("/{id}/progress", OperationProgress) diff --git a/routers/api/v2/mcp.go b/routers/api/v2/mcp.go new file mode 100644 index 0000000000..02947544f8 --- /dev/null +++ b/routers/api/v2/mcp.go @@ -0,0 +1,732 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v2 + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" +) + +// MCP Protocol Types (JSON-RPC 2.0) + +type MCPRequest struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type MCPResponse struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Result interface{} `json:"result,omitempty"` + Error *MCPError `json:"error,omitempty"` +} + +type MCPError struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +// MCP Tool definitions +type MCPTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]interface{} `json:"inputSchema"` +} + +type MCPToolsListResult struct { + Tools []MCPTool `json:"tools"` +} + +type MCPToolCallParams struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` +} + +type MCPToolCallResult struct { + Content []MCPContent `json:"content"` + IsError bool `json:"isError,omitempty"` +} + +type MCPContent struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type MCPInitializeParams struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities map[string]interface{} `json:"capabilities"` + ClientInfo map[string]string `json:"clientInfo"` +} + +type MCPInitializeResult struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities map[string]interface{} `json:"capabilities"` + ServerInfo map[string]string `json:"serverInfo"` +} + +// Available MCP tools +var mcpTools = []MCPTool{ + { + Name: "list_runners", + Description: "List all runners with their status, capabilities, and current workload", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "owner": map[string]interface{}{ + "type": "string", + "description": "Repository owner (optional, lists global runners if omitted)", + }, + "repo": map[string]interface{}{ + "type": "string", + "description": "Repository name (optional)", + }, + "status": map[string]interface{}{ + "type": "string", + "enum": []string{"online", "offline", "all"}, + "description": "Filter by runner status", + }, + }, + }, + }, + { + Name: "get_runner", + Description: "Get detailed information about a specific runner including capabilities, disk space, and bandwidth", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "runner_id": map[string]interface{}{ + "type": "integer", + "description": "The runner ID", + }, + }, + "required": []string{"runner_id"}, + }, + }, + { + Name: "list_workflow_runs", + Description: "List workflow runs for a repository with status and timing information", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "owner": map[string]interface{}{ + "type": "string", + "description": "Repository owner", + }, + "repo": map[string]interface{}{ + "type": "string", + "description": "Repository name", + }, + "status": map[string]interface{}{ + "type": "string", + "enum": []string{"pending", "running", "success", "failure", "cancelled", "all"}, + "description": "Filter by run status", + }, + "limit": map[string]interface{}{ + "type": "integer", + "description": "Maximum number of runs to return (default 20)", + }, + }, + "required": []string{"owner", "repo"}, + }, + }, + { + Name: "get_workflow_run", + Description: "Get detailed information about a specific workflow run including all jobs and their status", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "owner": map[string]interface{}{ + "type": "string", + "description": "Repository owner", + }, + "repo": map[string]interface{}{ + "type": "string", + "description": "Repository name", + }, + "run_id": map[string]interface{}{ + "type": "integer", + "description": "The workflow run ID", + }, + }, + "required": []string{"owner", "repo", "run_id"}, + }, + }, + { + Name: "get_job_logs", + Description: "Get logs from a specific job in a workflow run", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "owner": map[string]interface{}{ + "type": "string", + "description": "Repository owner", + }, + "repo": map[string]interface{}{ + "type": "string", + "description": "Repository name", + }, + "job_id": map[string]interface{}{ + "type": "integer", + "description": "The job ID", + }, + }, + "required": []string{"owner", "repo", "job_id"}, + }, + }, + { + Name: "list_releases", + Description: "List releases for a repository", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "owner": map[string]interface{}{ + "type": "string", + "description": "Repository owner", + }, + "repo": map[string]interface{}{ + "type": "string", + "description": "Repository name", + }, + "limit": map[string]interface{}{ + "type": "integer", + "description": "Maximum number of releases to return (default 10)", + }, + }, + "required": []string{"owner", "repo"}, + }, + }, + { + Name: "get_release", + Description: "Get details of a specific release including all assets", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "owner": map[string]interface{}{ + "type": "string", + "description": "Repository owner", + }, + "repo": map[string]interface{}{ + "type": "string", + "description": "Repository name", + }, + "tag": map[string]interface{}{ + "type": "string", + "description": "Release tag (e.g., v1.0.0)", + }, + }, + "required": []string{"owner", "repo", "tag"}, + }, + }, +} + +// MCPHandler handles MCP protocol requests +// @Summary MCP Protocol Endpoint +// @Description Handles Model Context Protocol requests for AI tool integration +// @Tags mcp +// @Accept json +// @Produce json +// @Success 200 {object} MCPResponse +// @Router /mcp [post] +func MCPHandler(ctx *context.APIContext) { + body, err := io.ReadAll(ctx.Req.Body) + if err != nil { + sendMCPError(ctx, nil, -32700, "Parse error", err.Error()) + return + } + + var req MCPRequest + if err := json.Unmarshal(body, &req); err != nil { + sendMCPError(ctx, nil, -32700, "Parse error", err.Error()) + return + } + + if req.JSONRPC != "2.0" { + sendMCPError(ctx, req.ID, -32600, "Invalid Request", "jsonrpc must be 2.0") + return + } + + log.Debug("MCP request: method=%s id=%v", req.Method, req.ID) + + switch req.Method { + case "initialize": + handleInitialize(ctx, &req) + case "tools/list": + handleToolsList(ctx, &req) + case "tools/call": + handleToolsCall(ctx, &req) + case "ping": + sendMCPResult(ctx, req.ID, map[string]string{}) + default: + sendMCPError(ctx, req.ID, -32601, "Method not found", fmt.Sprintf("Unknown method: %s", req.Method)) + } +} + +func handleInitialize(ctx *context.APIContext, req *MCPRequest) { + result := MCPInitializeResult{ + ProtocolVersion: "2024-11-05", + Capabilities: map[string]interface{}{ + "tools": map[string]interface{}{}, + }, + ServerInfo: map[string]string{ + "name": "gitea-actions", + "version": setting.AppVer, + }, + } + sendMCPResult(ctx, req.ID, result) +} + +func handleToolsList(ctx *context.APIContext, req *MCPRequest) { + result := MCPToolsListResult{Tools: mcpTools} + sendMCPResult(ctx, req.ID, result) +} + +func handleToolsCall(ctx *context.APIContext, req *MCPRequest) { + var params MCPToolCallParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + sendMCPError(ctx, req.ID, -32602, "Invalid params", err.Error()) + return + } + + var result interface{} + var err error + + switch params.Name { + case "list_runners": + result, err = toolListRunners(ctx, params.Arguments) + case "get_runner": + result, err = toolGetRunner(ctx, params.Arguments) + case "list_workflow_runs": + result, err = toolListWorkflowRuns(ctx, params.Arguments) + case "get_workflow_run": + result, err = toolGetWorkflowRun(ctx, params.Arguments) + case "get_job_logs": + result, err = toolGetJobLogs(ctx, params.Arguments) + case "list_releases": + result, err = toolListReleases(ctx, params.Arguments) + case "get_release": + result, err = toolGetRelease(ctx, params.Arguments) + default: + sendMCPError(ctx, req.ID, -32602, "Unknown tool", params.Name) + return + } + + if err != nil { + sendMCPToolResult(ctx, req.ID, err.Error(), true) + return + } + + // Convert result to JSON text + jsonBytes, _ := json.MarshalIndent(result, "", " ") + sendMCPToolResult(ctx, req.ID, string(jsonBytes), false) +} + +func sendMCPResult(ctx *context.APIContext, id interface{}, result interface{}) { + ctx.JSON(http.StatusOK, MCPResponse{ + JSONRPC: "2.0", + ID: id, + Result: result, + }) +} + +func sendMCPError(ctx *context.APIContext, id interface{}, code int, message, data string) { + ctx.JSON(http.StatusOK, MCPResponse{ + JSONRPC: "2.0", + ID: id, + Error: &MCPError{ + Code: code, + Message: message, + Data: data, + }, + }) +} + +func sendMCPToolResult(ctx *context.APIContext, id interface{}, text string, isError bool) { + ctx.JSON(http.StatusOK, MCPResponse{ + JSONRPC: "2.0", + ID: id, + Result: MCPToolCallResult{ + Content: []MCPContent{{Type: "text", Text: text}}, + IsError: isError, + }, + }) +} + +// Tool implementations + +func toolListRunners(ctx *context.APIContext, args map[string]interface{}) (interface{}, error) { + var runners actions_model.RunnerList + var err error + + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + + if owner != "" && repo != "" { + // Get repo-specific runners + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + runners, err = actions_model.GetRunnersOfRepo(ctx, repository.ID) + if err != nil { + return nil, err + } + } else { + // Get all runners (admin) + opts := actions_model.FindRunnerOptions{} + opts.PageSize = 100 + runners, err = db.Find[actions_model.ActionRunner](ctx, opts) + if err != nil { + return nil, err + } + } + + statusFilter, _ := args["status"].(string) + + result := make([]map[string]interface{}, 0, len(runners)) + for _, runner := range runners { + isOnline := runner.IsOnline() + + if statusFilter == "online" && !isOnline { + continue + } + if statusFilter == "offline" && isOnline { + continue + } + + r := map[string]interface{}{ + "id": runner.ID, + "name": runner.Name, + "is_online": isOnline, + "status": runner.Status().String(), + "version": runner.Version, + "labels": runner.AgentLabels, + "last_online": runner.LastOnline.AsTime().Format(time.RFC3339), + } + + // Parse capabilities if available + if runner.CapabilitiesJSON != "" { + var caps api.RunnerCapability + if json.Unmarshal([]byte(runner.CapabilitiesJSON), &caps) == nil { + r["capabilities"] = caps + } + } + + result = append(result, r) + } + + return map[string]interface{}{ + "runners": result, + "count": len(result), + }, nil +} + +func toolGetRunner(ctx *context.APIContext, args map[string]interface{}) (interface{}, error) { + runnerIDFloat, ok := args["runner_id"].(float64) + if !ok { + return nil, fmt.Errorf("runner_id is required") + } + runnerID := int64(runnerIDFloat) + + runner, err := actions_model.GetRunnerByID(ctx, runnerID) + if err != nil { + return nil, fmt.Errorf("runner not found: %d", runnerID) + } + + result := map[string]interface{}{ + "id": runner.ID, + "name": runner.Name, + "is_online": runner.IsOnline(), + "status": runner.Status().String(), + "version": runner.Version, + "labels": runner.AgentLabels, + "last_online": runner.LastOnline.AsTime().Format(time.RFC3339), + "repo_id": runner.RepoID, + "owner_id": runner.OwnerID, + } + + if runner.CapabilitiesJSON != "" { + var caps api.RunnerCapability + if json.Unmarshal([]byte(runner.CapabilitiesJSON), &caps) == nil { + result["capabilities"] = caps + } + } + + return result, nil +} + +func toolListWorkflowRuns(ctx *context.APIContext, args map[string]interface{}) (interface{}, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + + if owner == "" || repo == "" { + return nil, fmt.Errorf("owner and repo are required") + } + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + limit := 20 + if l, ok := args["limit"].(float64); ok { + limit = int(l) + } + + opts := actions_model.FindRunOptions{ + RepoID: repository.ID, + } + opts.PageSize = limit + + runs, err := db.Find[actions_model.ActionRun](ctx, opts) + if err != nil { + return nil, err + } + + statusFilter, _ := args["status"].(string) + + result := make([]map[string]interface{}, 0, len(runs)) + for _, run := range runs { + status := run.Status.String() + + if statusFilter != "" && statusFilter != "all" && !strings.EqualFold(status, statusFilter) { + continue + } + + r := map[string]interface{}{ + "id": run.ID, + "title": run.Title, + "status": status, + "event": string(run.Event), + "workflow_id": run.WorkflowID, + "ref": run.Ref, + "commit_sha": run.CommitSHA, + "started": run.Started.AsTime().Format(time.RFC3339), + "stopped": run.Stopped.AsTime().Format(time.RFC3339), + } + + result = append(result, r) + } + + return map[string]interface{}{ + "runs": result, + "count": len(result), + "repo": fmt.Sprintf("%s/%s", owner, repo), + }, nil +} + +func toolGetWorkflowRun(ctx *context.APIContext, args map[string]interface{}) (interface{}, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + runIDFloat, ok := args["run_id"].(float64) + + if owner == "" || repo == "" || !ok { + return nil, fmt.Errorf("owner, repo, and run_id are required") + } + runID := int64(runIDFloat) + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + run, err := actions_model.GetRunByRepoAndID(ctx, repository.ID, runID) + if err != nil { + return nil, fmt.Errorf("run not found: %d", runID) + } + + // Get jobs for this run + jobs, err := actions_model.GetRunJobsByRunID(ctx, runID) + if err != nil { + return nil, err + } + + jobResults := make([]map[string]interface{}, 0, len(jobs)) + for _, job := range jobs { + j := map[string]interface{}{ + "id": job.ID, + "name": job.Name, + "status": job.Status.String(), + "started": job.Started.AsTime().Format(time.RFC3339), + "stopped": job.Stopped.AsTime().Format(time.RFC3339), + "task_id": job.TaskID, + } + jobResults = append(jobResults, j) + } + + return map[string]interface{}{ + "id": run.ID, + "title": run.Title, + "status": run.Status.String(), + "event": string(run.Event), + "workflow_id": run.WorkflowID, + "ref": run.Ref, + "commit_sha": run.CommitSHA, + "started": run.Started.AsTime().Format(time.RFC3339), + "stopped": run.Stopped.AsTime().Format(time.RFC3339), + "jobs": jobResults, + "job_count": len(jobResults), + }, nil +} + +func toolGetJobLogs(ctx *context.APIContext, args map[string]interface{}) (interface{}, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + jobIDFloat, ok := args["job_id"].(float64) + + if owner == "" || repo == "" || !ok { + return nil, fmt.Errorf("owner, repo, and job_id are required") + } + jobID := int64(jobIDFloat) + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + job, err := actions_model.GetRunJobByID(ctx, jobID) + if err != nil { + return nil, fmt.Errorf("job not found: %d", jobID) + } + + // Verify job belongs to this repo + run, err := actions_model.GetRunByRepoAndID(ctx, repository.ID, job.RunID) + if err != nil { + return nil, fmt.Errorf("job not found in repository") + } + _ = run // used for validation + + // Get log content - simplified for now, actual implementation needs log storage access + return map[string]interface{}{ + "job_id": jobID, + "job_name": job.Name, + "status": job.Status.String(), + "message": "Log retrieval requires task log storage access - see actions log storage", + }, nil +} + +func toolListReleases(ctx *context.APIContext, args map[string]interface{}) (interface{}, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + + if owner == "" || repo == "" { + return nil, fmt.Errorf("owner and repo are required") + } + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + limit := 10 + if l, ok := args["limit"].(float64); ok { + limit = int(l) + } + + opts := repo_model.FindReleasesOptions{ + RepoID: repository.ID, + } + opts.PageSize = limit + + releases, err := db.Find[repo_model.Release](ctx, opts) + if err != nil { + return nil, err + } + + result := make([]map[string]interface{}, 0, len(releases)) + for _, release := range releases { + r := map[string]interface{}{ + "id": release.ID, + "tag_name": release.TagName, + "title": release.Title, + "is_draft": release.IsDraft, + "is_prerelease": release.IsPrerelease, + "created_at": release.CreatedUnix.AsTime().Format(time.RFC3339), + } + result = append(result, r) + } + + return map[string]interface{}{ + "releases": result, + "count": len(result), + "repo": fmt.Sprintf("%s/%s", owner, repo), + }, nil +} + +func toolGetRelease(ctx *context.APIContext, args map[string]interface{}) (interface{}, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + tag, _ := args["tag"].(string) + + if owner == "" || repo == "" || tag == "" { + return nil, fmt.Errorf("owner, repo, and tag are required") + } + + repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + } + + release, err := repo_model.GetRelease(ctx, repository.ID, tag) + if err != nil { + return nil, fmt.Errorf("release not found: %s", tag) + } + + // Load attachments + if err := release.LoadAttributes(ctx); err != nil { + return nil, err + } + + assets := make([]map[string]interface{}, 0, len(release.Attachments)) + for _, att := range release.Attachments { + assets = append(assets, map[string]interface{}{ + "id": att.ID, + "name": att.Name, + "size": att.Size, + "download_count": att.DownloadCount, + "download_url": fmt.Sprintf("%s/%s/%s/releases/download/%s/%s", + setting.AppURL, owner, repo, tag, att.Name), + }) + } + + return map[string]interface{}{ + "id": release.ID, + "tag_name": release.TagName, + "title": release.Title, + "body": release.Note, + "is_draft": release.IsDraft, + "is_prerelease": release.IsPrerelease, + "created_at": release.CreatedUnix.AsTime().Format(time.RFC3339), + "assets": assets, + "asset_count": len(assets), + }, nil +} + +// Helper to parse int from interface - unused but kept for future use +func getIntArg(args map[string]interface{}, key string) (int64, bool) { + if v, ok := args[key].(float64); ok { + return int64(v), true + } + if v, ok := args[key].(string); ok { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i, true + } + } + return 0, false +}