feat(mcp): download log content for self-hosted jobs in jobs_logs#863
Open
cchristous wants to merge 13 commits intosemaphoreio:mainfrom
Open
feat(mcp): download log content for self-hosted jobs in jobs_logs#863cchristous wants to merge 13 commits intosemaphoreio:mainfrom
cchristous wants to merge 13 commits intosemaphoreio:mainfrom
Conversation
The jobs_logs MCP tool previously returned only a JWT token and URL for self-hosted jobs, requiring the caller to manually curl the URL. Now fetchSelfHostedLogs makes an HTTP GET to the logs endpoint, parses the JSON events, and returns a paginated log preview — the same experience as hosted jobs. If the download fails (network error, non-200 status, parse error), it falls back gracefully to the existing token/URL behavior.
…nload Pass context.Context to downloadSelfHostedLogs so the HTTP request respects parent cancellation/deadlines, and return a descriptive error when the response exceeds the 10 MiB safety cap instead of falling through to a confusing JSON parse failure.
Add in-memory cache (60s TTL) keyed by job ID to avoid re-downloading the full log response on every pagination call. Also: reuse a shared HTTP client for connection pooling, fix LimitReader off-by-one to accept exactly 10 MiB responses, improve the cursor=0 truncation message, extract test helpers to reduce boilerplate, and document the intentional token omission from markdown when preview succeeds.
Skip the gRPC GenerateToken call on cache hits by checking the cache at the top of fetchSelfHostedLogs, avoiding unnecessary latency during pagination. Bound the in-memory cache to 10 entries (~100 MiB worst case) with LRU-by-expiry eviction. Add clarifying comments for the intentional empty-result cache skip, the truncated flag behavior on explicit cursors, and the test parallel-safety constraint.
- Extract shared pagination into computePaginationWindow helper - Replace downloadLogsFn package-level var with logDownloader parameter injection - Add expired-entry sweep in getCachedLogLines matching cacheLogLines pattern - Use explicit int64 cast on maxLogDownloadBytes comparison - Store token metadata in cache so cached responses include Token/LogsURL - Remove dead start <= 0 guards in format functions - Add cursorBeyondEndReturnsEmptyPreview tests for both hosted and self-hosted - Refactor newSelfHostedTestEnv to use t.Cleanup instead of manual cleanup func
Use atomic.Int32 for downloadCount in cache test to avoid a data race between the httptest handler goroutine and the test goroutine. Add a comment noting that cached tokenTtlSeconds reflects issuance time, not remaining lifetime. Fix computePaginationWindow to only report truncated when the window genuinely doesn't cover all available lines.
… harden tests - Only cache self-hosted log downloads for finished jobs; running jobs re-download on each call so new output is picked up immediately. - Show a distinct "no output yet" message when log download succeeds but returns empty events, instead of falling back to token/curl instructions. - Assert the logs URL passed to the test downloader matches expectations. - Store tokenTypeToString result in a local variable to avoid redundant call.
- Use errors.New for errLogResponseTooLarge sentinel (item 6) - Clamp NextCursor to last real page when cursor is beyond total lines, so self-hosted pagination redirects to actual data instead of another empty page (item 3) - Extract writePreviewMarkdown helper to deduplicate preview rendering between hosted and self-hosted formatters (item 4) - Skip log cache on the read path for running jobs, matching the existing write-side bypass for consistency (item 1)
…stant Drain the HTTP response body on non-200 status in downloadSelfHostedLogs so the shared selfHostedHTTPClient can reuse the TCP connection. Derive the errLogResponseTooLarge message from maxLogDownloadBytes instead of hardcoding the byte count.
…pping When a cursor points past all available log lines, the markdown now shows "Cursor is past the end" with a redirect cursor instead of misleading "no output yet" (hosted) or token/URL fallback (self-hosted). Also adds a comment explaining the virtualTotal/sliceOffset mapping in fetchHostedLogs.
DamjanBecirovic
approved these changes
Mar 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The
jobs_logsMCP tool previously had split behavior:curlthe URLThis forced MCP clients (like Claude Code) to fall back to Bash
curlto view self-hosted logs, defeating the purpose of having an MCP tool.This PR makes
fetchSelfHostedLogsdownload the log content via HTTP after generating the token, parse the JSON events response, and return a paginated preview — the same experience as hosted jobs. If the download fails for any reason (network error, non-200 status, JSON parse error), it falls back gracefully to the existing token/URL-only behavior with a logged warning.Changes
mcp_server/pkg/tools/jobs/logs.gocomputePaginationWindowhelper that computes slice indices, display metadata, and next cursor — used by bothfetchHostedLogsandpaginateSelfHostedLinesto eliminate duplicated windowing logiccomputePaginationWindowonly reportsTruncated = truewhen the window genuinely doesn't cover all available lines (start > 0 || end < totalLines), rather than unconditionally when a cursor is providedlogDownloaderfunction type and threaded it throughlogsHandler→fetchSelfHostedLogsas a parameter, replacing the previousdownloadLogsFnpackage-level variable (enables clean test injection without global mutation)downloadSelfHostedLogsfunction that GETs the logs URL, parses{"events": [{"output": "..."}]}JSON, and returns linesio.LimitReaderwith 10 MiB cap to prevent OOM from unexpectedly large responses — readsmaxLogDownloadBytes+1so that responses of exactly 10 MiB are accepted while anything larger is rejected with a descriptiveerrLogResponseTooLargeerror; uses explicitint64cast on both sides of the size comparison; the error message is derived frommaxLogDownloadBytesto stay in sync if the constant changesdownloadSelfHostedLogsacceptscontext.Contextand useshttp.NewRequestWithContextso the HTTP download respects parent cancellation/deadlinesselfHostedHTTPClientfor connection pooling instead of creating a newhttp.Clientper call; on non-200 responses the body is drained before returning the error so the TCP connection can be reusedlogCache) keyed by job ID with 60-second TTL — avoids re-downloading the full log response on every pagination call, since self-hosted logs are fetched via a single HTTP GET with no server-side windowing; only finished jobs are cached — running jobs re-download on each call so new output is picked up immediatelytoken,tokenType,tokenTtlSeconds,logsURL) so cached responses include the same fields as fresh responses; note thattokenTtlSecondsreflects the original TTL at issuance, not the remaining lifetime (the 60s cache TTL is well within the 300s token TTL so the token is always still valid)getCachedLogLinesandcacheLogLinesperform an expired-entry sweep on each call for consistent cleanupGenerateTokencall, so paginated requests that hit the cache skip the gRPC round-trip entirely (the token from the initial cache-miss call has a 300s TTL, well exceeding the 60s cache TTL)DownloadEmptyfield tologsResult— when the log download succeeds but returns no events,formatSelfHostedLogsMarkdownnow shows a distinct "no output yet, retry shortly" message instead of falling back to the token/curl instructionsfetchSelfHostedLogsto acceptstartingLinefor pagination andjobFinishedto control caching behaviorformatSelfHostedLogsMarkdownto show log preview when available, empty-download message when download succeeded but returned no lines, and token/URL fallback otherwise. When atcursor=0the truncation message now says "This is the beginning of the log" instead of the misleading "No further logs are available"if start <= 0 { start = 0 }guards from bothformatHostedLogsMarkdownandformatSelfHostedLogsMarkdown—StartLineis always >= 0logsFullDescriptionto reflect unified behaviortokenTypeToStringresult in a local variable to avoid redundant callmcp_server/pkg/tools/jobs/register.goRegisterto passdownloadSelfHostedLogsas thelogDownloaderargument tologsHandlermcp_server/pkg/tools/jobs/jobs_test.gonewSelfHostedTestEnvhelper that creates the full test environment (httptest server, stubs, provider, handler, cache reset) andcallLogsHandlerhelper for making requests — reduces ~40-60 lines of boilerplate per testnewSelfHostedTestEnvusest.Cleanupfor automatic teardown and passes a test downloader directly tologsHandler— no global state mutation, safe for parallel usenewSelfHostedTestEnvdefaults job state toFINISHED; acceptsselfHostedTestOptto override (e.g.STARTEDfor running-job tests)selfHostedTestJobID,selfHostedTestOrgID,selfHostedTestUser)TestFetchSelfHostedLogsto use helpers and verify preview lines are returnedTestSelfHostedLogsDownloadFailureFallback— HTTP 500 gracefully falls back to token/URLTestSelfHostedLogsEmptyDownloadFallback— empty events response shows distinct "no output yet" message and setsDownloadEmpty=trueTestSelfHostedLogsPagination— 5 sub-tests mirroring hosted pagination test cases (including out-of-range cursor)TestDownloadSelfHostedLogs— 5 sub-tests for the download function (valid, empty, error, bad JSON, response too large)TestSelfHostedLogsCacheAvoidsDuplicateDownload— usesatomic.Int32for the download counter to avoid a data race between the httptest handler goroutine and the test goroutine; verifies the HTTP server is only hit once across two handler calls for the same job, and asserts cached response includes Token/LogsURL metadataTestSelfHostedRunningJobSkipsCache— verifies that aSTARTEDjob downloads logs on every call (no caching), confirming running jobs always get fresh outputcursorBeyondEndReturnsEmptyPreviewtest case to bothTestFetchHostedLogsPaginationandTestSelfHostedLogsPagination— verifies a cursor past the end returns an empty preview with the correct backward cursorSecurity considerations
io.LimitReaderto prevent OOM, with a clear error when the cap is hithttp.NewRequestWithContextto respect parent context cancellationselfHostedHTTPClienthas a 30-second timeout as a safety net; response bodies are drained on non-200 status to allow connection reuseStructuredContentfor programmatic MCP client accessTest plan
go test ./pkg/tools/jobs/... -v -count=1)go vet ./pkg/tools/jobs/...cleango build ./...clean