feat(claude-do): MCP surface: worktree/diff/merge/log tools + status-enum doc

BUNDLE — all changes live in src/ClaudeDo.Worker/External/ExternalMcpService.cs only, so this is one worktree / one merge. Do NOT touch run-recording or data-layer code (those are separate tasks). Reuse the existing services behind the UI modals (WorktreesOverviewModalView, DiffModalView, MergeModalView) — do not reimplement git plumbing. Build green after each addition.

Add these MCP tools:
1. g

ClaudeDo-Task: f6bdfb5b-8cbf-4e65-93d4-6c758a160484
This commit is contained in:
Mika Kuns
2026-06-01 16:15:26 +02:00
parent 5170914a7a
commit c0978df19a
5 changed files with 477 additions and 48 deletions

View File

@@ -11,6 +11,12 @@ public sealed record RunDto(
int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut,
DateTime? StartedAt, DateTime? FinishedAt);
public sealed record TaskLogResult(
bool Available,
IReadOnlyList<string> Entries,
int TotalLines,
bool Truncated);
[McpServerToolType]
public sealed class RunHistoryMcpTools
{
@@ -33,26 +39,68 @@ public sealed class RunHistoryMcpTools
return ToDto(run);
}
private const int MaxLogBytes = 256 * 1024;
[McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")]
public async Task<string> GetTaskLog(string taskId, CancellationToken cancellationToken)
[McpServerTool, Description(
"Fetch log entries from a task's latest run. " +
"Returns { available, entries, totalLines, truncated }. " +
"available=false means no log exists yet (task is queued or just started — not an error). " +
"entries are the individual lines (NDJSON messages) from Claude's streaming output. " +
"Default: returns the last 50 entries (tail=50). " +
"tail: override the number of trailing entries to return. " +
"offset+limit: return entries starting at position offset (0-based); overrides tail when provided. " +
"truncated=true when fewer entries are returned than totalLines.")]
public async Task<TaskLogResult> GetTaskLog(
string taskId,
int? tail = null,
int? offset = null,
int? limit = null,
CancellationToken cancellationToken = default)
{
var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"No runs found for task {taskId}.");
if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
throw new InvalidOperationException("No log available for the latest run.");
var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken);
if (run is null || string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
return new TaskLogResult(false, [], 0, false);
var totalBytes = new FileInfo(run.LogPath).Length;
if (totalBytes <= MaxLogBytes)
return await File.ReadAllTextAsync(run.LogPath, cancellationToken);
string allText;
try
{
await using var fs = new FileStream(
run.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(fs);
allText = await reader.ReadToEndAsync(cancellationToken);
}
catch (IOException)
{
return new TaskLogResult(false, [], 0, false);
}
var buffer = new byte[MaxLogBytes];
await using var fs = new FileStream(run.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
fs.Seek(totalBytes - MaxLogBytes, SeekOrigin.Begin);
var read = await fs.ReadAsync(buffer, cancellationToken);
var tail = System.Text.Encoding.UTF8.GetString(buffer, 0, read);
return $"[truncated: showing last {MaxLogBytes} of {totalBytes} bytes]\n{tail}";
var lines = allText.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
var totalLines = lines.Length;
IReadOnlyList<string> entries;
bool truncated;
if (offset.HasValue || limit.HasValue)
{
var start = Math.Max(0, offset ?? 0);
var count = limit.HasValue ? Math.Min(limit.Value, totalLines - start) : totalLines - start;
entries = lines.Skip(start).Take(count).ToArray();
truncated = start > 0 || (start + count) < totalLines;
}
else
{
var take = tail ?? 50;
if (totalLines <= take)
{
entries = lines;
truncated = false;
}
else
{
entries = lines[^take..];
truncated = true;
}
}
return new TaskLogResult(true, entries, totalLines, truncated);
}
private static RunDto ToDto(TaskRunEntity r) => new(