Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ The following sets of tools are available (all are on by default):
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `return_content`: Returns actual log content instead of URLs (boolean, optional)
- `return_resource_links`: Returns MCP ResourceLinks for accessing logs instead of direct content or URLs (boolean, optional)
- `run_id`: Workflow run ID (required when using failed_only) (number, optional)
- `tail_lines`: Number of lines to return from the end of the log (number, optional)

Expand Down Expand Up @@ -841,6 +842,7 @@ The following sets of tools are available (all are on by default):
- `path`: Path to file/directory (directories must end with a slash '/') (string, optional)
- `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional)
- `repo`: Repository name (string, required)
- `return_resource_links`: Return ResourceLinks instead of file content - useful for large files or when you want to reference the file for later access (boolean, optional)
- `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional)

- **get_latest_release** - Get latest release
Expand Down
4 changes: 4 additions & 0 deletions pkg/github/__toolsnaps__/get_file_contents.snap
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
"description": "Repository name",
"type": "string"
},
"return_resource_links": {
"description": "Return ResourceLinks instead of file content - useful for large files or when you want to reference the file for later access",
"type": "boolean"
},
"sha": {
"description": "Accepts optional commit SHA. If specified, it will be used instead of ref",
"type": "string"
Expand Down
85 changes: 81 additions & 4 deletions pkg/github/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,9 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, con
mcp.WithBoolean("return_content",
mcp.Description("Returns actual log content instead of URLs"),
),
mcp.WithBoolean("return_resource_links",
mcp.Description("Returns MCP ResourceLinks for accessing logs instead of direct content or URLs"),
),
mcp.WithNumber("tail_lines",
mcp.Description("Number of lines to return from the end of the log"),
mcp.DefaultNumber(500),
Expand Down Expand Up @@ -590,6 +593,10 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, con
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
returnResourceLinks, err := OptionalParam[bool](request, "return_resource_links")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
tailLines, err := OptionalIntParam(request, "tail_lines")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
Expand All @@ -612,20 +619,32 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, con
return mcp.NewToolResultError("job_id is required when failed_only is false"), nil
}

// Validate that only one return mode is selected
returnModes := []bool{returnContent, returnResourceLinks}
activeModes := 0
for _, mode := range returnModes {
if mode {
activeModes++
}
}
if activeModes > 1 {
return mcp.NewToolResultError("Only one of return_content or return_resource_links can be true"), nil
}

if failedOnly && runID > 0 {
// Handle failed-only mode: get logs for all failed jobs in the workflow run
return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, contentWindowSize)
return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, returnResourceLinks, tailLines, contentWindowSize)
} else if jobID > 0 {
// Handle single job mode
return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, contentWindowSize)
return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, returnResourceLinks, tailLines, contentWindowSize)
}

return mcp.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil
}
}

// handleFailedJobLogs gets logs for all failed jobs in a workflow run
func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) {
func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, returnResourceLinks bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) {
// First, get all jobs for the workflow run
jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{
Filter: "latest",
Expand Down Expand Up @@ -654,6 +673,33 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo
return mcp.NewToolResultText(string(r)), nil
}

if returnResourceLinks {
// Return ResourceLinks for all failed job logs
var content []mcp.Content

// Add summary text
summaryText := fmt.Sprintf("Found %d failed jobs in workflow run %d. ResourceLinks provided below for accessing individual job logs.", len(failedJobs), runID)
content = append(content, mcp.TextContent{
Type: "text",
Text: summaryText,
})

// Add ResourceLinks for each failed job
for _, job := range failedJobs {
resourceLink := mcp.ResourceLink{
URI: fmt.Sprintf("actions://%s/%s/jobs/%d/logs", owner, repo, job.GetID()),
Name: fmt.Sprintf("failed-job-%d-logs", job.GetID()),
Description: fmt.Sprintf("Logs for failed job: %s (ID: %d)", job.GetName(), job.GetID()),
MIMEType: "text/plain",
}
content = append(content, resourceLink)
}

return &mcp.CallToolResult{
Content: content,
}, nil
}

// Collect logs for all failed jobs
var logResults []map[string]any
for _, job := range failedJobs {
Expand Down Expand Up @@ -690,7 +736,38 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo
}

// handleSingleJobLogs gets logs for a single job
func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) {
func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, returnResourceLinks bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) {
if returnResourceLinks {
// Return a ResourceLink for the job logs
resourceLink := mcp.ResourceLink{
URI: fmt.Sprintf("actions://%s/%s/jobs/%d/logs", owner, repo, jobID),
Name: fmt.Sprintf("job-%d-logs", jobID),
Description: fmt.Sprintf("Complete logs for job %d", jobID),
MIMEType: "text/plain",
}

result := map[string]any{
"message": "Job logs available via ResourceLink",
"job_id": jobID,
"resource_uri": resourceLink.URI,
}

r, err := json.Marshal(result)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}

return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: string(r),
},
resourceLink,
},
}, nil
}

jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines, contentWindowSize)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil
Expand Down
195 changes: 195 additions & 0 deletions pkg/github/actions_resource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
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
}
36 changes: 36 additions & 0 deletions pkg/github/actions_resource_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package github

import (
"testing"

"github.com/github/github-mcp-server/pkg/translations"
"github.com/stretchr/testify/assert"
)

func TestGetJobLogsWithResourceLinks(t *testing.T) {
// Test that the tool has the new parameter
tool, _ := GetJobLogs(stubGetClientFn(nil), translations.NullTranslationHelper, 1000)

// Verify tool has the new parameter
schema := tool.InputSchema
assert.Contains(t, schema.Properties, "return_resource_links")

// Check that the parameter exists (we can't easily check types in this interface)
resourceLinkParam := schema.Properties["return_resource_links"]
assert.NotNil(t, resourceLinkParam)
}

func TestJobLogsResourceCreation(t *testing.T) {
// Test that we can create the resource templates without errors
jobLogsResource, jobLogsHandler := GetJobLogsResource(stubGetClientFn(nil), translations.NullTranslationHelper)
workflowRunLogsResource, workflowRunLogsHandler := GetWorkflowRunLogsResource(stubGetClientFn(nil), translations.NullTranslationHelper)

// Verify resource templates are created
assert.NotNil(t, jobLogsResource)
assert.NotNil(t, jobLogsHandler)
assert.Equal(t, "actions://{owner}/{repo}/jobs/{jobId}/logs", jobLogsResource.URITemplate.Raw())

assert.NotNil(t, workflowRunLogsResource)
assert.NotNil(t, workflowRunLogsHandler)
assert.Equal(t, "actions://{owner}/{repo}/runs/{runId}/logs", workflowRunLogsResource.URITemplate.Raw())
}
Loading
Loading