Files
github--github-mcp-server/pkg/github/actions_resource.go
2025-08-29 14:45:19 +01:00

196 lines
6.4 KiB
Go

package github
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"github.com/github/github-mcp-server/internal/profiler"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// GetWorkflowRunLogsResource defines the resource template and handler for getting workflow run logs.
func GetWorkflowRunLogsResource(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
return mcp.NewResourceTemplate(
"actions://{owner}/{repo}/runs/{runId}/logs", // Resource template
t("RESOURCE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Workflow Run Logs"),
),
WorkflowRunLogsResourceHandler(getClient)
}
// GetJobLogsResource defines the resource template and handler for getting individual job logs.
func GetJobLogsResource(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
return mcp.NewResourceTemplate(
"actions://{owner}/{repo}/jobs/{jobId}/logs", // Resource template
t("RESOURCE_JOB_LOGS_DESCRIPTION", "Job Logs"),
),
JobLogsResourceHandler(getClient)
}
// WorkflowRunLogsResourceHandler returns a handler function for workflow run logs requests.
func WorkflowRunLogsResourceHandler(getClient GetClientFn) func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
// Parse parameters from the URI template matcher
owner, ok := request.Params.Arguments["owner"].([]string)
if !ok || len(owner) == 0 {
return nil, errors.New("owner is required")
}
repo, ok := request.Params.Arguments["repo"].([]string)
if !ok || len(repo) == 0 {
return nil, errors.New("repo is required")
}
runIDStr, ok := request.Params.Arguments["runId"].([]string)
if !ok || len(runIDStr) == 0 {
return nil, errors.New("runId is required")
}
runID, err := strconv.ParseInt(runIDStr[0], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid runId: %w", err)
}
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
// Get the JIT URL for workflow run logs
url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner[0], repo[0], runID, 1)
if err != nil {
return nil, fmt.Errorf("failed to get workflow run logs URL: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// Download the logs content immediately using the JIT URL
content, err := downloadLogsFromJITURL(ctx, url.String())
if err != nil {
return nil, fmt.Errorf("failed to download workflow run logs: %w", err)
}
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: request.Params.URI,
MIMEType: "application/zip",
Text: fmt.Sprintf("Workflow run logs for run %d (ZIP archive)\n\nNote: This is a ZIP archive containing all job logs. Download URL was: %s\n\nContent length: %d bytes", runID, url.String(), len(content)),
},
}, nil
}
}
// JobLogsResourceHandler returns a handler function for individual job logs requests.
func JobLogsResourceHandler(getClient GetClientFn) func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
// Parse parameters from the URI template matcher
owner, ok := request.Params.Arguments["owner"].([]string)
if !ok || len(owner) == 0 {
return nil, errors.New("owner is required")
}
repo, ok := request.Params.Arguments["repo"].([]string)
if !ok || len(repo) == 0 {
return nil, errors.New("repo is required")
}
jobIDStr, ok := request.Params.Arguments["jobId"].([]string)
if !ok || len(jobIDStr) == 0 {
return nil, errors.New("jobId is required")
}
jobID, err := strconv.ParseInt(jobIDStr[0], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid jobId: %w", err)
}
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
// Get the JIT URL for job logs
url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner[0], repo[0], jobID, 1)
if err != nil {
return nil, fmt.Errorf("failed to get job logs URL: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// Download the logs content immediately using the JIT URL
content, err := downloadLogsFromJITURL(ctx, url.String())
if err != nil {
return nil, fmt.Errorf("failed to download job logs: %w", err)
}
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: request.Params.URI,
MIMEType: "text/plain",
Text: content,
},
}, nil
}
}
// downloadLogsFromJITURL downloads content from a GitHub JIT URL
func downloadLogsFromJITURL(ctx context.Context, jitURL string) (string, error) {
prof := profiler.New(nil, profiler.IsProfilingEnabled())
finish := prof.Start(ctx, "download_jit_logs")
httpResp, err := http.Get(jitURL) //nolint:gosec
if err != nil {
_ = finish(0, 0)
return "", fmt.Errorf("failed to download from JIT URL: %w", err)
}
defer httpResp.Body.Close()
if httpResp.StatusCode != http.StatusOK {
_ = finish(0, 0)
return "", fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode)
}
// For large files, we should limit the content size to avoid memory issues
const maxContentSize = 10 * 1024 * 1024 // 10MB limit
// Read the content with a size limit
content := make([]byte, 0, 1024*1024) // Start with 1MB capacity
buffer := make([]byte, 32*1024) // 32KB read buffer
totalRead := 0
for {
n, err := httpResp.Body.Read(buffer)
if n > 0 {
if totalRead+n > maxContentSize {
// Truncate if content is too large
remaining := maxContentSize - totalRead
content = append(content, buffer[:remaining]...)
content = append(content, []byte(fmt.Sprintf("\n\n[Content truncated - original size exceeded %d bytes]", maxContentSize))...)
break
}
content = append(content, buffer[:n]...)
totalRead += n
}
if err != nil {
if err.Error() == "EOF" {
break
}
_ = finish(0, int64(totalRead))
return "", fmt.Errorf("failed to read response body: %w", err)
}
}
// Count lines for profiler
lines := 1
for _, b := range content {
if b == '\n' {
lines++
}
}
_ = finish(lines, int64(len(content)))
return string(content), nil
}