Skip to content

Commit 7c67d9c

Browse files
committed
feat: Add return_resource_links parameter to GetFileContents and GetJobLogs for accessing logs via ResourceLinks
1 parent 0418808 commit 7c67d9c

File tree

8 files changed

+505
-4
lines changed

8 files changed

+505
-4
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ The following sets of tools are available (all are on by default):
328328
- `owner`: Repository owner (string, required)
329329
- `repo`: Repository name (string, required)
330330
- `return_content`: Returns actual log content instead of URLs (boolean, optional)
331+
- `return_resource_links`: Returns MCP ResourceLinks for accessing logs instead of direct content or URLs (boolean, optional)
331332
- `run_id`: Workflow run ID (required when using failed_only) (number, optional)
332333
- `tail_lines`: Number of lines to return from the end of the log (number, optional)
333334

@@ -841,6 +842,7 @@ The following sets of tools are available (all are on by default):
841842
- `path`: Path to file/directory (directories must end with a slash '/') (string, optional)
842843
- `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional)
843844
- `repo`: Repository name (string, required)
845+
- `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)
844846
- `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional)
845847

846848
- **get_latest_release** - Get latest release

pkg/github/__toolsnaps__/get_file_contents.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
"description": "Repository name",
2424
"type": "string"
2525
},
26+
"return_resource_links": {
27+
"description": "Return ResourceLinks instead of file content - useful for large files or when you want to reference the file for later access",
28+
"type": "boolean"
29+
},
2630
"sha": {
2731
"description": "Accepts optional commit SHA. If specified, it will be used instead of ref",
2832
"type": "string"

pkg/github/actions.go

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,9 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, con
558558
mcp.WithBoolean("return_content",
559559
mcp.Description("Returns actual log content instead of URLs"),
560560
),
561+
mcp.WithBoolean("return_resource_links",
562+
mcp.Description("Returns MCP ResourceLinks for accessing logs instead of direct content or URLs"),
563+
),
561564
mcp.WithNumber("tail_lines",
562565
mcp.Description("Number of lines to return from the end of the log"),
563566
mcp.DefaultNumber(500),
@@ -590,6 +593,10 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, con
590593
if err != nil {
591594
return mcp.NewToolResultError(err.Error()), nil
592595
}
596+
returnResourceLinks, err := OptionalParam[bool](request, "return_resource_links")
597+
if err != nil {
598+
return mcp.NewToolResultError(err.Error()), nil
599+
}
593600
tailLines, err := OptionalIntParam(request, "tail_lines")
594601
if err != nil {
595602
return mcp.NewToolResultError(err.Error()), nil
@@ -612,20 +619,32 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, con
612619
return mcp.NewToolResultError("job_id is required when failed_only is false"), nil
613620
}
614621

622+
// Validate that only one return mode is selected
623+
returnModes := []bool{returnContent, returnResourceLinks}
624+
activeModes := 0
625+
for _, mode := range returnModes {
626+
if mode {
627+
activeModes++
628+
}
629+
}
630+
if activeModes > 1 {
631+
return mcp.NewToolResultError("Only one of return_content or return_resource_links can be true"), nil
632+
}
633+
615634
if failedOnly && runID > 0 {
616635
// Handle failed-only mode: get logs for all failed jobs in the workflow run
617-
return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, contentWindowSize)
636+
return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, returnResourceLinks, tailLines, contentWindowSize)
618637
} else if jobID > 0 {
619638
// Handle single job mode
620-
return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, contentWindowSize)
639+
return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, returnResourceLinks, tailLines, contentWindowSize)
621640
}
622641

623642
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
624643
}
625644
}
626645

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

676+
if returnResourceLinks {
677+
// Return ResourceLinks for all failed job logs
678+
var content []mcp.Content
679+
680+
// Add summary text
681+
summaryText := fmt.Sprintf("Found %d failed jobs in workflow run %d. ResourceLinks provided below for accessing individual job logs.", len(failedJobs), runID)
682+
content = append(content, mcp.TextContent{
683+
Type: "text",
684+
Text: summaryText,
685+
})
686+
687+
// Add ResourceLinks for each failed job
688+
for _, job := range failedJobs {
689+
resourceLink := mcp.ResourceLink{
690+
URI: fmt.Sprintf("actions://%s/%s/jobs/%d/logs", owner, repo, job.GetID()),
691+
Name: fmt.Sprintf("failed-job-%d-logs", job.GetID()),
692+
Description: fmt.Sprintf("Logs for failed job: %s (ID: %d)", job.GetName(), job.GetID()),
693+
MIMEType: "text/plain",
694+
}
695+
content = append(content, resourceLink)
696+
}
697+
698+
return &mcp.CallToolResult{
699+
Content: content,
700+
}, nil
701+
}
702+
657703
// Collect logs for all failed jobs
658704
var logResults []map[string]any
659705
for _, job := range failedJobs {
@@ -690,7 +736,38 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo
690736
}
691737

692738
// handleSingleJobLogs gets logs for a single job
693-
func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) {
739+
func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, returnResourceLinks bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) {
740+
if returnResourceLinks {
741+
// Return a ResourceLink for the job logs
742+
resourceLink := mcp.ResourceLink{
743+
URI: fmt.Sprintf("actions://%s/%s/jobs/%d/logs", owner, repo, jobID),
744+
Name: fmt.Sprintf("job-%d-logs", jobID),
745+
Description: fmt.Sprintf("Complete logs for job %d", jobID),
746+
MIMEType: "text/plain",
747+
}
748+
749+
result := map[string]any{
750+
"message": "Job logs available via ResourceLink",
751+
"job_id": jobID,
752+
"resource_uri": resourceLink.URI,
753+
}
754+
755+
r, err := json.Marshal(result)
756+
if err != nil {
757+
return nil, fmt.Errorf("failed to marshal response: %w", err)
758+
}
759+
760+
return &mcp.CallToolResult{
761+
Content: []mcp.Content{
762+
mcp.TextContent{
763+
Type: "text",
764+
Text: string(r),
765+
},
766+
resourceLink,
767+
},
768+
}, nil
769+
}
770+
694771
jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines, contentWindowSize)
695772
if err != nil {
696773
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil

pkg/github/actions_resource.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"strconv"
9+
10+
"github.com/github/github-mcp-server/internal/profiler"
11+
"github.com/github/github-mcp-server/pkg/translations"
12+
"github.com/mark3labs/mcp-go/mcp"
13+
"github.com/mark3labs/mcp-go/server"
14+
)
15+
16+
// GetWorkflowRunLogsResource defines the resource template and handler for getting workflow run logs.
17+
func GetWorkflowRunLogsResource(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
18+
return mcp.NewResourceTemplate(
19+
"actions://{owner}/{repo}/runs/{runId}/logs", // Resource template
20+
t("RESOURCE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Workflow Run Logs"),
21+
),
22+
WorkflowRunLogsResourceHandler(getClient)
23+
}
24+
25+
// GetJobLogsResource defines the resource template and handler for getting individual job logs.
26+
func GetJobLogsResource(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
27+
return mcp.NewResourceTemplate(
28+
"actions://{owner}/{repo}/jobs/{jobId}/logs", // Resource template
29+
t("RESOURCE_JOB_LOGS_DESCRIPTION", "Job Logs"),
30+
),
31+
JobLogsResourceHandler(getClient)
32+
}
33+
34+
// WorkflowRunLogsResourceHandler returns a handler function for workflow run logs requests.
35+
func WorkflowRunLogsResourceHandler(getClient GetClientFn) func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
36+
return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
37+
// Parse parameters from the URI template matcher
38+
owner, ok := request.Params.Arguments["owner"].([]string)
39+
if !ok || len(owner) == 0 {
40+
return nil, errors.New("owner is required")
41+
}
42+
43+
repo, ok := request.Params.Arguments["repo"].([]string)
44+
if !ok || len(repo) == 0 {
45+
return nil, errors.New("repo is required")
46+
}
47+
48+
runIdStr, ok := request.Params.Arguments["runId"].([]string)
49+
if !ok || len(runIdStr) == 0 {
50+
return nil, errors.New("runId is required")
51+
}
52+
53+
runId, err := strconv.ParseInt(runIdStr[0], 10, 64)
54+
if err != nil {
55+
return nil, fmt.Errorf("invalid runId: %w", err)
56+
}
57+
58+
client, err := getClient(ctx)
59+
if err != nil {
60+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
61+
}
62+
63+
// Get the JIT URL for workflow run logs
64+
url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner[0], repo[0], runId, 1)
65+
if err != nil {
66+
return nil, fmt.Errorf("failed to get workflow run logs URL: %w", err)
67+
}
68+
defer func() { _ = resp.Body.Close() }()
69+
70+
// Download the logs content immediately using the JIT URL
71+
content, err := downloadLogsFromJITURL(ctx, url.String())
72+
if err != nil {
73+
return nil, fmt.Errorf("failed to download workflow run logs: %w", err)
74+
}
75+
76+
return []mcp.ResourceContents{
77+
mcp.TextResourceContents{
78+
URI: request.Params.URI,
79+
MIMEType: "application/zip",
80+
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)),
81+
},
82+
}, nil
83+
}
84+
}
85+
86+
// JobLogsResourceHandler returns a handler function for individual job logs requests.
87+
func JobLogsResourceHandler(getClient GetClientFn) func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
88+
return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
89+
// Parse parameters from the URI template matcher
90+
owner, ok := request.Params.Arguments["owner"].([]string)
91+
if !ok || len(owner) == 0 {
92+
return nil, errors.New("owner is required")
93+
}
94+
95+
repo, ok := request.Params.Arguments["repo"].([]string)
96+
if !ok || len(repo) == 0 {
97+
return nil, errors.New("repo is required")
98+
}
99+
100+
jobIdStr, ok := request.Params.Arguments["jobId"].([]string)
101+
if !ok || len(jobIdStr) == 0 {
102+
return nil, errors.New("jobId is required")
103+
}
104+
105+
jobId, err := strconv.ParseInt(jobIdStr[0], 10, 64)
106+
if err != nil {
107+
return nil, fmt.Errorf("invalid jobId: %w", err)
108+
}
109+
110+
client, err := getClient(ctx)
111+
if err != nil {
112+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
113+
}
114+
115+
// Get the JIT URL for job logs
116+
url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner[0], repo[0], jobId, 1)
117+
if err != nil {
118+
return nil, fmt.Errorf("failed to get job logs URL: %w", err)
119+
}
120+
defer func() { _ = resp.Body.Close() }()
121+
122+
// Download the logs content immediately using the JIT URL
123+
content, err := downloadLogsFromJITURL(ctx, url.String())
124+
if err != nil {
125+
return nil, fmt.Errorf("failed to download job logs: %w", err)
126+
}
127+
128+
return []mcp.ResourceContents{
129+
mcp.TextResourceContents{
130+
URI: request.Params.URI,
131+
MIMEType: "text/plain",
132+
Text: content,
133+
},
134+
}, nil
135+
}
136+
}
137+
138+
// downloadLogsFromJITURL downloads content from a GitHub JIT URL
139+
func downloadLogsFromJITURL(ctx context.Context, jitURL string) (string, error) {
140+
prof := profiler.New(nil, profiler.IsProfilingEnabled())
141+
finish := prof.Start(ctx, "download_jit_logs")
142+
143+
httpResp, err := http.Get(jitURL) //nolint:gosec
144+
if err != nil {
145+
_ = finish(0, 0)
146+
return "", fmt.Errorf("failed to download from JIT URL: %w", err)
147+
}
148+
defer httpResp.Body.Close()
149+
150+
if httpResp.StatusCode != http.StatusOK {
151+
_ = finish(0, 0)
152+
return "", fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode)
153+
}
154+
155+
// For large files, we should limit the content size to avoid memory issues
156+
const maxContentSize = 10 * 1024 * 1024 // 10MB limit
157+
158+
// Read the content with a size limit
159+
content := make([]byte, 0, 1024*1024) // Start with 1MB capacity
160+
buffer := make([]byte, 32*1024) // 32KB read buffer
161+
totalRead := 0
162+
163+
for {
164+
n, err := httpResp.Body.Read(buffer)
165+
if n > 0 {
166+
if totalRead+n > maxContentSize {
167+
// Truncate if content is too large
168+
remaining := maxContentSize - totalRead
169+
content = append(content, buffer[:remaining]...)
170+
content = append(content, []byte(fmt.Sprintf("\n\n[Content truncated - original size exceeded %d bytes]", maxContentSize))...)
171+
break
172+
}
173+
content = append(content, buffer[:n]...)
174+
totalRead += n
175+
}
176+
if err != nil {
177+
if err.Error() == "EOF" {
178+
break
179+
}
180+
_ = finish(0, int64(totalRead))
181+
return "", fmt.Errorf("failed to read response body: %w", err)
182+
}
183+
}
184+
185+
// Count lines for profiler
186+
lines := 1
187+
for _, b := range content {
188+
if b == '\n' {
189+
lines++
190+
}
191+
}
192+
193+
_ = finish(lines, int64(len(content)))
194+
return string(content), nil
195+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package github
2+
3+
import (
4+
"testing"
5+
6+
"github.com/github/github-mcp-server/pkg/translations"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestGetJobLogsWithResourceLinks(t *testing.T) {
11+
// Test that the tool has the new parameter
12+
tool, _ := GetJobLogs(stubGetClientFn(nil), translations.NullTranslationHelper, 1000)
13+
14+
// Verify tool has the new parameter
15+
schema := tool.InputSchema
16+
assert.Contains(t, schema.Properties, "return_resource_links")
17+
18+
// Check that the parameter exists (we can't easily check types in this interface)
19+
resourceLinkParam := schema.Properties["return_resource_links"]
20+
assert.NotNil(t, resourceLinkParam)
21+
}
22+
23+
func TestJobLogsResourceCreation(t *testing.T) {
24+
// Test that we can create the resource templates without errors
25+
jobLogsResource, jobLogsHandler := GetJobLogsResource(stubGetClientFn(nil), translations.NullTranslationHelper)
26+
workflowRunLogsResource, workflowRunLogsHandler := GetWorkflowRunLogsResource(stubGetClientFn(nil), translations.NullTranslationHelper)
27+
28+
// Verify resource templates are created
29+
assert.NotNil(t, jobLogsResource)
30+
assert.NotNil(t, jobLogsHandler)
31+
assert.Equal(t, "actions://{owner}/{repo}/jobs/{jobId}/logs", jobLogsResource.URITemplate.Raw())
32+
33+
assert.NotNil(t, workflowRunLogsResource)
34+
assert.NotNil(t, workflowRunLogsHandler)
35+
assert.Equal(t, "actions://{owner}/{repo}/runs/{runId}/logs", workflowRunLogsResource.URITemplate.Raw())
36+
}

0 commit comments

Comments
 (0)